Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue28
-rw-r--r--app/assets/javascripts/diffs/store/utils.js4
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss33
-rw-r--r--app/assets/stylesheets/framework/diffs.scss18
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss8
-rw-r--r--app/assets/stylesheets/framework/toggle.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/merge_conflicts.scss4
-rw-r--r--app/assets/stylesheets/pages/commits.scss6
-rw-r--r--app/assets/stylesheets/pages/notes.scss15
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb4
-rw-r--r--app/controllers/registrations_controller.rb12
-rw-r--r--app/graphql/queries/epic/epic_children.query.graphql126
-rw-r--r--app/graphql/queries/epic/epic_details.query.graphql20
-rw-r--r--app/graphql/resolvers/base_resolver.rb4
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb2
-rw-r--r--app/graphql/resolvers/issues_resolver.rb2
-rw-r--r--app/models/project.rb15
-rw-r--r--app/models/project_services/jenkins_service.rb91
-rw-r--r--app/models/service.rb6
-rw-r--r--app/models/user.rb2
-rw-r--r--changelogs/unreleased/281953-fix-file-header-line-height.yml5
-rw-r--r--changelogs/unreleased/283947-fj-track-sse-edit-in-api.yml5
-rw-r--r--changelogs/unreleased/37797-jenkins-to-core.yml5
-rw-r--r--changelogs/unreleased/add-sidekiq-dead-job-metrics.yml5
-rw-r--r--changelogs/unreleased/add_design_to_git_transfer_in_progress.yml6
-rw-r--r--changelogs/unreleased/update_gitaly_gem_13_6_1.yml5
-rw-r--r--config/initializers/sidekiq.rb2
-rw-r--r--config/routes/repository_scoped.rb1
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md1
-rw-r--r--doc/development/graphql_guide/pagination.md16
-rw-r--r--doc/development/product_analytics/snowplow.md2
-rw-r--r--doc/downgrade_ee_to_ce/README.md22
-rw-r--r--doc/integration/README.md2
-rw-r--r--doc/integration/jenkins.md16
-rw-r--r--lib/api/helpers/services_helpers.rb27
-rw-r--r--lib/api/helpers/sse_helpers.rb16
-rw-r--r--lib/api/merge_requests.rb3
-rw-r--r--lib/gitlab/sidekiq_death_handler.rb19
-rw-r--r--lib/gitlab/sidekiq_middleware/client_metrics.rb4
-rw-r--r--lib/gitlab/sidekiq_middleware/metrics_helper.rb (renamed from lib/gitlab/sidekiq_middleware/metrics.rb)2
-rw-r--r--lib/gitlab/sidekiq_middleware/server_metrics.rb4
-rw-r--r--lib/gitlab/tracking.rb8
-rw-r--r--lib/gitlab/tracking/destinations/snowplow.rb4
-rw-r--r--lib/gitlab/usage_data.rb1
-rw-r--r--lib/gitlab/usage_data_counters/editor_unique_counter.rb9
-rw-r--r--lib/gitlab/usage_data_counters/known_events/common.yml6
-rw-r--r--qa/qa.rb6
-rw-r--r--qa/qa/page/project/settings/services/jenkins.rb54
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/jenkins/jenkins_build_status_spec.rb156
-rw-r--r--scripts/rspec_helpers.sh10
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb15
-rw-r--r--spec/factories/usage_data.rb7
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb1
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js6
-rw-r--r--spec/frontend/diffs/store/utils_spec.js17
-rw-r--r--spec/graphql/resolvers/base_resolver_spec.rb8
-rw-r--r--spec/lib/api/helpers/sse_helpers_spec.rb44
-rw-r--r--spec/lib/gitlab/sidekiq_death_handler_spec.rb50
-rw-r--r--spec/lib/gitlab/tracking/destinations/snowplow_spec.rb4
-rw-r--r--spec/lib/gitlab/tracking_spec.rb4
-rw-r--r--spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb12
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb10
-rw-r--r--spec/models/project_services/jenkins_service_spec.rb255
-rw-r--r--spec/models/project_spec.rb36
-rw-r--r--spec/models/service_spec.rb6
-rw-r--r--spec/requests/api/merge_requests_spec.rb48
-rw-r--r--spec/services/projects/hashed_storage/migrate_repository_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/rollback_repository_service_spec.rb2
-rw-r--r--spec/support/helpers/usage_data_helpers.rb1
-rw-r--r--spec/support/shared_contexts/services_shared_context.rb3
-rw-r--r--spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb82
-rwxr-xr-xtooling/bin/parallel_rspec19
-rw-r--r--tooling/lib/tooling/parallel_rspec_runner.rb78
77 files changed, 1337 insertions, 213 deletions
diff --git a/Gemfile b/Gemfile
index 293111d1d4b..092c668290b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -465,7 +465,7 @@ group :ed25519 do
end
# Gitaly GRPC protocol definitions
-gem 'gitaly', '~> 13.5.0-rc2'
+gem 'gitaly', '~> 13.6.1'
gem 'grpc', '~> 1.30.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index f16173de975..5107036c7fa 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -420,7 +420,7 @@ GEM
rails (>= 3.2.0)
git (1.7.0)
rchardet (~> 1.8)
- gitaly (13.5.0.pre.rc2)
+ gitaly (13.6.1)
grpc (~> 1.0)
github-markup (1.7.0)
gitlab-chronic (0.10.5)
@@ -1345,7 +1345,7 @@ DEPENDENCIES
gettext (~> 3.3)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly (~> 13.5.0.pre.rc2)
+ gitaly (~> 13.6.1)
github-markup (~> 1.7.0)
gitlab-chronic (~> 0.10.5)
gitlab-fog-azure-rm (~> 1.0)
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 77a97c67f3b..c0719e2a7d9 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -157,10 +157,10 @@ export default {
"
/>
</div>
- <div :class="classNameMapCellLeft" class="diff-td diff-line-num old_line">
+ <div v-if="inline" :class="classNameMapCellLeft" class="diff-td diff-line-num old_line">
<a
- v-if="line.left.old_line"
- :data-linenumber="line.left.old_line"
+ v-if="line.left.new_line"
+ :data-linenumber="line.left.new_line"
:href="line.lineHrefOld"
@click="setHighlightedRow(line.lineCode)"
>
@@ -179,21 +179,14 @@ export default {
</template>
<template v-else>
<div data-testid="leftEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div>
- <div class="diff-td diff-line-num old_line empty-cell"></div>
+ <div v-if="inline" class="diff-td diff-line-num old_line empty-cell"></div>
<div class="diff-td line-coverage left-side empty-cell"></div>
<div class="diff-td line_content with-coverage parallel left-side empty-cell"></div>
</template>
</div>
- <div
- v-if="!inline || (line.right && Boolean(line.right.type))"
- class="diff-grid-right right-side"
- >
+ <div v-if="!inline" class="diff-grid-right right-side">
<template v-if="line.right">
- <div
- :class="classNameMapCellRight"
- data-testid="rightLineNumber"
- class="diff-td diff-line-num new_line"
- >
+ <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
<span
v-if="shouldRenderCommentButton"
v-gl-tooltip
@@ -231,15 +224,6 @@ export default {
"
/>
</div>
- <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
- <a
- v-if="line.right.new_line"
- :data-linenumber="line.right.new_line"
- :href="line.lineHrefNew"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- </div>
<div
v-gl-tooltip.hover
:title="coverageState.text"
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 509a89d52f6..8f32952676a 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -47,7 +47,7 @@ export const parallelizeDiffLines = (diffLines, inline) => {
for (let i = 0, diffLinesLength = diffLines.length, index = 0; i < diffLinesLength; i += 1) {
const line = diffLines[i];
- if (isRemoved(line)) {
+ if (isRemoved(line) || inline) {
lines.push({
[LINE_POSITION_LEFT]: line,
[LINE_POSITION_RIGHT]: null,
@@ -59,7 +59,7 @@ export const parallelizeDiffLines = (diffLines, inline) => {
}
index += 1;
} else if (isAdded(line)) {
- if (freeRightIndex !== null && !inline) {
+ if (freeRightIndex !== null) {
// If an old line came before this without a line on the right, this
// line can be put to the right of it.
lines[freeRightIndex].right = line;
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
index 8a955cffc49..3b68e3d2dc8 100644
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ b/app/assets/stylesheets/fontawesome_custom.scss
@@ -51,35 +51,6 @@
text-align: center;
}
-.fa-spin {
- -webkit-animation: fa-spin 2s infinite linear;
- animation: fa-spin 2s infinite linear;
-}
-
-@-webkit-keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
- }
-
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
- }
-}
-
-@keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
- }
-
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
- }
-}
-
.fa-inverse {
color: $white;
}
@@ -97,10 +68,6 @@
content: '\f071';
}
-.fa-spinner::before {
- content: '\f110';
-}
-
.fa-caret-right::before {
content: '\f0da';
}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index a9925fb3621..5b4475a8000 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -597,10 +597,6 @@ table.code {
.diff-grid-right {
display: grid;
grid-template-columns: 50px 8px 1fr;
-
- .diff-td:nth-child(2) {
- display: none;
- }
}
.diff-grid-comments {
@@ -631,20 +627,6 @@ table.code {
.diff-grid-left,
.diff-grid-right {
grid-template-columns: 50px 50px 8px 1fr;
-
- .diff-td:nth-child(2) {
- display: block;
- }
- }
-
- .diff-grid-left .old:nth-child(1) [data-linenumber],
- .diff-grid-right .new:nth-child(2) [data-linenumber] {
- display: inline;
- }
-
- .diff-grid-left .old:nth-child(2) [data-linenumber],
- .diff-grid-right .new:nth-child(1) [data-linenumber] {
- display: none;
}
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 2094c824286..c9c1e46233b 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -143,15 +143,9 @@
.fa {
position: absolute;
-
- &.fa-spinner {
- font-size: 16px;
- margin-top: -3px;
- }
}
- .fa-chevron-down,
- .fa-spinner {
+ .fa-chevron-down {
position: absolute;
top: 11px;
right: 8px;
diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss
index 054280f3321..fd888fdec65 100644
--- a/app/assets/stylesheets/framework/toggle.scss
+++ b/app/assets/stylesheets/framework/toggle.scss
@@ -4,22 +4,22 @@
* @usage
* ### Active and Inactive text should be provided as data attributes:
* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Checked should have `is-checked` class
* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Disabled should have `is-disabled` class
* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Loading should have `is-loading` and an icon with `loading-icon` class
* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon"></i>
+* <span class="gl-spinner loading-icon" aria-label="Loading"></span>
* </button>
*/
.project-feature-toggle {
diff --git a/app/assets/stylesheets/page_bundles/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
index b0655408edf..a26affb10a9 100644
--- a/app/assets/stylesheets/page_bundles/merge_conflicts.scss
+++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
@@ -255,10 +255,6 @@ $colors: (
}
}
- .btn-success .fa-spinner {
- color: var(--white, $white);
- }
-
.editor-wrap {
&.is-loading {
.editor {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 17474b95e50..9b17da80023 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -174,12 +174,6 @@
}
.commit-actions {
- @include media-breakpoint-up(sm) {
- .fa-spinner {
- font-size: 12px;
- }
- }
-
.ci-status-icon svg {
vertical-align: text-bottom;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index e23ec25a2f3..45f8a4782e6 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -190,8 +190,7 @@ $note-form-margin-left: 72px;
border: 1px solid darken($gray-100, 25%);
}
- .note-headline-light,
- .fa-spinner {
+ .note-headline-light {
margin-left: 3px;
}
}
@@ -249,16 +248,6 @@ $note-form-margin-left: 72px;
.note-emoji-button {
position: relative;
line-height: 1;
-
- .fa-spinner {
- display: none;
- }
-
- &.is-loading {
- .fa-spinner {
- display: inline-block;
- }
- }
}
}
@@ -407,8 +396,6 @@ $note-form-margin-left: 72px;
.discussion-body .diff-file {
.file-title {
cursor: default;
- line-height: 42px;
- padding: 0 $gl-padding;
border-top: 1px solid $border-color;
border-radius: 0;
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index 5c3d9b60877..0d9a6f568a1 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -19,6 +19,10 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
feature_category :static_site_editor
+ def index
+ render_404
+ end
+
def show
service_response = ::StaticSiteEditor::ConfigService.new(
container: project,
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 04cb9616cf6..e7872eeac27 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -6,8 +6,6 @@ class RegistrationsController < Devise::RegistrationsController
include RecaptchaHelper
include InvisibleCaptchaOnSignup
- BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
-
layout 'devise'
prepend_before_action :check_captcha, only: :create
@@ -167,12 +165,18 @@ class RegistrationsController < Devise::RegistrationsController
end
def set_user_state
- return unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup
+ return unless set_blocked_pending_approval?
+
+ resource.state = User::BLOCKED_PENDING_APPROVAL_STATE
+ end
- resource.state = BLOCKED_PENDING_APPROVAL_STATE
+ def set_blocked_pending_approval?
+ Gitlab::CurrentSettings.require_admin_approval_after_user_signup
end
def set_invite_params
@invite_email = ActionController::Base.helpers.sanitize(params[:invite_email])
end
end
+
+RegistrationsController.prepend_if_ee('EE::RegistrationsController')
diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql
new file mode 100644
index 00000000000..c12778109d0
--- /dev/null
+++ b/app/graphql/queries/epic/epic_children.query.graphql
@@ -0,0 +1,126 @@
+fragment PageInfo on PageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+}
+
+fragment RelatedTreeBaseEpic on Epic {
+ id
+ iid
+ title
+ webPath
+ relativePosition
+ userPermissions {
+ __typename
+ adminEpic
+ createEpic
+ }
+ descendantCounts {
+ __typename
+ openedEpics
+ closedEpics
+ openedIssues
+ closedIssues
+ }
+ healthStatus {
+ __typename
+ issuesAtRisk
+ issuesOnTrack
+ issuesNeedingAttention
+ }
+}
+
+fragment EpicNode on Epic {
+ ...RelatedTreeBaseEpic
+ state
+ reference(full: true)
+ relationPath
+ createdAt
+ closedAt
+ hasChildren
+ hasIssues
+ group {
+ __typename
+ fullPath
+ }
+}
+
+query childItems(
+ $fullPath: ID!
+ $iid: ID
+ $pageSize: Int = 100
+ $epicEndCursor: String = ""
+ $issueEndCursor: String = ""
+) {
+ group(fullPath: $fullPath) {
+ __typename
+ id
+ path
+ fullPath
+ epic(iid: $iid) {
+ __typename
+ ...RelatedTreeBaseEpic
+ children(first: $pageSize, after: $epicEndCursor) {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ ...EpicNode
+ }
+ }
+ pageInfo {
+ __typename
+ ...PageInfo
+ }
+ }
+ issues(first: $pageSize, after: $issueEndCursor) {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ iid
+ epicIssueId
+ title
+ closedAt
+ state
+ createdAt
+ confidential
+ dueDate
+ weight
+ webPath
+ reference(full: true)
+ relationPath
+ relativePosition
+ assignees {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ webUrl
+ name
+ username
+ avatarUrl
+ }
+ }
+ }
+ milestone {
+ __typename
+ title
+ startDate
+ dueDate
+ }
+ healthStatus
+ }
+ }
+ pageInfo {
+ __typename
+ ...PageInfo
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/queries/epic/epic_details.query.graphql b/app/graphql/queries/epic/epic_details.query.graphql
new file mode 100644
index 00000000000..406d630b180
--- /dev/null
+++ b/app/graphql/queries/epic/epic_details.query.graphql
@@ -0,0 +1,20 @@
+query epicDetails($fullPath: ID!, $iid: ID!) {
+ group(fullPath: $fullPath) {
+ __typename
+ epic(iid: $iid) {
+ __typename
+ participants {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ name
+ avatarUrl
+ webUrl
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 87a63231b22..68d61b1532f 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -109,6 +109,10 @@ module Resolvers
[args[:iid], args[:iids]].any? ? 0 : 0.01
end
+ def offset_pagination(relation)
+ ::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(relation)
+ end
+
override :object
def object
super.tap do |obj|
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index 3421e1024c0..3e4a5a3cb70 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
+ offset_pagination(service.execute)
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index ef12dfa19ff..0a43d6f3a16 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -27,7 +27,7 @@ module Resolvers
List.preload_preferences_for_user(lists, context[:current_user])
end
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists)
+ offset_pagination(lists)
end
private
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index dd35219454f..26092cc12ec 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -24,7 +24,7 @@ module Resolvers
if non_stable_cursor_sort?(args[:sort])
# Certain complex sorts are not supported by the stable cursor pagination yet.
# In these cases, we use offset pagination, so we return the correct connection.
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues)
+ offset_pagination(issues)
else
issues
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 0ec37fb9be6..234126cde89 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -64,6 +64,8 @@ class Project < ApplicationRecord
SORTING_PREFERENCE_FIELD = :projects_sort
MAX_BUILD_TIMEOUT = 1.month
+ GL_REPOSITORY_TYPES = [Gitlab::GlRepository::PROJECT, Gitlab::GlRepository::WIKI, Gitlab::GlRepository::DESIGN].freeze
+
cache_markdown_field :description, pipeline: :description
default_value_for :packages_enabled, true
@@ -164,6 +166,7 @@ class Project < ApplicationRecord
has_one :bamboo_service
has_one :teamcity_service
has_one :pushover_service
+ has_one :jenkins_service
has_one :jira_service
has_one :redmine_service
has_one :youtrack_service
@@ -2275,7 +2278,9 @@ class Project < ApplicationRecord
end
def git_transfer_in_progress?
- repo_reference_count > 0 || wiki_reference_count > 0
+ GL_REPOSITORY_TYPES.any? do |type|
+ reference_counter(type: type).value > 0
+ end
end
def storage_version=(value)
@@ -2608,14 +2613,6 @@ class Project < ApplicationRecord
end
end
- def repo_reference_count
- reference_counter.value
- end
-
- def wiki_reference_count
- reference_counter(type: Gitlab::GlRepository::WIKI).value
- end
-
def check_repository_absence!
return if skip_disk_validation
diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb
new file mode 100644
index 00000000000..63ecfc66877
--- /dev/null
+++ b/app/models/project_services/jenkins_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+class JenkinsService < CiService
+ prop_accessor :jenkins_url, :project_name, :username, :password
+
+ before_update :reset_password
+
+ validates :jenkins_url, presence: true, addressable_url: true, if: :activated?
+ validates :project_name, presence: true, if: :activated?
+ validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? }
+
+ default_value_for :push_events, true
+ default_value_for :merge_requests_events, false
+ default_value_for :tag_push_events, false
+
+ after_save :compose_service_hook, if: :activated?
+
+ def reset_password
+ # don't reset the password if a new one is provided
+ if (jenkins_url_changed? || username.blank?) && !password_touched?
+ self.password = nil
+ end
+ end
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.url = hook_url
+ hook.save
+ end
+
+ def execute(data)
+ return if project.disabled_services.include?(to_param)
+ return unless supported_events.include?(data[:object_kind])
+
+ service_hook.execute(data, "#{data[:object_kind]}_hook")
+ end
+
+ def test(data)
+ begin
+ result = execute(data)
+ return { success: false, result: result[:message] } if result[:http_status] != 200
+ rescue StandardError => error
+ return { success: false, result: error }
+ end
+
+ { success: true, result: result[:message] }
+ end
+
+ def hook_url
+ url = URI.parse(jenkins_url)
+ url.path = File.join(url.path || '/', "project/#{project_name}")
+ url.user = ERB::Util.url_encode(username) unless username.blank?
+ url.password = ERB::Util.url_encode(password) unless password.blank?
+ url.to_s
+ end
+
+ def self.supported_events
+ %w(push merge_request tag_push)
+ end
+
+ def title
+ 'Jenkins CI'
+ end
+
+ def description
+ 'An extendable open source continuous integration server'
+ end
+
+ def help
+ "You must have installed the Git Plugin and GitLab Plugin in Jenkins. [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/jenkins')})"
+ end
+
+ def self.to_param
+ 'jenkins'
+ end
+
+ def fields
+ [
+ {
+ type: 'text', name: 'jenkins_url',
+ placeholder: 'Jenkins URL like http://jenkins.example.com'
+ },
+ {
+ type: 'text', name: 'project_name', placeholder: 'Project Name',
+ help: 'The URL-friendly project name. Example: my_project_name'
+ },
+ { type: 'text', name: 'username' },
+ { type: 'password', name: 'password' }
+ ]
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 2b6971954e3..f6b98387b98 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -17,6 +17,10 @@ class Service < ApplicationRecord
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
+ PROJECT_SPECIFIC_SERVICE_NAMES = %w[
+ jenkins
+ ].freeze
+
# Fake services to help with local development.
DEV_SERVICE_NAMES = %w[
mock_ci mock_deployment mock_monitoring
@@ -212,7 +216,7 @@ class Service < ApplicationRecord
end
def self.project_specific_services_names
- []
+ PROJECT_SPECIFIC_SERVICE_NAMES
end
def self.available_services_types(include_project_specific: true, include_dev: true)
diff --git a/app/models/user.rb b/app/models/user.rb
index db62211444c..33a6eec473c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -30,6 +30,8 @@ class User < ApplicationRecord
INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
+ BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
+
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token
add_authentication_token_field :static_object_token
diff --git a/changelogs/unreleased/281953-fix-file-header-line-height.yml b/changelogs/unreleased/281953-fix-file-header-line-height.yml
new file mode 100644
index 00000000000..d29932bef41
--- /dev/null
+++ b/changelogs/unreleased/281953-fix-file-header-line-height.yml
@@ -0,0 +1,5 @@
+---
+title: Fix incorrect line height in file header
+merge_request: 48117
+author:
+type: fixed
diff --git a/changelogs/unreleased/283947-fj-track-sse-edit-in-api.yml b/changelogs/unreleased/283947-fj-track-sse-edit-in-api.yml
new file mode 100644
index 00000000000..1af7c923360
--- /dev/null
+++ b/changelogs/unreleased/283947-fj-track-sse-edit-in-api.yml
@@ -0,0 +1,5 @@
+---
+title: Track MAU for SSE edit
+merge_request: 48377
+author:
+type: added
diff --git a/changelogs/unreleased/37797-jenkins-to-core.yml b/changelogs/unreleased/37797-jenkins-to-core.yml
new file mode 100644
index 00000000000..35c665d3e21
--- /dev/null
+++ b/changelogs/unreleased/37797-jenkins-to-core.yml
@@ -0,0 +1,5 @@
+---
+title: Move Jenkins to Core
+merge_request: 37797
+author: Ben Bodenmiller (@bbodenmiller)
+type: changed
diff --git a/changelogs/unreleased/add-sidekiq-dead-job-metrics.yml b/changelogs/unreleased/add-sidekiq-dead-job-metrics.yml
new file mode 100644
index 00000000000..231bc064bcb
--- /dev/null
+++ b/changelogs/unreleased/add-sidekiq-dead-job-metrics.yml
@@ -0,0 +1,5 @@
+---
+title: Add metric for dead Sidekiq jobs
+merge_request: 48361
+author:
+type: changed
diff --git a/changelogs/unreleased/add_design_to_git_transfer_in_progress.yml b/changelogs/unreleased/add_design_to_git_transfer_in_progress.yml
new file mode 100644
index 00000000000..2817ab9335d
--- /dev/null
+++ b/changelogs/unreleased/add_design_to_git_transfer_in_progress.yml
@@ -0,0 +1,6 @@
+---
+title: Consider design repositories when determining if there is a git transfer in
+ progress
+merge_request: 48304
+author:
+type: fixed
diff --git a/changelogs/unreleased/update_gitaly_gem_13_6_1.yml b/changelogs/unreleased/update_gitaly_gem_13_6_1.yml
new file mode 100644
index 00000000000..929afb22950
--- /dev/null
+++ b/changelogs/unreleased/update_gitaly_gem_13_6_1.yml
@@ -0,0 +1,5 @@
+---
+title: Update gitaly gem to 13.6.1
+merge_request: 48601
+author:
+type: other
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 8e3241a2e4c..43beae3f50d 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -46,6 +46,8 @@ Sidekiq.configure_server do |config|
config.client_middleware(&Gitlab::SidekiqMiddleware.client_configurator)
+ config.death_handlers << Gitlab::SidekiqDeathHandler.method(:handler)
+
config.on :startup do
# Clear any connections that might have been obtained before starting
# Sidekiq (e.g. in an initializer).
diff --git a/config/routes/repository_scoped.rb b/config/routes/repository_scoped.rb
index 865a5bdb5a9..7fabf3ff895 100644
--- a/config/routes/repository_scoped.rb
+++ b/config/routes/repository_scoped.rb
@@ -34,6 +34,7 @@ scope format: false do
scope constraints: { id: /[^\0]+?/ } do
scope controller: :static_site_editor do
get '/sse/:id(/*vueroute)', action: :show, as: :show_sse
+ get '/sse', as: :root_sse, action: :index
end
end
end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 82a171103bc..ac339bae2a9 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -308,6 +308,8 @@
- 2
- - service_desk_email_receiver
- 1
+- - set_user_status_based_on_user_cap_setting
+ - 1
- - status_page_publish
- 1
- - sync_seat_link_request
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
index 6c15067c02d..8692fdbbd5a 100644
--- a/doc/administration/monitoring/prometheus/gitlab_metrics.md
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -142,6 +142,7 @@ configuration option in `gitlab.yml`. These metrics are served from the
| `sidekiq_jobs_queue_duration_seconds` | Histogram | 12.5 | Duration in seconds that a Sidekiq job was queued before being executed | `queue`, `boundary`, `external_dependencies`, `feature_category`, `urgency` |
| `sidekiq_jobs_failed_total` | Counter | 12.2 | Sidekiq jobs failed | `queue`, `boundary`, `external_dependencies`, `feature_category`, `urgency` |
| `sidekiq_jobs_retried_total` | Counter | 12.2 | Sidekiq jobs retried | `queue`, `boundary`, `external_dependencies`, `feature_category`, `urgency` |
+| `sidekiq_jobs_dead_total` | Counter | 13.7 | Sidekiq dead jobs (jobs that have run out of retries) | `queue`, `boundary`, `external_dependencies`, `feature_category`, `urgency` |
| `sidekiq_redis_requests_total` | Counter | 13.1 | Redis requests during a Sidekiq job execution | `queue`, `boundary`, `external_dependencies`, `feature_category`, `job_status`, `urgency` |
| `sidekiq_elasticsearch_requests_total` | Counter | 13.1 | Elasticsearch requests during a Sidekiq job execution | `queue`, `boundary`, `external_dependencies`, `feature_category`, `job_status`, `urgency` |
| `sidekiq_running_jobs` | Gauge | 12.2 | Number of Sidekiq jobs running | `queue`, `boundary`, `external_dependencies`, `feature_category`, `urgency` |
diff --git a/doc/development/graphql_guide/pagination.md b/doc/development/graphql_guide/pagination.md
index 14314ac1432..1f659bffab3 100644
--- a/doc/development/graphql_guide/pagination.md
+++ b/doc/development/graphql_guide/pagination.md
@@ -86,6 +86,20 @@ However, there are some cases where we have to use the offset
pagination connection, `OffsetActiveRecordRelationConnection`, such as when
sorting by label priority in issues, due to the complexity of the sort.
+If you return a relation from a resolver that is not suitable for keyset
+pagination (due to the sort order for example), then you can use the
+`BaseResolver#offset_pagination` method to wrap the relation in the correct
+connection type. For example:
+
+```ruby
+def resolve(**args)
+ result = Finder.new(object, current_user, args).execute
+ result = offset_pagination(result) if needs_offset?(args[:sort])
+
+ result
+end
+```
+
### Keyset pagination
The keyset pagination implementation is a subclass of `GraphQL::Pagination::ActiveRecordRelationConnection`,
@@ -225,7 +239,7 @@ instead of an `ActiveRecord::Relation`:
if non_stable_cursor_sort?(args[:sort])
# Certain complex sorts are not supported by the stable cursor pagination yet.
# In these cases, we use offset pagination, so we return the correct connection.
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues)
+ offset_pagination(issues)
else
issues
end
diff --git a/doc/development/product_analytics/snowplow.md b/doc/development/product_analytics/snowplow.md
index 193c4f3b9d8..20786a63b54 100644
--- a/doc/development/product_analytics/snowplow.md
+++ b/doc/development/product_analytics/snowplow.md
@@ -421,7 +421,7 @@ Snowplow Micro is a Docker-based solution for testing frontend and backend event
1. Send a test Snowplow event from the Rails console:
```ruby
- Gitlab::Tracking.self_describing_event('iglu:com.gitlab/pageview_context/jsonschema/1-0-0', { page_type: 'MY_TYPE' }, context: nil )
+ Gitlab::Tracking.self_describing_event('iglu:com.gitlab/pageview_context/jsonschema/1-0-0', data: { page_type: 'MY_TYPE' }, context: nil)
```
### Snowplow Mini
diff --git a/doc/downgrade_ee_to_ce/README.md b/doc/downgrade_ee_to_ce/README.md
index 5d54fd5cf98..b0842a77ae6 100644
--- a/doc/downgrade_ee_to_ce/README.md
+++ b/doc/downgrade_ee_to_ce/README.md
@@ -23,23 +23,12 @@ alternative authentication methods to your users.
### Remove Service Integration entries from the database
-The `JenkinsService` and `GithubService` classes are only available in the Enterprise Edition codebase,
+The `GithubService` class is only available in the Enterprise Edition codebase,
so if you downgrade to the Community Edition, the following error displays:
```plaintext
Completed 500 Internal Server Error in 497ms (ActiveRecord: 32.2ms)
-ActionView::Template::Error (The single-table inheritance mechanism failed to locate the subclass: 'JenkinsService'. This
-error is raised because the column 'type' is reserved for storing the class in case of inheritance. Please rename this
-column if you didn't intend it to be used for storing the inheritance class or overwrite Service.inheritance_column to
-use another column for that information.)
-```
-
-or
-
-```plaintext
-Completed 500 Internal Server Error in 497ms (ActiveRecord: 32.2ms)
-
ActionView::Template::Error (The single-table inheritance mechanism failed to locate the subclass: 'GithubService'. This
error is raised because the column 'type' is reserved for storing the class in case of inheritance. Please rename this
column if you didn't intend it to be used for storing the inheritance class or overwrite Service.inheritance_column to
@@ -48,22 +37,23 @@ use another column for that information.)
All services are created automatically for every project you have, so in order
to avoid getting this error, you need to remove all instances of the
-`JenkinsService` and `GithubService` from your database:
+`GithubService` from your database:
**Omnibus Installation**
```shell
-sudo gitlab-rails runner "Service.where(type: ['JenkinsService', 'GithubService']).delete_all"
+sudo gitlab-rails runner "Service.where(type: ['GithubService']).delete_all"
```
**Source Installation**
```shell
-bundle exec rails runner "Service.where(type: ['JenkinsService', 'GithubService']).delete_all" production
+bundle exec rails runner "Service.where(type: ['GithubService']).delete_all" production
```
NOTE: **Note:**
-If you are running `GitLab =< v13.0` you need to also remove `JenkinsDeprecatedService` records.
+If you are running `GitLab =< v13.0` you need to also remove `JenkinsDeprecatedService` records
+and if you are running `GitLab =< v13.6` you need to also remove `JenkinsService` records.
### Variables environment scopes
diff --git a/doc/integration/README.md b/doc/integration/README.md
index 3a906904b11..396090eb420 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -43,7 +43,7 @@ GitLab also provides features to improve the security of your own application. F
GitLab can be integrated with the following external service for continuous integration:
-- [Jenkins](jenkins.md) CI. **(STARTER)**
+- [Jenkins](jenkins.md) CI.
## Feature enhancements
diff --git a/doc/integration/jenkins.md b/doc/integration/jenkins.md
index 70008b6e22f..13d6c9f7101 100644
--- a/doc/integration/jenkins.md
+++ b/doc/integration/jenkins.md
@@ -4,7 +4,7 @@ group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
-# Jenkins CI service **(STARTER)**
+# Jenkins CI service
NOTE: **Note:**
This documentation focuses only on how to **configure** a Jenkins *integration* with
@@ -73,11 +73,11 @@ Create a personal access token to authorize Jenkins' access to GitLab.
1. Click **Access Tokens** in the sidebar.
1. Create a personal access token with the **API** scope checkbox checked. For more details, see
[Personal access tokens](../user/profile/personal_access_tokens.md).
-1. Record the personal access token's value, because it's required in [Configure the Jenkins server](#configure-the-jenkins-server).
+1. Record the personal access token's value, because it's required in [Configure the Jenkins server](#configure-the-jenkins-server) section.
## Configure the Jenkins server
-Install and configure the Jenkins plugins. Both plugins must be installed and configured to
+Install and configure the Jenkins plugin. The plugin must be installed and configured to
authorize the connection to GitLab.
1. On the Jenkins server, go to **Manage Jenkins > Manage Plugins**.
@@ -137,6 +137,8 @@ Set up the Jenkins project you intend to run your build on.
Configure the GitLab integration with Jenkins.
+### Option 1: Jenkins integration (recommended)
+
1. Create a new GitLab project or choose an existing one.
1. Go to **Settings > Integrations**, then select **Jenkins CI**.
1. Turn on the **Active** toggle.
@@ -154,6 +156,14 @@ Configure the GitLab integration with Jenkins.
authentication.
1. Click **Test settings and save changes**. GitLab tests the connection to Jenkins.
+### Option 2: Webhook
+
+1. In the configuration of your Jenkins job, in the GitLab configuration section, click **Advanced**.
+1. Click the **Generate** button under the **Secret Token** field.
+1. Copy the resulting token, and save the job configuration.
+1. In GitLab, create a webhook for your project, enter the trigger URL (e.g. `https://JENKINS_URL/project/YOUR_JOB`) and paste the token in the **Secret Token** field.
+1. After you add the webhook, click the **Test** button, and it should succeed.
+
## Troubleshooting
### Error in merge requests - "Could not connect to the CI server"
diff --git a/lib/api/helpers/services_helpers.rb b/lib/api/helpers/services_helpers.rb
index 4adb27a7414..7bac27d1235 100644
--- a/lib/api/helpers/services_helpers.rb
+++ b/lib/api/helpers/services_helpers.rb
@@ -459,6 +459,32 @@ module API
desc: 'Colorize messages'
}
],
+ 'jenkins' => [
+ {
+ required: true,
+ name: :jenkins_url,
+ type: String,
+ desc: 'Jenkins root URL like https://jenkins.example.com'
+ },
+ {
+ required: true,
+ name: :project_name,
+ type: String,
+ desc: 'The URL-friendly project name. Example: my_project_name'
+ },
+ {
+ required: false,
+ name: :username,
+ type: String,
+ desc: 'A user with access to the Jenkins server, if applicable'
+ },
+ {
+ required: false,
+ name: :password,
+ type: String,
+ desc: 'The password of the user'
+ }
+ ],
'jira' => [
{
required: true,
@@ -767,6 +793,7 @@ module API
::HangoutsChatService,
::HipchatService,
::IrkerService,
+ ::JenkinsService,
::JiraService,
::MattermostSlashCommandsService,
::SlackSlashCommandsService,
diff --git a/lib/api/helpers/sse_helpers.rb b/lib/api/helpers/sse_helpers.rb
new file mode 100644
index 00000000000..c354694f508
--- /dev/null
+++ b/lib/api/helpers/sse_helpers.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module API
+ module Helpers
+ module SSEHelpers
+ def request_from_sse?(project)
+ return false if request.referer.blank?
+
+ uri = URI.parse(request.referer)
+ uri.path.starts_with?(::Gitlab::Routing.url_helpers.project_root_sse_path(project))
+ rescue URI::InvalidURIError
+ false
+ end
+ end
+ end
+end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index d17e451093b..ab0e9b95e4a 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -11,6 +11,7 @@ module API
feature_category :code_review
helpers Helpers::MergeRequestsHelpers
+ helpers Helpers::SSEHelpers
# EE::API::MergeRequests would override the following helpers
helpers do
@@ -216,6 +217,8 @@ module API
handle_merge_request_errors!(merge_request)
+ Gitlab::UsageDataCounters::EditorUniqueCounter.track_sse_edit_action(author: current_user) if request_from_sse?(user_project)
+
present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end
diff --git a/lib/gitlab/sidekiq_death_handler.rb b/lib/gitlab/sidekiq_death_handler.rb
new file mode 100644
index 00000000000..f86d9f17b5f
--- /dev/null
+++ b/lib/gitlab/sidekiq_death_handler.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module SidekiqDeathHandler
+ class << self
+ include ::Gitlab::SidekiqMiddleware::MetricsHelper
+
+ def handler(job, _exception)
+ labels = create_labels(job['class'].constantize, job['queue'])
+
+ counter.increment(labels)
+ end
+
+ def counter
+ @counter ||= ::Gitlab::Metrics.counter(:sidekiq_jobs_dead_total, 'Sidekiq dead jobs')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/sidekiq_middleware/client_metrics.rb b/lib/gitlab/sidekiq_middleware/client_metrics.rb
index 245a1b5e024..7ee8a623d30 100644
--- a/lib/gitlab/sidekiq_middleware/client_metrics.rb
+++ b/lib/gitlab/sidekiq_middleware/client_metrics.rb
@@ -2,7 +2,9 @@
module Gitlab
module SidekiqMiddleware
- class ClientMetrics < SidekiqMiddleware::Metrics
+ class ClientMetrics
+ include ::Gitlab::SidekiqMiddleware::MetricsHelper
+
ENQUEUED = :sidekiq_enqueued_jobs_total
def initialize
diff --git a/lib/gitlab/sidekiq_middleware/metrics.rb b/lib/gitlab/sidekiq_middleware/metrics_helper.rb
index 7ae8995c46d..5c1ce2b98e8 100644
--- a/lib/gitlab/sidekiq_middleware/metrics.rb
+++ b/lib/gitlab/sidekiq_middleware/metrics_helper.rb
@@ -2,7 +2,7 @@
module Gitlab
module SidekiqMiddleware
- class Metrics
+ module MetricsHelper
TRUE_LABEL = "yes"
FALSE_LABEL = "no"
diff --git a/lib/gitlab/sidekiq_middleware/server_metrics.rb b/lib/gitlab/sidekiq_middleware/server_metrics.rb
index 0635c07ae4b..7f3048f4c6e 100644
--- a/lib/gitlab/sidekiq_middleware/server_metrics.rb
+++ b/lib/gitlab/sidekiq_middleware/server_metrics.rb
@@ -2,7 +2,9 @@
module Gitlab
module SidekiqMiddleware
- class ServerMetrics < SidekiqMiddleware::Metrics
+ class ServerMetrics
+ include ::Gitlab::SidekiqMiddleware::MetricsHelper
+
# SIDEKIQ_LATENCY_BUCKETS are latency histogram buckets better suited to Sidekiq
# timeframes than the DEFAULT_BUCKET definition. Defined in seconds.
SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze
diff --git a/lib/gitlab/tracking.rb b/lib/gitlab/tracking.rb
index 461b5dc3afc..618e359211b 100644
--- a/lib/gitlab/tracking.rb
+++ b/lib/gitlab/tracking.rb
@@ -14,8 +14,8 @@ module Gitlab
Gitlab::Tracking.event(category, action.to_s, **args)
end
- def track_self_describing_event(schema_url, event_data_json, **args)
- Gitlab::Tracking.self_describing_event(schema_url, event_data_json, **args)
+ def track_self_describing_event(schema_url, data:, **args)
+ Gitlab::Tracking.self_describing_event(schema_url, data: data, **args)
end
end
@@ -29,8 +29,8 @@ module Gitlab
product_analytics.event(category, action, label: label, property: property, value: value, context: context)
end
- def self_describing_event(schema_url, event_data_json, context: nil)
- snowplow.self_describing_event(schema_url, event_data_json, context: context)
+ def self_describing_event(schema_url, data:, context: nil)
+ snowplow.self_describing_event(schema_url, data: data, context: context)
end
def snowplow_options(group)
diff --git a/lib/gitlab/tracking/destinations/snowplow.rb b/lib/gitlab/tracking/destinations/snowplow.rb
index 9cebcfe5ee1..4fa844de325 100644
--- a/lib/gitlab/tracking/destinations/snowplow.rb
+++ b/lib/gitlab/tracking/destinations/snowplow.rb
@@ -15,10 +15,10 @@ module Gitlab
tracker.track_struct_event(category, action, label, property, value, context, (Time.now.to_f * 1000).to_i)
end
- def self_describing_event(schema_url, event_data_json, context: nil)
+ def self_describing_event(schema_url, data:, context: nil)
return unless enabled?
- event_json = SnowplowTracker::SelfDescribingJson.new(schema_url, event_data_json)
+ event_json = SnowplowTracker::SelfDescribingJson.new(schema_url, data)
tracker.track_self_describing_event(event_json, context, (Time.now.to_f * 1000).to_i)
end
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 77a74c86c63..91f9c8c2cff 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -770,6 +770,7 @@ module Gitlab
action_monthly_active_users_web_ide_edit: redis_usage_data { counter.count_web_ide_edit_actions(**date_range) },
action_monthly_active_users_sfe_edit: redis_usage_data { counter.count_sfe_edit_actions(**date_range) },
action_monthly_active_users_snippet_editor_edit: redis_usage_data { counter.count_snippet_editor_edit_actions(**date_range) },
+ action_monthly_active_users_sse_edit: redis_usage_data { counter.count_sse_edit_actions(**date_range) },
action_monthly_active_users_ide_edit: redis_usage_data { counter.count_edit_using_editor(**date_range) }
}
end
diff --git a/lib/gitlab/usage_data_counters/editor_unique_counter.rb b/lib/gitlab/usage_data_counters/editor_unique_counter.rb
index b68d50ee419..eeb26c11bfa 100644
--- a/lib/gitlab/usage_data_counters/editor_unique_counter.rb
+++ b/lib/gitlab/usage_data_counters/editor_unique_counter.rb
@@ -6,6 +6,7 @@ module Gitlab
EDIT_BY_SNIPPET_EDITOR = 'g_edit_by_snippet_ide'
EDIT_BY_SFE = 'g_edit_by_sfe'
EDIT_BY_WEB_IDE = 'g_edit_by_web_ide'
+ EDIT_BY_SSE = 'g_edit_by_sse'
EDIT_CATEGORY = 'ide_edit'
class << self
@@ -38,6 +39,14 @@ module Gitlab
count_unique(events, date_from, date_to)
end
+ def track_sse_edit_action(author:, time: Time.zone.now)
+ track_unique_action(EDIT_BY_SSE, author, time)
+ end
+
+ def count_sse_edit_actions(date_from:, date_to:)
+ count_unique(EDIT_BY_SSE, date_from, date_to)
+ end
+
private
def track_unique_action(action, author, time)
diff --git a/lib/gitlab/usage_data_counters/known_events/common.yml b/lib/gitlab/usage_data_counters/known_events/common.yml
index 35e74c803d7..53224f1e4aa 100644
--- a/lib/gitlab/usage_data_counters/known_events/common.yml
+++ b/lib/gitlab/usage_data_counters/known_events/common.yml
@@ -118,6 +118,12 @@
expiry: 29
aggregation: daily
feature_flag: track_editor_edit_actions
+- name: g_edit_by_sse
+ category: ide_edit
+ redis_slot: edit
+ expiry: 29
+ aggregation: daily
+ feature_flag: track_editor_edit_actions
- name: g_edit_by_snippet_ide
category: ide_edit
redis_slot: edit
diff --git a/qa/qa.rb b/qa/qa.rb
index 91058038c80..b30497d88e1 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -307,14 +307,12 @@ module QA
module Services
autoload :Jira, 'qa/page/project/settings/services/jira'
+ autoload :Jenkins, 'qa/page/project/settings/services/jenkins'
+ autoload :Prometheus, 'qa/page/project/settings/services/prometheus'
end
autoload :Operations, 'qa/page/project/settings/operations'
autoload :Incidents, 'qa/page/project/settings/incidents'
autoload :Integrations, 'qa/page/project/settings/integrations'
-
- module Services
- autoload :Prometheus, 'qa/page/project/settings/services/prometheus'
- end
end
module SubMenus
diff --git a/qa/qa/page/project/settings/services/jenkins.rb b/qa/qa/page/project/settings/services/jenkins.rb
new file mode 100644
index 00000000000..3d7da8d0161
--- /dev/null
+++ b/qa/qa/page/project/settings/services/jenkins.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Project
+ module Settings
+ module Services
+ class Jenkins < QA::Page::Base
+ view 'app/assets/javascripts/integrations/edit/components/dynamic_field.vue' do
+ element :jenkins_url_field, ':data-qa-selector="`${fieldId}_field`"' # rubocop:disable QA/ElementWithPattern
+ element :project_name_field, ':data-qa-selector="`${fieldId}_field`"' # rubocop:disable QA/ElementWithPattern
+ element :username_field, ':data-qa-selector="`${fieldId}_field`"' # rubocop:disable QA/ElementWithPattern
+ element :password_field, ':data-qa-selector="`${fieldId}_field`"' # rubocop:disable QA/ElementWithPattern
+ end
+
+ view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do
+ element :save_changes_button
+ end
+
+ def setup_service_with(jenkins_url:, project_name:)
+ set_jenkins_url(jenkins_url)
+ set_project_name(project_name)
+ set_username('admin')
+ set_password('password')
+ click_save_changes_button
+ end
+
+ private
+
+ def set_jenkins_url(jenkins_url)
+ fill_element(:jenkins_url_field, jenkins_url)
+ end
+
+ def set_project_name(project_name)
+ fill_element(:project_name_field, project_name)
+ end
+
+ def set_username(username)
+ fill_element(:username_field, username)
+ end
+
+ def set_password(password)
+ fill_element(:password_field, password)
+ end
+
+ def click_save_changes_button
+ click_element :save_changes_button
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/jenkins/jenkins_build_status_spec.rb b/qa/qa/specs/features/browser_ui/3_create/jenkins/jenkins_build_status_spec.rb
new file mode 100644
index 00000000000..2889875cf24
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/jenkins/jenkins_build_status_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+require 'securerandom'
+
+module QA
+ RSpec.describe 'Create', :requires_admin, :skip_live_env, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/195179', type: :flaky } do
+ describe 'Jenkins integration' do
+ let(:project_name) { "project_with_jenkins_#{SecureRandom.hex(4)}" }
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = project_name
+ project.initialize_with_readme = true
+ project.auto_devops_enabled = false
+ end
+ end
+
+ before do
+ jenkins_server = run_jenkins_server
+
+ Vendor::Jenkins::Page::Base.host = jenkins_server.host_address
+
+ Runtime::Env.personal_access_token ||= fabricate_personal_access_token
+
+ allow_requests_to_local_networks
+
+ setup_jenkins
+ end
+
+ it 'integrates and displays build status for MR pipeline in GitLab', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/719' do
+ login_to_gitlab
+
+ setup_project_integration_with_jenkins
+
+ expect(page).to have_text("Jenkins CI activated.")
+
+ QA::Support::Retrier.retry_on_exception do
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = 'master'
+ push.new_branch = false
+ push.file_name = "file_#{SecureRandom.hex(4)}.txt"
+ end
+
+ Vendor::Jenkins::Page::LastJobConsole.perform do |job_console|
+ job_console.job_name = project_name
+
+ job_console.visit!
+
+ Support::Waiter.wait_until(sleep_interval: 2, reload_page: page) do
+ job_console.has_successful_build? && job_console.no_failed_status_update?
+ end
+ end
+
+ project.visit!
+
+ Flow::Pipeline.visit_latest_pipeline
+
+ Page::Project::Pipeline::Show.perform do |show|
+ expect(show).to have_build('jenkins', status: :success, wait: 15)
+ end
+ end
+ end
+
+ after do
+ remove_jenkins_server
+ end
+
+ def setup_jenkins
+ Vendor::Jenkins::Page::Login.perform do |login_page|
+ login_page.visit!
+ login_page.login
+ end
+
+ token_description = "token-#{SecureRandom.hex(8)}"
+
+ Vendor::Jenkins::Page::NewCredentials.perform do |new_credentials|
+ new_credentials.visit_and_set_gitlab_api_token(Runtime::Env.personal_access_token, token_description)
+ end
+
+ Vendor::Jenkins::Page::Configure.perform do |configure|
+ configure.visit_and_setup_gitlab_connection(patch_host_name(Runtime::Scenario.gitlab_address, 'gitlab'), token_description) do
+ configure.click_test_connection
+ expect(configure).to have_success
+ end
+ end
+
+ Vendor::Jenkins::Page::NewJob.perform do |new_job|
+ new_job.visit_and_create_new_job_with_name(project_name)
+ end
+
+ Vendor::Jenkins::Page::ConfigureJob.perform do |configure_job|
+ configure_job.job_name = project_name
+ configure_job.configure(scm_url: patch_host_name(project.repository_http_location.git_uri, 'gitlab'))
+ end
+ end
+
+ def run_jenkins_server
+ Service::DockerRun::Jenkins.new.tap do |runner|
+ runner.pull
+ runner.register!
+ end
+ end
+
+ def remove_jenkins_server
+ Service::DockerRun::Jenkins.new.remove!
+ end
+
+ def fabricate_personal_access_token
+ login_to_gitlab
+
+ token = Resource::PersonalAccessToken.fabricate!.access_token
+ Page::Main::Menu.perform(&:sign_out)
+ token
+ end
+
+ def login_to_gitlab
+ Flow::Login.sign_in
+ end
+
+ def patch_host_name(host_name, container_name)
+ return host_name unless host_name.include?('localhost')
+
+ ip_address = `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' #{container_name}`.strip
+ host_name.gsub('localhost', ip_address)
+ end
+
+ def setup_project_integration_with_jenkins
+ project.visit!
+
+ Page::Project::Menu.perform(&:click_project)
+ Page::Project::Menu.perform(&:go_to_integrations_settings)
+ Page::Project::Settings::Integrations.perform(&:click_jenkins_ci_link)
+
+ QA::Page::Project::Settings::Services::Jenkins.perform do |jenkins|
+ jenkins.setup_service_with(jenkins_url: patch_host_name(Vendor::Jenkins::Page::Base.host, 'jenkins-server'),
+ project_name: project_name)
+ end
+ end
+
+ def allow_requests_to_local_networks
+ Page::Main::Menu.perform(&:sign_out_if_signed_in)
+ Flow::Login.sign_in_as_admin
+ Page::Main::Menu.perform(&:go_to_admin_area)
+ Page::Admin::Menu.perform(&:go_to_network_settings)
+
+ Page::Admin::Settings::Network.perform do |network|
+ network.expand_outbound_requests do |outbound_requests|
+ outbound_requests.allow_requests_to_local_network_from_services
+ end
+ end
+
+ Page::Main::Menu.perform(&:sign_out)
+ end
+ end
+ end
+end
diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh
index 0f14b702de2..122f830ce45 100644
--- a/scripts/rspec_helpers.sh
+++ b/scripts/rspec_helpers.sh
@@ -56,7 +56,7 @@ function update_tests_mapping() {
}
function crystalball_rspec_data_exists() {
- compgen -G "crystalball/rspec*.yml" > /dev/null;
+ compgen -G "crystalball/rspec*.yml" >/dev/null
}
function rspec_simple_job() {
@@ -117,7 +117,13 @@ function rspec_paralellized_job() {
export MEMORY_TEST_PATH="tmp/memory_test/${report_name}_memory.csv"
- knapsack rspec "-Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}"
+ local rspec_args="-Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}"
+
+ if [[ -n $RSPEC_MATCHING_TESTS_ENABLED ]]; then
+ tooling/bin/parallel_rspec --rspec_args "${rspec_args}" --filter tmp/matching_tests.txt
+ else
+ tooling/bin/parallel_rspec --rspec_args "${rspec_args}"
+ fi
date
}
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index 867b2b51039..b563f3b667f 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -7,6 +7,21 @@ RSpec.describe Projects::StaticSiteEditorController do
let_it_be(:user) { create(:user) }
let(:data) { { key: 'value' } }
+ describe 'GET index' do
+ let(:default_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project
+ }
+ end
+
+ it 'responds with 404 page' do
+ get :index, params: default_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
describe 'GET show' do
render_views
diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb
index 87f806a3d74..f933461a07a 100644
--- a/spec/factories/usage_data.rb
+++ b/spec/factories/usage_data.rb
@@ -19,15 +19,16 @@ FactoryBot.define do
create(:jira_import_state, :finished, project: projects[1], label: jira_label, imported_issues_count: 3)
create(:jira_import_state, :scheduled, project: projects[1], label: jira_label)
create(:prometheus_service, project: projects[1])
+ create(:service, project: projects[1], type: 'JenkinsService', active: true)
create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true)
create(:service, project: projects[1], type: 'SlackService', active: true)
create(:service, project: projects[2], type: 'SlackService', active: true)
create(:service, project: projects[2], type: 'MattermostService', active: false)
create(:service, group: group, project: nil, type: 'MattermostService', active: true)
create(:service, :template, type: 'MattermostService', active: true)
- matermost_instance = create(:service, :instance, type: 'MattermostService', active: true)
- create(:service, project: projects[1], type: 'MattermostService', active: true, inherit_from_id: matermost_instance.id)
- create(:service, group: group, project: nil, type: 'SlackService', active: true, inherit_from_id: matermost_instance.id)
+ mattermost_instance = create(:service, :instance, type: 'MattermostService', active: true)
+ create(:service, project: projects[1], type: 'MattermostService', active: true, inherit_from_id: mattermost_instance.id)
+ create(:service, group: group, project: nil, type: 'SlackService', active: true, inherit_from_id: mattermost_instance.id)
create(:service, project: projects[2], type: 'CustomIssueTrackerService', active: true)
create(:project_error_tracking_setting, project: projects[0])
create(:project_error_tracking_setting, project: projects[1], enabled: false)
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index 00f0c88497b..cb7c952dfe4 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -111,7 +111,6 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'shows resolved thread when toggled' do
find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click
- expect(page.find(".line-holder-placeholder")).to be_visible
expect(page.find(".timeline-content #note_#{note.id}")).to be_visible
end
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
index f9e76cf8107..0ec075c8ad8 100644
--- a/spec/frontend/diffs/components/diff_row_spec.js
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -97,18 +97,18 @@ describe('DiffRow', () => {
${'right'}
`('$side side', ({ side }) => {
it(`renders empty cells if ${side} is unavailable`, () => {
- const wrapper = createWrapper({ props: { line: testLines[2] } });
+ const wrapper = createWrapper({ props: { line: testLines[2], inline: false } });
expect(wrapper.find(`[data-testid="${side}LineNumber"]`).exists()).toBe(false);
expect(wrapper.find(`[data-testid="${side}EmptyCell"]`).exists()).toBe(true);
});
it('renders comment button', () => {
- const wrapper = createWrapper({ props: { line: testLines[3] } });
+ const wrapper = createWrapper({ props: { line: testLines[3], inline: false } });
expect(wrapper.find(`[data-testid="${side}CommentButton"]`).exists()).toBe(true);
});
it('renders avatars', () => {
- const wrapper = createWrapper({ props: { line: testLines[0] } });
+ const wrapper = createWrapper({ props: { line: testLines[0], inline: false } });
expect(wrapper.find(`[data-testid="${side}Discussions"]`).exists()).toBe(true);
});
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 181d562661f..a78c4c2d065 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -1119,25 +1119,14 @@ describe('DiffsStoreUtils', () => {
);
});
- /**
- * What's going on here?
- *
- * The inline version of parallelizeDiffLines simply keeps the difflines
- * in the same order they are received as opposed to shuffling them
- * to be "side by side".
- *
- * This keeps the underlying data structure the same which simplifies
- * the components, but keeps the changes grouped together as users
- * expect when viewing changes inline.
- */
- it('converts inline diff lines to inline diff lines with a parallel structure', () => {
+ it('converts inline diff lines', () => {
const file = getDiffFileMock();
const files = utils.parallelizeDiffLines(file.highlighted_diff_lines, true);
expect(files[5].left).toEqual(file.parallel_diff_lines[5].left);
expect(files[5].right).toBeNull();
- expect(files[6].left).toBeNull();
- expect(files[6].right).toEqual(file.parallel_diff_lines[5].right);
+ expect(files[6].left).toEqual(file.parallel_diff_lines[5].right);
+ expect(files[6].right).toBeNull();
});
});
});
diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb
index e5b9fb57e42..8a24b69eb6f 100644
--- a/spec/graphql/resolvers/base_resolver_spec.rb
+++ b/spec/graphql/resolvers/base_resolver_spec.rb
@@ -273,4 +273,12 @@ RSpec.describe Resolvers::BaseResolver do
end
end
end
+
+ describe '#offset_pagination' do
+ let(:instance) { resolver_instance(resolver) }
+
+ it 'is sugar for OffsetActiveRecordRelationConnection.new' do
+ expect(instance.offset_pagination(User.none)).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection)
+ end
+ end
end
diff --git a/spec/lib/api/helpers/sse_helpers_spec.rb b/spec/lib/api/helpers/sse_helpers_spec.rb
new file mode 100644
index 00000000000..397051d9142
--- /dev/null
+++ b/spec/lib/api/helpers/sse_helpers_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Helpers::SSEHelpers do
+ include Gitlab::Routing
+
+ let_it_be(:project) { create(:project) }
+
+ subject { Class.new.include(described_class).new }
+
+ describe '#request_from_sse?' do
+ before do
+ allow(subject).to receive(:request).and_return(request)
+ end
+
+ context 'when referer is nil' do
+ let(:request) { double(referer: nil)}
+
+ it 'returns false' do
+ expect(URI).not_to receive(:parse)
+ expect(subject.request_from_sse?(project)).to eq false
+ end
+ end
+
+ context 'when referer is not from SSE' do
+ let(:request) { double(referer: 'https://gitlab.com')}
+
+ it 'returns false' do
+ expect(URI).to receive(:parse).and_call_original
+ expect(subject.request_from_sse?(project)).to eq false
+ end
+ end
+
+ context 'when referer is from SSE' do
+ let(:request) { double(referer: project_show_sse_path(project, 'master/README.md'))}
+
+ it 'returns true' do
+ expect(URI).to receive(:parse).and_call_original
+ expect(subject.request_from_sse?(project)).to eq true
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_death_handler_spec.rb b/spec/lib/gitlab/sidekiq_death_handler_spec.rb
new file mode 100644
index 00000000000..96fef88de4e
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_death_handler_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqDeathHandler, :clean_gitlab_redis_queues do
+ describe '.handler' do
+ context 'when the job class has worker attributes' do
+ let(:test_worker) do
+ Class.new do
+ include WorkerAttributes
+
+ urgency :low
+ worker_has_external_dependencies!
+ worker_resource_boundary :cpu
+ feature_category :users
+ end
+ end
+
+ before do
+ stub_const('TestWorker', test_worker)
+ end
+
+ it 'uses the attributes from the worker' do
+ expect(described_class.counter)
+ .to receive(:increment)
+ .with(queue: 'test_queue', worker: 'TestWorker',
+ urgency: 'low', external_dependencies: 'yes',
+ feature_category: 'users', boundary: 'cpu')
+
+ described_class.handler({ 'class' => 'TestWorker', 'queue' => 'test_queue' }, nil)
+ end
+ end
+
+ context 'when the job class does not have worker attributes' do
+ before do
+ stub_const('TestWorker', Class.new)
+ end
+
+ it 'uses blank attributes' do
+ expect(described_class.counter)
+ .to receive(:increment)
+ .with(queue: 'test_queue', worker: 'TestWorker',
+ urgency: '', external_dependencies: 'no',
+ feature_category: '', boundary: '')
+
+ described_class.handler({ 'class' => 'TestWorker', 'queue' => 'test_queue' }, nil)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb b/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb
index 7de23cd9621..0e8647ad78a 100644
--- a/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb
+++ b/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Gitlab::Tracking::Destinations::Snowplow do
it 'sends event to tracker' do
allow(tracker).to receive(:track_self_describing_event).and_call_original
- subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', { foo: 'bar' })
+ subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', data: { foo: 'bar' })
expect(tracker).to have_received(:track_self_describing_event) do |event, context, timestamp|
expect(event.to_json[:schema]).to eq('iglu:com.gitlab/foo/jsonschema/1-0-0')
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Tracking::Destinations::Snowplow do
it 'does not send event to tracker' do
expect_any_instance_of(SnowplowTracker::Tracker).not_to receive(:track_self_describing_event)
- subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', { foo: 'bar' })
+ subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', data: { foo: 'bar' })
end
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index 8efd4d4b848..57882de0974 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -62,9 +62,9 @@ RSpec.describe Gitlab::Tracking do
it 'delegates to snowplow destination' do
expect_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
.to receive(:self_describing_event)
- .with('iglu:com.gitlab/foo/jsonschema/1-0-0', { foo: 'bar' }, context: nil)
+ .with('iglu:com.gitlab/foo/jsonschema/1-0-0', data: { foo: 'bar' }, context: nil)
- described_class.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', { foo: 'bar' })
+ described_class.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', data: { foo: 'bar' })
end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
index f2c1d8718d7..a112655ab49 100644
--- a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
@@ -74,6 +74,18 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
end
end
+ context 'for SSE edit actions' do
+ it_behaves_like 'tracks and counts action' do
+ def track_action(params)
+ described_class.track_sse_edit_action(**params)
+ end
+
+ def count_unique(params)
+ described_class.count_sse_edit_actions(**params)
+ end
+ end
+ end
+
it 'can return the count of actions per user deduplicated ' do
described_class.track_web_ide_edit_action(author: user1)
described_class.track_snippet_editor_edit_action(author: user1)
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 7de7916c04d..e07e13f4920 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -456,6 +456,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:projects]).to eq(4)
expect(count_data[:projects_asana_active]).to eq(0)
expect(count_data[:projects_prometheus_active]).to eq(1)
+ expect(count_data[:projects_jenkins_active]).to eq(1)
expect(count_data[:projects_jira_active]).to eq(4)
expect(count_data[:projects_jira_server_active]).to eq(2)
expect(count_data[:projects_jira_cloud_active]).to eq(2)
@@ -1122,6 +1123,12 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
counter.track_web_ide_edit_action(author: user3, time: time - 3.days)
counter.track_snippet_editor_edit_action(author: user3)
+
+ counter.track_sse_edit_action(author: user1)
+ counter.track_sse_edit_action(author: user1)
+ counter.track_sse_edit_action(author: user2)
+ counter.track_sse_edit_action(author: user3)
+ counter.track_sse_edit_action(author: user2, time: time - 3.days)
end
it 'returns the distinct count of user actions within the specified time period' do
@@ -1134,7 +1141,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
action_monthly_active_users_web_ide_edit: 2,
action_monthly_active_users_sfe_edit: 2,
action_monthly_active_users_snippet_editor_edit: 2,
- action_monthly_active_users_ide_edit: 3
+ action_monthly_active_users_ide_edit: 3,
+ action_monthly_active_users_sse_edit: 3
}
)
end
diff --git a/spec/models/project_services/jenkins_service_spec.rb b/spec/models/project_services/jenkins_service_spec.rb
new file mode 100644
index 00000000000..4663e41736a
--- /dev/null
+++ b/spec/models/project_services/jenkins_service_spec.rb
@@ -0,0 +1,255 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JenkinsService do
+ let(:project) { create(:project) }
+ let(:jenkins_url) { 'http://jenkins.example.com/' }
+ let(:jenkins_hook_url) { jenkins_url + 'project/my_project' }
+ let(:jenkins_username) { 'u$er name%2520' }
+ let(:jenkins_password) { 'pas$ word' }
+
+ let(:jenkins_params) do
+ {
+ active: true,
+ project: project,
+ properties: {
+ password: jenkins_password,
+ username: jenkins_username,
+ jenkins_url: jenkins_url,
+ project_name: 'my_project'
+ }
+ }
+ end
+
+ let(:jenkins_authorization) { "Basic " + ::Base64.strict_encode64(jenkins_username + ':' + jenkins_password) }
+
+ describe 'Associations' do
+ it { is_expected.to belong_to :project }
+ it { is_expected.to have_one :service_hook }
+ end
+
+ describe 'username validation' do
+ before do
+ @jenkins_service = described_class.create!(
+ active: active,
+ project: project,
+ properties: {
+ jenkins_url: 'http://jenkins.example.com/',
+ password: 'password',
+ username: 'username',
+ project_name: 'my_project'
+ }
+ )
+ end
+
+ subject { @jenkins_service }
+
+ context 'when the service is active' do
+ let(:active) { true }
+
+ context 'when password was not touched' do
+ before do
+ allow(subject).to receive(:password_touched?).and_return(false)
+ end
+
+ it { is_expected.not_to validate_presence_of :username }
+ end
+
+ context 'when password was touched' do
+ before do
+ allow(subject).to receive(:password_touched?).and_return(true)
+ end
+
+ it { is_expected.to validate_presence_of :username }
+ end
+
+ context 'when password is blank' do
+ it 'does not validate the username' do
+ expect(subject).not_to validate_presence_of :username
+
+ subject.password = ''
+ subject.save!
+ end
+ end
+ end
+
+ context 'when the service is inactive' do
+ let(:active) { false }
+
+ it { is_expected.not_to validate_presence_of :username }
+ end
+ end
+
+ describe '#hook_url' do
+ let(:username) { nil }
+ let(:password) { nil }
+ let(:jenkins_service) do
+ described_class.new(
+ project: project,
+ properties: {
+ jenkins_url: jenkins_url,
+ project_name: 'my_project',
+ username: username,
+ password: password
+ }
+ )
+ end
+
+ subject { jenkins_service.hook_url }
+
+ context 'when the jenkins_url has no relative path' do
+ let(:jenkins_url) { 'http://jenkins.example.com/' }
+
+ it { is_expected.to eq('http://jenkins.example.com/project/my_project') }
+ end
+
+ context 'when the jenkins_url has relative path' do
+ let(:jenkins_url) { 'http://organization.example.com/jenkins' }
+
+ it { is_expected.to eq('http://organization.example.com/jenkins/project/my_project') }
+ end
+
+ context 'userinfo is missing and username and password are set' do
+ let(:jenkins_url) { 'http://organization.example.com/jenkins' }
+ let(:username) { 'u$ername' }
+ let(:password) { 'pas$ word' }
+
+ it { is_expected.to eq('http://u%24ername:pas%24%20word@organization.example.com/jenkins/project/my_project') }
+ end
+
+ context 'userinfo is provided and username and password are set' do
+ let(:jenkins_url) { 'http://u:p@organization.example.com/jenkins' }
+ let(:username) { 'username' }
+ let(:password) { 'password' }
+
+ it { is_expected.to eq('http://username:password@organization.example.com/jenkins/project/my_project') }
+ end
+
+ context 'userinfo is provided username and password are not set' do
+ let(:jenkins_url) { 'http://u:p@organization.example.com/jenkins' }
+
+ it { is_expected.to eq('http://u:p@organization.example.com/jenkins/project/my_project') }
+ end
+ end
+
+ describe '#test' do
+ it 'returns the right status' do
+ user = create(:user, username: 'username')
+ project = create(:project, name: 'project')
+ push_sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
+ jenkins_service = described_class.create!(jenkins_params)
+ stub_request(:post, jenkins_hook_url).with(headers: { 'Authorization' => jenkins_authorization })
+
+ result = jenkins_service.test(push_sample_data)
+
+ expect(result).to eq({ success: true, result: '' })
+ end
+ end
+
+ describe '#execute' do
+ let(:user) { create(:user, username: 'username') }
+ let(:namespace) { create(:group, :private) }
+ let(:project) { create(:project, :private, name: 'project', namespace: namespace) }
+ let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
+ let(:jenkins_service) { described_class.create!(jenkins_params) }
+
+ before do
+ stub_request(:post, jenkins_hook_url)
+ end
+
+ it 'invokes the Jenkins API' do
+ jenkins_service.execute(push_sample_data)
+
+ expect(a_request(:post, jenkins_hook_url)).to have_been_made.once
+ end
+
+ it 'adds default web hook headers to the request' do
+ jenkins_service.execute(push_sample_data)
+
+ expect(
+ a_request(:post, jenkins_hook_url)
+ .with(headers: { 'X-Gitlab-Event' => 'Push Hook', 'Authorization' => jenkins_authorization })
+ ).to have_been_made.once
+ end
+
+ it 'request url contains properly serialized username and password' do
+ jenkins_service.execute(push_sample_data)
+
+ expect(
+ a_request(:post, 'http://jenkins.example.com/project/my_project')
+ .with(headers: { 'Authorization' => jenkins_authorization })
+ ).to have_been_made.once
+ end
+ end
+
+ describe 'Stored password invalidation' do
+ let(:project) { create(:project) }
+
+ context 'when a password was previously set' do
+ before do
+ @jenkins_service = described_class.create!(
+ project: project,
+ properties: {
+ jenkins_url: 'http://jenkins.example.com/',
+ username: 'jenkins',
+ password: 'password'
+ }
+ )
+ end
+
+ it 'resets password if url changed' do
+ @jenkins_service.jenkins_url = 'http://jenkins-edited.example.com/'
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to be_nil
+ end
+
+ it 'resets password if username is blank' do
+ @jenkins_service.username = ''
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to be_nil
+ end
+
+ it 'does not reset password if username changed' do
+ @jenkins_service.username = 'some_name'
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to eq('password')
+ end
+
+ it 'does not reset password if new url is set together with password, even if it\'s the same password' do
+ @jenkins_service.jenkins_url = 'http://jenkins_edited.example.com/'
+ @jenkins_service.password = 'password'
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to eq('password')
+ expect(@jenkins_service.jenkins_url).to eq('http://jenkins_edited.example.com/')
+ end
+
+ it 'resets password if url changed, even if setter called multiple times' do
+ @jenkins_service.jenkins_url = 'http://jenkins1.example.com/'
+ @jenkins_service.jenkins_url = 'http://jenkins1.example.com/'
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to be_nil
+ end
+ end
+
+ context 'when no password was previously set' do
+ before do
+ @jenkins_service = described_class.create!(
+ project: create(:project),
+ properties: {
+ jenkins_url: 'http://jenkins.example.com/',
+ username: 'jenkins'
+ }
+ )
+ end
+
+ it 'saves password if new url is set together with password' do
+ @jenkins_service.jenkins_url = 'http://jenkins_edited.example.com/'
+ @jenkins_service.password = 'password'
+ @jenkins_service.save!
+ expect(@jenkins_service.password).to eq('password')
+ expect(@jenkins_service.jenkins_url).to eq('http://jenkins_edited.example.com/')
+ end
+ end
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 26f8271a180..fb97431599a 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -4293,29 +4293,33 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#git_transfer_in_progress?' do
+ using RSpec::Parameterized::TableSyntax
+
let(:project) { build(:project) }
subject { project.git_transfer_in_progress? }
- it 'returns false when repo_reference_count and wiki_reference_count are 0' do
- allow(project).to receive(:repo_reference_count) { 0 }
- allow(project).to receive(:wiki_reference_count) { 0 }
-
- expect(subject).to be_falsey
- end
-
- it 'returns true when repo_reference_count is > 0' do
- allow(project).to receive(:repo_reference_count) { 2 }
- allow(project).to receive(:wiki_reference_count) { 0 }
-
- expect(subject).to be_truthy
+ where(:project_reference_counter, :wiki_reference_counter, :design_reference_counter, :result) do
+ 0 | 0 | 0 | false
+ 2 | 0 | 0 | true
+ 0 | 2 | 0 | true
+ 0 | 0 | 2 | true
end
- it 'returns true when wiki_reference_count is > 0' do
- allow(project).to receive(:repo_reference_count) { 0 }
- allow(project).to receive(:wiki_reference_count) { 2 }
+ with_them do
+ before do
+ allow(project).to receive(:reference_counter).with(type: Gitlab::GlRepository::PROJECT) do
+ double(:project_reference_counter, value: project_reference_counter)
+ end
+ allow(project).to receive(:reference_counter).with(type: Gitlab::GlRepository::WIKI) do
+ double(:wiki_reference_counter, value: wiki_reference_counter)
+ end
+ allow(project).to receive(:reference_counter).with(type: Gitlab::GlRepository::DESIGN) do
+ double(:design_reference_counter, value: design_reference_counter)
+ end
+ end
- expect(subject).to be_truthy
+ specify { expect(subject).to be result }
end
end
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index 402c1a3d19b..7f70a68a198 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -916,5 +916,11 @@ RSpec.describe Service do
described_class.available_services_names(include_dev: false)
end
+
+ it { expect(described_class.available_services_names).to include('jenkins') }
+ end
+
+ describe '.project_specific_services_names' do
+ it { expect(described_class.project_specific_services_names).to include('jenkins') }
end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index e7005bd3ec5..4339f1dd830 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1888,6 +1888,54 @@ RSpec.describe API::MergeRequests do
expect(response).to have_gitlab_http_status(:created)
end
end
+
+ describe 'SSE counter' do
+ let(:headers) { {} }
+ let(:params) do
+ {
+ title: 'Test merge_request',
+ source_branch: 'feature_conflict',
+ target_branch: 'master',
+ author_id: user.id,
+ milestone_id: milestone.id,
+ squash: true
+ }
+ end
+
+ subject { post api("/projects/#{project.id}/merge_requests", user), params: params, headers: headers }
+
+ it 'does not increase the SSE counter by default' do
+ expect(Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_sse_edit_action)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+
+ context 'when referer is not the SSE' do
+ let(:headers) { { 'HTTP_REFERER' => 'https://gitlab.com' } }
+
+ it 'does not increase the SSE counter by default' do
+ expect(Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_sse_edit_action)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when referer is the SSE' do
+ let(:headers) { { 'HTTP_REFERER' => project_show_sse_url(project, 'master/README.md') } }
+
+ it 'increases the SSE counter by default' do
+ expect(Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_sse_edit_action).with(author: user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
end
describe 'PUT /projects/:id/merge_reuests/:merge_request_iid' do
diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
index f0fd243f0ca..47252bcf7a7 100644
--- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Projects::HashedStorage::MigrateRepositoryService do
end
it 'fails when a git operation is in progress' do
- allow(project).to receive(:repo_reference_count) { 1 }
+ allow(project).to receive(:git_transfer_in_progress?) { true }
expect { service.execute }.to raise_error(Projects::HashedStorage::RepositoryInUseError)
end
diff --git a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
index 492eb0956aa..af128a532b9 100644
--- a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab
end
it 'fails when a git operation is in progress' do
- allow(project).to receive(:repo_reference_count) { 1 }
+ allow(project).to receive(:git_transfer_in_progress?) { true }
expect { service.execute }.to raise_error(Projects::HashedStorage::RepositoryInUseError)
end
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index 8e8aeea2ea1..df562761f02 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -85,6 +85,7 @@ module UsageDataHelpers
projects
projects_imported_from_github
projects_asana_active
+ projects_jenkins_active
projects_jira_active
projects_jira_server_active
projects_jira_cloud_active
diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb
index 2bd516a2339..99a66bf20f6 100644
--- a/spec/support/shared_contexts/services_shared_context.rb
+++ b/spec/support/shared_contexts/services_shared_context.rb
@@ -34,8 +34,7 @@ Service.available_services_names.each do |service|
let(:licensed_features) do
{
- 'github' => :github_project_service_integration,
- 'jenkins' => :jenkins_integration
+ 'github' => :github_project_service_integration
}
end
diff --git a/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
new file mode 100644
index 00000000000..b4633c8b795
--- /dev/null
+++ b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/parallel_rspec_runner'
+
+RSpec.describe Tooling::ParallelRSpecRunner do # rubocop:disable RSpec/FilePath
+ describe '#run' do
+ let(:allocator) { instance_double(Knapsack::Allocator) }
+ let(:rspec_args) { '--seed 123' }
+ let(:filter_tests_file) { 'tests.txt' }
+ let(:node_tests) { %w[01_spec.rb 03_spec.rb 05_spec.rb] }
+ let(:filter_tests) { '01_spec.rb 02_spec.rb 03_spec.rb' }
+ let(:test_dir) { 'spec' }
+
+ before do
+ allow(Knapsack.logger).to receive(:info)
+ allow(allocator).to receive(:node_tests).and_return(node_tests)
+ allow(allocator).to receive(:test_dir).and_return(test_dir)
+ allow(File).to receive(:exist?).with(filter_tests_file).and_return(true)
+ allow(File).to receive(:read).and_call_original
+ allow(File).to receive(:read).with(filter_tests_file).and_return(filter_tests)
+ allow(subject).to receive(:exec)
+ end
+
+ subject { described_class.new(allocator: allocator, filter_tests_file: filter_tests_file, rspec_args: rspec_args) }
+
+ shared_examples 'runs node tests' do
+ it 'runs rspec with tests allocated for this node' do
+ expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb 05_spec.rb])
+
+ subject.run
+ end
+ end
+
+ context 'given filter tests' do
+ it 'reads filter tests file for list of tests' do
+ expect(File).to receive(:read).with(filter_tests_file)
+
+ subject.run
+ end
+
+ it 'runs rspec filter tests that are allocated for this node' do
+ expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb])
+
+ subject.run
+ end
+ end
+
+ context 'with empty filter tests file' do
+ let(:filter_tests) { '' }
+
+ it_behaves_like 'runs node tests'
+ end
+
+ context 'without filter_tests_file option' do
+ let(:filter_tests_file) { nil }
+
+ it_behaves_like 'runs node tests'
+ end
+
+ context 'if filter_tests_file does not exist' do
+ before do
+ allow(File).to receive(:exist?).with(filter_tests_file).and_return(false)
+ end
+
+ it_behaves_like 'runs node tests'
+ end
+
+ context 'without rspec args' do
+ let(:rspec_args) { nil }
+
+ it 'runs rspec with without extra arguments' do
+ expect_command(%w[bundle exec rspec --default-path spec -- 01_spec.rb 03_spec.rb])
+
+ subject.run
+ end
+ end
+
+ def expect_command(cmd)
+ expect(subject).to receive(:exec).with(*cmd)
+ end
+ end
+end
diff --git a/tooling/bin/parallel_rspec b/tooling/bin/parallel_rspec
new file mode 100755
index 00000000000..a706df69a1e
--- /dev/null
+++ b/tooling/bin/parallel_rspec
@@ -0,0 +1,19 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'optparse'
+require_relative '../lib/tooling/parallel_rspec_runner'
+
+options = {}
+
+OptionParser.new do |opts|
+ opts.on("--rspec_args rspec_args", String, "Optional rspec arguments") do |value|
+ options[:rspec_args] = value
+ end
+
+ opts.on("--filter filter_tests_file", String, "Optional filename containing tests to be filtered") do |value|
+ options[:filter_tests_file] = value
+ end
+end.parse!
+
+Tooling::ParallelRSpecRunner.run(options)
diff --git a/tooling/lib/tooling/parallel_rspec_runner.rb b/tooling/lib/tooling/parallel_rspec_runner.rb
new file mode 100644
index 00000000000..443da55a978
--- /dev/null
+++ b/tooling/lib/tooling/parallel_rspec_runner.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'knapsack'
+
+# A custom parallel rspec runner based on Knapsack runner
+# which takes in additional option for a file containing
+# list of test files.
+#
+# When executing RSpec in CI, the list of tests allocated by Knapsack
+# will be compared with the test files listed in the file.
+#
+# Only the test files allocated by Knapsack and listed in the file
+# would be executed in the CI node.
+module Tooling
+ class ParallelRSpecRunner
+ def self.run(rspec_args: nil, filter_tests_file: nil)
+ new(rspec_args: rspec_args, filter_tests_file: filter_tests_file).run
+ end
+
+ def initialize(allocator: knapsack_allocator, filter_tests_file: nil, rspec_args: nil)
+ @allocator = allocator
+ @filter_tests_file = filter_tests_file
+ @rspec_args = rspec_args&.split(' ') || []
+ end
+
+ def run
+ Knapsack.logger.info
+ Knapsack.logger.info 'Knapsack node specs:'
+ Knapsack.logger.info node_tests
+ Knapsack.logger.info
+ Knapsack.logger.info 'Filter specs:'
+ Knapsack.logger.info filter_tests
+ Knapsack.logger.info
+ Knapsack.logger.info 'Running specs:'
+ Knapsack.logger.info tests_to_run
+ Knapsack.logger.info
+
+ exec(*rspec_command)
+ end
+
+ private
+
+ attr_reader :allocator, :filter_tests_file, :rspec_args
+
+ def rspec_command
+ %w[bundle exec rspec].tap do |cmd|
+ cmd.push(*rspec_args)
+ cmd.push('--default-path', allocator.test_dir)
+ cmd.push('--')
+ cmd.push(*tests_to_run)
+ end
+ end
+
+ def tests_to_run
+ return node_tests if filter_tests.empty?
+
+ node_tests & filter_tests
+ end
+
+ def node_tests
+ allocator.node_tests
+ end
+
+ def filter_tests
+ filter_tests_file ? tests_from_file(filter_tests_file) : []
+ end
+
+ def tests_from_file(filter_tests_file)
+ return [] unless File.exist?(filter_tests_file)
+
+ File.read(filter_tests_file).split(' ')
+ end
+
+ def knapsack_allocator
+ Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).allocator
+ end
+ end
+end