Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-01-22 18:09:10 +00:00
parent 7e81076708
commit 852877d997
50 changed files with 1065 additions and 414 deletions

View File

@ -5,11 +5,11 @@ By submitting code as an individual you agree to the
By submitting code as an entity you agree to the
[corporate contributor license agreement](doc/legal/corporate_contributor_license_agreement.md).
All Documentation content that resides under the [doc/ directory](/doc) of this
All Documentation content that resides under the [`doc/` directory](/doc) of this
repository is licensed under Creative Commons:
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
_This notice should stay as the first item in the CONTRIBUTING.md file._
_This notice should stay as the first item in the `CONTRIBUTING.md` file._
## Contributing Documentation has been moved

View File

@ -0,0 +1,115 @@
<script>
import {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { generateUserPaths } from '../utils';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
},
props: {
user: {
type: Object,
required: true,
},
paths: {
type: Object,
required: true,
},
},
computed: {
userActions() {
return convertArrayToCamelCase(this.user.actions);
},
dropdownActions() {
return this.userActions.filter((a) => a !== 'edit');
},
dropdownDeleteActions() {
return this.dropdownActions.filter((a) => a.includes('delete'));
},
dropdownSafeActions() {
return this.dropdownActions.filter((a) => !this.dropdownDeleteActions.includes(a));
},
hasDropdownActions() {
return this.dropdownActions.length > 0;
},
hasDeleteActions() {
return this.dropdownDeleteActions.length > 0;
},
hasEditAction() {
return this.userActions.includes('edit');
},
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
},
methods: {
isLdapAction(action) {
return action === 'ldapBlocked';
},
},
i18n: {
edit: __('Edit'),
settings: __('Settings'),
unlock: __('Unlock'),
block: s__('AdminUsers|Block'),
unblock: s__('AdminUsers|Unblock'),
approve: s__('AdminUsers|Approve'),
reject: s__('AdminUsers|Reject'),
deactivate: s__('AdminUsers|Deactivate'),
activate: s__('AdminUsers|Activate'),
ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'),
delete: s__('AdminUsers|Delete user'),
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{
$options.i18n.edit
}}</gl-button>
<gl-dropdown
v-if="hasDropdownActions"
data-testid="actions"
right
class="gl-ml-2"
icon="settings"
>
<gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header>
<template v-for="action in dropdownSafeActions">
<gl-dropdown-item v-if="isLdapAction(action)" :key="action" :data-testid="action">
{{ $options.i18n.ldap }}
</gl-dropdown-item>
<gl-dropdown-item v-else :key="action" :href="userPaths[action]" :data-testid="action">
{{ $options.i18n[action] }}
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="hasDeleteActions" />
<gl-dropdown-item
v-for="action in dropdownDeleteActions"
:key="action"
:href="userPaths[action]"
:data-testid="`delete-${action}`"
>
<span class="gl-text-red-500">{{ $options.i18n[action] }}</span>
</gl-dropdown-item>
</gl-dropdown>
</div>
</template>

View File

@ -0,0 +1,29 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { SHORT_DATE_FORMAT } from '../constants';
export default {
props: {
date: {
type: String,
required: false,
default: null,
},
},
computed: {
formattedDate() {
const { date } = this;
if (date === null) {
return __('Never');
}
return formatDate(new Date(date), SHORT_DATE_FORMAT);
},
},
};
</script>
<template>
<span>
{{ formattedDate }}
</span>
</template>

View File

@ -2,6 +2,8 @@
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
import UserAvatar from './user_avatar.vue';
import UserActions from './user_actions.vue';
import UserDate from './user_date.vue';
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
@ -11,6 +13,8 @@ export default {
components: {
GlTable,
UserAvatar,
UserActions,
UserDate,
},
props: {
users: {
@ -62,7 +66,19 @@ export default {
stacked="md"
>
<template #cell(name)="{ item: user }">
<UserAvatar :user="user" :admin-user-path="paths.adminUser" />
<user-avatar :user="user" :admin-user-path="paths.adminUser" />
</template>
<template #cell(createdAt)="{ item: { createdAt } }">
<user-date :date="createdAt" />
</template>
<template #cell(lastActivityOn)="{ item: { lastActivityOn } }">
<user-date :date="lastActivityOn" show-never />
</template>
<template #cell(settings)="{ item: user }">
<user-actions :user="user" :paths="paths" />
</template>
</gl-table>
</div>

View File

@ -1 +1,3 @@
export const USER_AVATAR_SIZE = 32;
export const SHORT_DATE_FORMAT = 'd mmm, yyyy';

View File

@ -0,0 +1,7 @@
export const generateUserPaths = (paths, id) => {
return Object.fromEntries(
Object.entries(paths).map(([action, genericPath]) => {
return [action, genericPath.replace('id', id)];
}),
);
};

View File

@ -802,3 +802,12 @@ export const removeCookie = (name) => Cookies.remove(name);
* @returns {Boolean} on/off
*/
export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag];
/**
* This method takes in array with snake_case strings
* and returns a new array with camelCase strings
*
* @param {Array[String]} array - Array to be converted
* @returns {Array[String]} Converted array
*/
export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i));

View File

@ -260,7 +260,6 @@
}
.pipeline-quota {
border-top: 1px solid $table-border-color;
border-bottom: 1px solid $table-border-color;
margin: 0 0 $gl-padding;

View File

@ -250,6 +250,7 @@ module Ci
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
pipeline.run_after_commit do
::Ci::PipelineArtifacts::CoverageReportWorker.perform_async(pipeline.id)
::Ci::PipelineArtifacts::CreateQualityReportWorker.perform_async(pipeline.id)
end
end
@ -1007,6 +1008,8 @@ module Ci
end
def can_generate_codequality_reports?
return false unless Feature.enabled?(:codequality_mr_diff, project)
has_reports?(Ci::JobArtifact.codequality_reports)
end

View File

@ -14,7 +14,8 @@ module Ci
EXPIRATION_DATE = 1.week.freeze
DEFAULT_FILE_NAMES = {
code_coverage: 'code_coverage.json'
code_coverage: 'code_coverage.json',
code_quality: 'code_quality.json'
}.freeze
belongs_to :project, class_name: "Project", inverse_of: :pipeline_artifacts

View File

@ -11,4 +11,5 @@ class ProjectPagesMetadatum < ApplicationRecord
scope :deployed, -> { where(deployed: true) }
scope :only_on_legacy_storage, -> { deployed.where(pages_deployment: nil) }
scope :with_project_route_and_deployment, -> { preload(project: [:namespace, :route, pages_metadatum: :pages_deployment]) }
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Ci
module PipelineArtifacts
class CreateQualityReportService
def execute(pipeline)
return unless pipeline.can_generate_codequality_reports?
return if pipeline.has_codequality_reports?
file = build_carrierwave_file(pipeline)
pipeline.pipeline_artifacts.create!(
project_id: pipeline.project_id,
file_type: :code_quality,
file_format: :raw,
size: file["tempfile"].size,
file: file,
expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now
)
end
private
def build_carrierwave_file(pipeline)
CarrierWaveStringFile.new_file(
file_content: pipeline.codequality_reports.to_json,
filename: Ci::PipelineArtifact::DEFAULT_FILE_NAMES.fetch(:code_quality),
content_type: 'application/json'
)
end
end
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
module Pages
class MigrateFromLegacyStorageService
def initialize(logger, migration_threads, batch_size)
@logger = logger
@migration_threads = migration_threads
@batch_size = batch_size
@migrated = 0
@errored = 0
@counters_lock = Mutex.new
end
def execute
@queue = SizedQueue.new(1)
threads = start_migration_threads
ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: @batch_size) do |batch|
@queue.push(batch)
end
@queue.close
@logger.info("Waiting for threads to finish...")
threads.each(&:join)
{ migrated: @migrated, errored: @errored }
end
def start_migration_threads
Array.new(@migration_threads) do
Thread.new do
while batch = @queue.pop
process_batch(batch)
end
end
end
end
def process_batch(batch)
batch.with_project_route_and_deployment.each do |metadatum|
project = metadatum.project
migrate_project(project)
end
@logger.info("#{@migrated} projects are migrated successfully, #{@errored} projects failed to be migrated")
end
def migrate_project(project)
result = nil
time = Benchmark.realtime do
result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project).execute
end
if result[:status] == :success
@logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time} seconds")
@counters_lock.synchronize { @migrated += 1 }
else
@logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time} seconds: #{result[:message]}")
@counters_lock.synchronize { @errored += 1 }
end
rescue => e
@counters_lock.synchronize { @errored += 1 }
@logger.error("#{e.message} project_id: #{project&.id}")
Gitlab::ErrorTracking.track_exception(e, project_id: project&.id)
end
end
end

View File

@ -1109,6 +1109,14 @@
:weight: 1
:idempotent: true
:tags: []
- :name: pipeline_background:ci_pipeline_artifacts_create_quality_report
:feature_category: :code_testing
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: pipeline_background:ci_pipeline_success_unlock_artifacts
:feature_category: :continuous_integration
:has_external_dependencies:

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
module Ci
module PipelineArtifacts
class CreateQualityReportWorker
include ApplicationWorker
queue_namespace :pipeline_background
feature_category :code_testing
idempotent!
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
Ci::PipelineArtifacts::CreateQualityReportService.new.execute(pipeline)
end
end
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Apply GitLab UI button styles to button in geo
merge_request: 51777
author: Yogi (@yo)
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove temp index in oauth_applications table
merge_request: 52157
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove extra border-top on pipeline quota in the settings page
merge_request: 52059
author: Yogi (@yo)
type: fixed

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class DeleteOauthApplicationsTmpIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'tmp_index_oauth_applications_on_id_where_trusted'
disable_ddl_transaction!
def up
remove_concurrent_index_by_name :oauth_applications, INDEX_NAME
end
def down
add_concurrent_index :oauth_applications, :id, where: 'trusted = true', name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
4bf1d277affdfa9ee772d69cb713f49f257140fb58c40bc8659d563b4cc3de29

View File

@ -23448,8 +23448,6 @@ CREATE UNIQUE INDEX term_agreements_unique_index ON term_agreements USING btree
CREATE INDEX tmp_idx_deduplicate_vulnerability_occurrences ON vulnerability_occurrences USING btree (project_id, report_type, location_fingerprint, primary_identifier_id, id);
CREATE INDEX tmp_index_oauth_applications_on_id_where_trusted ON oauth_applications USING btree (id) WHERE (trusted = true);
CREATE INDEX tmp_index_on_vulnerabilities_non_dismissed ON vulnerabilities USING btree (id) WHERE (state <> 2);
CREATE UNIQUE INDEX uniq_pkgs_deb_grp_architectures_on_distribution_id_and_name ON packages_debian_group_architectures USING btree (distribution_id, name);

View File

@ -11,10 +11,6 @@ Check this document if it includes instructions for the version you are updating
These steps go together with the [general steps](updating_the_geo_nodes.md#general-update-steps)
for updating Geo nodes.
## Updating to GitLab 13.8
We've detected an issue with the `FetchRemove` call that is used by Geo secondaries. This causes performance issues as we execute reference transaction hooks for each updated reference. Please hold off upgrading until this is in [the 13.8.1 patch release.](https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3002). More details are available [in this issue](https://gitlab.com/gitlab-org/git/-/issues/79).
## Updating to GitLab 13.7
We've detected an issue with the `FetchRemove` call that is used by Geo secondaries. This causes performance issues as we execute reference transaction hooks for each updated reference. Please hold off upgrading until this is in [the 13.7.5 patch release.](https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3002). More details are available [in this issue](https://gitlab.com/gitlab-org/git/-/issues/79).

View File

@ -45,7 +45,7 @@ To install GitLab for this default reference architecture, use the standard
You can also optionally configure GitLab to use an [external PostgreSQL service](../postgresql/external.md)
or an [external object storage service](../object_storage.md) for added
performance and reliability at a reduced complexity cost.
performance and reliability at an increased complexity cost.
## Configure Advanced Search **(STARTER ONLY)**

View File

@ -134,90 +134,97 @@ of the `kas` receiving the request from the _external_ endpoint to retry and re-
requests. This method ensures a single central component for each request can determine
how a request is routed, rather than distributing the decision across several `kas` instances.
### Reverse gRPC tunnel
This section explains how the `agentk` -> `kas` reverse gRPC tunnel is implemented.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a video overview of how some of the blocks map to code, see
[GitLab Kubernetes Agent reverse gRPC tunnel architecture and code overview
](https://www.youtube.com/watch?v=9pnQF76hyZc).
#### High level schema
In this example, `Server side of module A` exposes its API to get the `Pod` list
on the `Public API gRPC server`. When it receives a request, it must determine
the agent ID from it, then call the proxying code which forwards the request to
a suitable `agentk` that can handle it.
The `Agent side of module A` exposes the same API on the `Internal gRPC server`.
When it receives the request, it needs to handle it (such as retrieving and returning
the `Pod` list).
This schema describes how reverse tunneling is handled fully transparently
for modules, so you can add new features:
```mermaid
graph TB
subgraph kas
server-internal-grpc-server[Internal gRPC server]
server-api-grpc-server[Public API gRPC server]
server-module-a[Server side of module A]
server-module-b[Server side of module B]
end
subgraph agentk
agent-internal-grpc-server[Internal gRPC server]
agent-module-a[Agent side of module A]
agent-module-b[Agent side of module B]
end
agent-internal-grpc-server -- request --> agent-module-a
agent-internal-grpc-server -- request --> agent-module-b
server-module-a-. expose API on .-> server-internal-grpc-server
server-module-b-. expose API on .-> server-api-grpc-server
server-internal-grpc-server -- proxy request --> agent-internal-grpc-server
server-api-grpc-server -- proxy request --> agent-internal-grpc-server
```
#### Implementation schema
`HandleTunnelConnection()` is called with the server-side interface of the reverse
tunnel. It registers the connection and blocks, waiting for a request to proxy
through the connection.
`HandleIncomingConnection()` is called with the server-side interface of the incoming
connection. It registers the connection and blocks, waiting for a matching tunnel
to proxy the connection through.
After it has two connections that match, `Connection registry` starts bi-directional
data streaming:
```mermaid
graph TB
subgraph kas
server-tunnel-module[Server tunnel module]
connection-registry[Connection registry]
server-internal-grpc-server[Internal gRPC server]
server-api-grpc-server[Public API gRPC server]
server-module-a[Server side of module A]
server-module-b[Server side of module B]
end
subgraph agentk
agent-internal-grpc-server[Internal gRPC server]
agent-tunnel-module[Agent tunnel module]
agent-module-a[Agent side of module A]
agent-module-b[Agent side of module B]
end
server-tunnel-module -- "HandleTunnelConnection()" --> connection-registry
server-internal-grpc-server -- "HandleIncomingConnection()" --> connection-registry
server-api-grpc-server -- "HandleIncomingConnection()" --> connection-registry
server-module-a-. expose API on .-> server-internal-grpc-server
server-module-b-. expose API on .-> server-api-grpc-server
agent-tunnel-module -- "establish tunnel, receive request" --> server-tunnel-module
agent-tunnel-module -- make request --> agent-internal-grpc-server
agent-internal-grpc-server -- request --> agent-module-a
agent-internal-grpc-server -- request --> agent-module-b
```
### API definitions
```proto
syntax = "proto3";
import "google/protobuf/timestamp.proto";
message KasAddress {
string ip = 1;
uint32 port = 2;
}
message ConnectedAgentInfo {
// Agent id.
int64 id = 1;
// Identifies a particular agentk->kas connection. Randomly generated when agent connects.
int64 connection_id = 2;
string version = 3;
string commit = 4;
// Pod namespace.
string pod_namespace = 5;
// Pod name.
string pod_name = 6;
// When the connection was established.
google.protobuf.Timestamp connected_at = 7;
KasAddress kas_address = 8;
// What else do we need?
}
message KasInstanceInfo {
string version = 1;
string commit = 2;
KasAddress address = 3;
// What else do we need?
}
message ConnectedAgentsForProjectRequest {
int64 project_id = 1;
}
message ConnectedAgentsForProjectResponse {
// There may 0 or more agents with the same id, depending on the number of running Pods.
repeated ConnectedAgentInfo agents = 1;
}
message ConnectedAgentsByIdRequest {
int64 agent_id = 1;
}
message ConnectedAgentsByIdResponse {
repeated ConnectedAgentInfo agents = 1;
}
// API for use by GitLab.
service KasApi {
// Connected agents for a particular configuration project.
rpc ConnectedAgentsForProject (ConnectedAgentsForProjectRequest) returns (ConnectedAgentsForProjectResponse) {
}
// Connected agents for a particular agent id.
rpc ConnectedAgentsById (ConnectedAgentsByIdRequest) returns (ConnectedAgentsByIdResponse) {
}
// Depends on the need, but here is the call from the example above.
rpc GetPods (GetPodsRequest) returns (GetPodsResponse) {
}
}
message Pod {
string namespace = 1;
string name = 2;
}
message GetPodsRequest {
int64 agent_id = 1;
int64 connection_id = 2;
}
message GetPodsResponse {
repeated Pod pods = 1;
}
// Internal API for use by kas for kas -> kas calls.
service KasInternal {
// Depends on the need, but here is the call from the example above.
rpc GetPods (GetPodsRequest) returns (GetPodsResponse) {
}
}
```
- [`agent_tracker/agent_tracker.proto`](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/internal/module/agent_tracker/agent_tracker.proto)
- [`agent_tracker/rpc/rpc.proto`](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/internal/module/agent_tracker/rpc/rpc.proto)
- [`reverse_tunnel/rpc/rpc.proto`](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/internal/module/reverse_tunnel/rpc/rpc.proto)

View File

@ -170,7 +170,7 @@ The last option is to import a project using a Rails console:
Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: repo_path,
shared: shared,
project: project).restore
importable: project).restore
```
We are storing all import failures in the `import_failures` data table.

View File

@ -1,8 +1,8 @@
---
redirect_to: '../user/project/issues/index.md#viewing-and-managing-issues'
redirect_to: '../user/project/issues/index.md#view-and-manage-issues'
---
This document was moved to [another location](../user/project/issues/index.md#viewing-and-managing-issues).
This document was moved to [another location](../user/project/issues/index.md#view-and-manage-issues).
<!-- This redirect file can be deleted after February 1, 2021. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->

View File

@ -43,7 +43,7 @@ GitLab is a Git-based platform that integrates a great number of essential tools
- Hosting code in repositories with version control.
- Tracking proposals for new implementations, bug reports, and feedback with a
fully featured [Issue Tracker](project/issues/index.md#issues-list).
- Organizing and prioritizing with [Issue Boards](project/issues/index.md#issue-boards).
- Organizing and prioritizing with [Issue Boards](project/issue_board.md).
- Reviewing code in [Merge Requests](project/merge_requests/index.md) with live-preview changes per
branch with [Review Apps](../ci/review_apps/index.md).
- Building, testing, and deploying with built-in [Continuous Integration](../ci/README.md).

View File

@ -6,33 +6,27 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Issues **(CORE)**
Issues are the fundamental medium for collaborating on ideas and planning work in GitLab.
Issues are the fundamental mechanism in GitLab to collaborate on ideas, solve
problems, and plan work.
## Overview
Using issues, you can share and discuss proposals (both before and during their
implementation) between you and your team, and outside collaborators.
The GitLab issue tracker is an advanced tool for collaboratively developing ideas, solving problems,
and planning work.
You can use issues for many purposes, customized to your needs and workflow.
Common use cases include:
Issues can allow sharing and discussion of proposals before, and during,
their implementation between:
- Discussing the implementation of a new idea.
- Tracking tasks and work status.
- Accepting feature proposals, questions, support requests, or bug reports.
- Elaborating on new code implementations.
- You and your team.
- Outside collaborators.
For more information about using issues, see the
[Always start a discussion with an issue](https://about.gitlab.com/blog/2016/03/03/start-with-an-issue/)
GitLab blog post.
They can also be used for a variety of other purposes, customized to your
needs and workflow.
Issues are always associated with a specific project. If you have multiple projects in a group,
you can view all of the issues collectively at the group level.
**Common use cases include:**
- Discussing the implementation of a new idea
- Tracking tasks and work status
- Accepting feature proposals, questions, support requests, or bug reports
- Elaborating on new code implementations
See also [Always start a discussion with an issue](https://about.gitlab.com/blog/2016/03/03/start-with-an-issue/).
Issues are always associated with a specific project. If you have multiple
projects in a group, you can view all of the issues collectively at the group
level.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
To learn how our Strategic Marketing department uses GitLab issues with [labels](../labels.md) and
@ -41,63 +35,30 @@ To learn how our Strategic Marketing department uses GitLab issues with [labels]
## Parts of an issue
Issues contain a variety of content and metadata, enabling a large range of flexibility
in how they are used. Each issue can contain the following attributes, though not all items
must be set.
Issues have a flexible content and metadata structure. Here are some of the
elements you can provide in an issue:
<table class="borderless-table fixed-table">
<tr>
<td>
<ul>
<li>Content</li>
<ul>
<li>Title</li>
<li>Description and tasks</li>
<li>Comments and other activity</li>
</ul>
<li>People</li>
<ul>
<li>Author</li>
<li>Assignee(s)</li>
</ul>
<li>State</li>
<ul>
<li>State (open or closed)</li>
<li>Health status (on track, needs attention, or at risk)</li>
<li>Confidentiality</li>
<li>Tasks (completed vs. outstanding)</li>
</ul>
</ul>
</td>
<td>
<ul>
<li>Planning and tracking</li>
<ul>
<li>Milestone</li>
<li>Due date</li>
<li>Weight</li>
<li>Time tracking</li>
<li>Labels</li>
<li>Votes</li>
<li>Reaction emoji</li>
<li>Linked issues</li>
<li>Assigned epic</li>
<li>Unique issue number and URL</li>
</ul>
</ul>
</td>
</tr>
</table>
- Title
- Description and tasks
- Comments and other activity
- Author
- Assignees
- State (open or closed)
- Health status (on track, needs attention, or at risk)
- Confidentiality
- Tasks (completed vs. outstanding)
- Milestone
- Due date
- Weight
- Time tracking
- Labels
- Votes
- Reaction emoji
- Linked issues
- Assigned epic
- Unique issue number and URL
## Viewing and managing issues
While you can view and manage details of an issue on the [issue page](#issue-page),
you can also work with multiple issues at a time using:
- [Issues List](#issues-list).
- [Issue Boards](#issue-boards).
- Issue references.
- [Epics](#epics) **(PREMIUM)**.
## View and manage issues
Key actions for issues include:
@ -105,7 +66,17 @@ Key actions for issues include:
- [Moving issues](managing_issues.md#moving-issues)
- [Closing issues](managing_issues.md#closing-issues)
- [Deleting issues](managing_issues.md#deleting-issues)
- [Promoting issues](managing_issues.md#promote-an-issue-to-an-epic) **(PREMIUM)**
- [Promoting issues](managing_issues.md#promote-an-issue-to-an-epic)
Although you can view and manage details of an issue on the [issue page](#issue-page),
you can also work with several issues at a time by using these features:
- [Issues List](#issues-list): View a list of issues in a project or group.
- [Issue Boards](../issue_board.md): Organize issues with a project management
workflow for a feature or product release.
- Issue references
- [Epics](../../group/epics/index.md): Manage your portfolio of projects by
tracking groups of issues with a shared theme.
### Issue page
@ -125,7 +96,7 @@ To enable it, you need to enable [ActionCable in-app mode](https://docs.gitlab.c
![Project Issues List view](img/project_issues_list_view.png)
On the Issues List, you can:
In the Issues List, you can:
- View all issues in a project when opening the Issues List from a project context.
- View all issues in a groups's projects when opening the Issues List from a group context.
@ -154,31 +125,12 @@ This feature might not be available to you. Check the **version history** note a
In a group, the sidebar displays the total count of open issues and this value is cached if higher
than 1000. The cached value is rounded to thousands (or millions) and updated every 24 hours.
### Issue boards
![Issue board](img/issue_board.png)
[Issue boards](../issue_board.md) are Kanban boards with columns that display issues based on their
labels or their assignees**(PREMIUM)**. They offer the flexibility to manage issues using
highly customizable workflows.
You can reorder issues in the column. If you drag an issue card to another column, its
associated label or assignee is changed to match that of the new column. The entire
board can also be filtered to only include issues from a certain milestone or an overarching
label.
### Design Management
With [Design Management](design_management.md), you can upload design
assets to issues and view them all together for sharing and
collaboration with your team.
### Epics **(PREMIUM)**
[Epics](../../group/epics/index.md) let you manage your portfolio of projects more
efficiently and with less effort. Epics track groups of issues that share a theme, across
projects and milestones.
### Related issues
You can mark two issues as related, so that when viewing one, the other is always

View File

@ -129,7 +129,7 @@ element. Due dates can be changed as many times as needed.
### Labels
Categorize issues by giving them [labels](../labels.md). They help to organize workflows,
and they enable you to work with the [GitLab Issue Board](index.md#issue-boards).
and they enable you to work with the [GitLab Issue Board](../issue_board.md).
Group Labels, which allow you to use the same labels for all projects in the same
group, can also be given to issues. They work exactly the same, but are immediately

View File

@ -3,10 +3,11 @@
module Gitlab
module ImportExport
class DesignRepoRestorer < RepoRestorer
def initialize(project:, shared:, path_to_bundle:)
super(project: project, shared: shared, path_to_bundle: path_to_bundle)
extend ::Gitlab::Utils::Override
@repository = project.design_repository
override :repository
def repository
@repository ||= importable.design_repository
end
# `restore` method is handled in super class

View File

@ -75,19 +75,19 @@ module Gitlab
def repo_restorer
Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: repo_path,
shared: shared,
project: project)
importable: project)
end
def wiki_restorer
Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: wiki_repo_path,
shared: shared,
project: ProjectWiki.new(project))
importable: ProjectWiki.new(project))
end
def design_repo_restorer
Gitlab::ImportExport::DesignRepoRestorer.new(path_to_bundle: design_repo_path,
shared: shared,
project: project)
importable: project)
end
def uploads_restorer

View File

@ -5,10 +5,12 @@ module Gitlab
class RepoRestorer
include Gitlab::ImportExport::CommandLineUtil
def initialize(project:, shared:, path_to_bundle:)
@repository = project.repository
attr_reader :importable
def initialize(importable:, shared:, path_to_bundle:)
@path_to_bundle = path_to_bundle
@shared = shared
@importable = importable
end
def restore
@ -22,9 +24,13 @@ module Gitlab
false
end
def repository
@repository ||= importable.repository
end
private
attr_accessor :repository, :path_to_bundle, :shared
attr_accessor :path_to_bundle, :shared
def ensure_repository_does_not_exist!
if repository.exists?

View File

@ -6,37 +6,20 @@ namespace :gitlab do
task migrate_legacy_storage: :gitlab_environment do
logger = Logger.new(STDOUT)
logger.info('Starting to migrate legacy pages storage to zip deployments')
projects_migrated = 0
projects_errored = 0
ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: 10) do |batch|
batch.preload(project: [:namespace, :route, pages_metadatum: :pages_deployment]).each do |metadatum|
project = metadatum.project
result = ::Pages::MigrateFromLegacyStorageService.new(logger, migration_threads, batch_size).execute
result = nil
time = Benchmark.realtime do
result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project).execute
end
logger.info("A total of #{result[:migrated] + result[:errored]} projects were processed.")
logger.info("- The #{result[:migrated]} projects migrated successfully")
logger.info("- The #{result[:errored]} projects failed to be migrated")
end
if result[:status] == :success
logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time} seconds")
projects_migrated += 1
else
logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time} seconds: #{result[:message]}")
projects_errored += 1
end
rescue => e
projects_errored += 1
logger.error("#{e.message} project_id: #{project&.id}")
Gitlab::ErrorTracking.track_exception(e, project_id: project&.id)
end
def migration_threads
ENV.fetch('PAGES_MIGRATION_THREADS', '3').to_i
end
logger.info("#{projects_migrated} projects are migrated successfully, #{projects_errored} projects failed to be migrated")
end
logger.info("A total of #{projects_migrated + projects_errored} projects were processed.")
logger.info("- The #{projects_migrated} projects migrated successfully")
logger.info("- The #{projects_errored} projects failed to be migrated")
def batch_size
ENV.fetch('PAGES_MIGRATION_BATCH_SIZE', '10').to_i
end
end
end

View File

@ -45,7 +45,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.179.0",
"@gitlab/tributejs": "1.0.0",
"@gitlab/ui": "26.1.0",
"@gitlab/ui": "26.3.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-4",
"@rails/ujs": "^6.0.3-4",

View File

@ -4,7 +4,7 @@ module QA
RSpec.describe 'Manage', :smoke do
describe 'Project creation' do
it 'user creates a new project',
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/429' do
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1234' do
Flow::Login.sign_in
created_project = Resource::Project.fabricate_via_browser_ui! do |project|

View File

@ -0,0 +1,138 @@
import { shallowMount } from '@vue/test-utils';
import { GlDropdownDivider } from '@gitlab/ui';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { generateUserPaths } from '~/admin/users/utils';
import { users, paths } from '../mock_data';
const BLOCK = 'block';
const EDIT = 'edit';
const LDAP = 'ldapBlocked';
const DELETE = 'delete';
const DELETE_WITH_CONTRIBUTIONS = 'deleteWithContributions';
describe('AdminUserActions component', () => {
let wrapper;
const user = users[0];
const userPaths = generateUserPaths(paths, user.username);
const findEditButton = () => wrapper.find('[data-testid="edit"]');
const findActionsDropdown = () => wrapper.find('[data-testid="actions"');
const findDropdownDivider = () => wrapper.find(GlDropdownDivider);
const initComponent = ({ actions = [] } = {}) => {
wrapper = shallowMount(AdminUserActions, {
propsData: {
user: {
...user,
actions,
},
paths,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('edit button', () => {
describe('when the user has an edit action attached', () => {
beforeEach(() => {
initComponent({ actions: [EDIT] });
});
it('renders the edit button linking to the user edit path', () => {
expect(findEditButton().exists()).toBe(true);
expect(findEditButton().attributes('href')).toBe(userPaths.edit);
});
});
describe('when there is no edit action attached to the user', () => {
beforeEach(() => {
initComponent({ actions: [] });
});
it('does not render the edit button linking to the user edit path', () => {
expect(findEditButton().exists()).toBe(false);
});
});
});
describe('actions dropdown', () => {
describe('when there are actions', () => {
const actions = [EDIT, BLOCK];
beforeEach(() => {
initComponent({ actions });
});
it('renders the actions dropdown', () => {
expect(findActionsDropdown().exists()).toBe(true);
});
it.each(actions)('renders a dropdown item for %s', (action) => {
const dropdownAction = wrapper.find(`[data-testid="${action}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(userPaths[action]);
});
describe('when there is a LDAP action', () => {
beforeEach(() => {
initComponent({ actions: [LDAP] });
});
it('renders the LDAP dropdown item without a link', () => {
const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(undefined);
});
});
describe('when there is a delete action', () => {
const deleteActions = [DELETE, DELETE_WITH_CONTRIBUTIONS];
beforeEach(() => {
initComponent({ actions: [BLOCK, ...deleteActions] });
});
it('renders a dropdown divider', () => {
expect(findDropdownDivider().exists()).toBe(true);
});
it('only renders delete dropdown items for actions containing the word "delete"', () => {
const { length } = wrapper.findAll(`[data-testid*="delete-"]`);
expect(length).toBe(deleteActions.length);
});
it.each(deleteActions)('renders a delete dropdown item for %s', (action) => {
const deleteAction = wrapper.find(`[data-testid="delete-${action}"]`);
expect(deleteAction.exists()).toBe(true);
expect(deleteAction.attributes('href')).toBe(userPaths[action]);
});
});
describe('when there are no delete actions', () => {
it('does not render a dropdown divider', () => {
expect(findDropdownDivider().exists()).toBe(false);
});
it('does not render a delete dropdown item', () => {
const anyDeleteAction = wrapper.find(`[data-testid*="delete-"]`);
expect(anyDeleteAction.exists()).toBe(false);
});
});
});
describe('when there are no actions', () => {
beforeEach(() => {
initComponent({ actions: [] });
});
it('does not render the actions dropdown', () => {
expect(findActionsDropdown().exists()).toBe(false);
});
});
});
});

View File

@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import UserDate from '~/admin/users/components/user_date.vue';
import { users } from '../mock_data';
const mockDate = users[0].createdAt;
describe('FormatDate component', () => {
let wrapper;
const initComponent = (props = {}) => {
wrapper = shallowMount(UserDate, {
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each`
date | output
${mockDate} | ${'13 Nov, 2020'}
${null} | ${'Never'}
${undefined} | ${'Never'}
`('renders $date as $output', ({ date, output }) => {
initComponent({ date });
expect(wrapper.text()).toBe(output);
});
});

View File

@ -3,6 +3,9 @@ import { mount } from '@vue/test-utils';
import AdminUsersTable from '~/admin/users/components/users_table.vue';
import AdminUserAvatar from '~/admin/users/components/user_avatar.vue';
import AdminUserDate from '~/admin/users/components/user_date.vue';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { users, paths } from '../mock_data';
describe('AdminUsersTable component', () => {
@ -39,18 +42,21 @@ describe('AdminUsersTable component', () => {
initComponent();
});
it.each`
key | label
${'name'} | ${'Name'}
${'projectsCount'} | ${'Projects'}
${'createdAt'} | ${'Created on'}
${'lastActivityOn'} | ${'Last activity'}
`('renders users.$key in column $label', ({ key, label }) => {
expect(getCellByLabel(0, label).text()).toContain(`${user[key]}`);
it('renders the projects count', () => {
expect(getCellByLabel(0, 'Projects').text()).toContain(`${user.projectsCount}`);
});
it('renders an AdminUserAvatar component', () => {
expect(getCellByLabel(0, 'Name').find(AdminUserAvatar).exists()).toBe(true);
it('renders the user actions', () => {
expect(wrapper.find(AdminUserActions).exists()).toBe(true);
});
it.each`
component | label
${AdminUserAvatar} | ${'Name'}
${AdminUserDate} | ${'Created on'}
${AdminUserDate} | ${'Last activity'}
`('renders the component for column $label', ({ component, label }) => {
expect(getCellByLabel(0, label).find(component).exists()).toBe(true);
});
});

View File

@ -1045,4 +1045,12 @@ describe('common_utils', () => {
expect(commonUtils.getDashPath('/some/url')).toEqual(null);
});
});
describe('convertArrayToCamelCase', () => {
it('returns a new array with snake_case string elements converted camelCase', () => {
const result = commonUtils.convertArrayToCamelCase(['hello', 'hello_world']);
expect(result).toEqual(['hello', 'helloWorld']);
});
});
});

View File

@ -16,7 +16,7 @@ RSpec.describe Gitlab::ImportExport::DesignRepoRestorer do
let(:restorer) do
described_class.new(path_to_bundle: bundle_path,
shared: shared,
project: project)
importable: project)
end
before do

View File

@ -16,7 +16,7 @@ RSpec.describe 'forked project import' do
let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
let(:repo_restorer) do
Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: bundle_path, shared: shared, project: project)
Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: bundle_path, shared: shared, importable: project)
end
let!(:merge_request) do

View File

@ -69,8 +69,8 @@ RSpec.describe Gitlab::ImportExport::Importer do
repo_path = File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename)
restorer = double(Gitlab::ImportExport::RepoRestorer)
expect(Gitlab::ImportExport::RepoRestorer).to receive(:new).with(path_to_bundle: repo_path, shared: shared, project: project).and_return(restorer)
expect(Gitlab::ImportExport::RepoRestorer).to receive(:new).with(path_to_bundle: wiki_repo_path, shared: shared, project: ProjectWiki.new(project)).and_return(restorer)
expect(Gitlab::ImportExport::RepoRestorer).to receive(:new).with(path_to_bundle: repo_path, shared: shared, importable: project).and_return(restorer)
expect(Gitlab::ImportExport::RepoRestorer).to receive(:new).with(path_to_bundle: wiki_repo_path, shared: shared, importable: ProjectWiki.new(project)).and_return(restorer)
expect(Gitlab::ImportExport::RepoRestorer).to receive(:new).and_call_original
expect(restorer).to receive(:restore).and_return(true).twice

View File

@ -30,7 +30,7 @@ RSpec.describe Gitlab::ImportExport::RepoRestorer do
let(:bundler) { Gitlab::ImportExport::RepoSaver.new(exportable: project_with_repo, shared: shared) }
let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
subject { described_class.new(path_to_bundle: bundle_path, shared: shared, project: project) }
subject { described_class.new(path_to_bundle: bundle_path, shared: shared, importable: project) }
after do
Gitlab::Shell.new.remove_repository(project.repository_storage, project.disk_path)
@ -65,7 +65,7 @@ RSpec.describe Gitlab::ImportExport::RepoRestorer do
let(:bundler) { Gitlab::ImportExport::WikiRepoSaver.new(exportable: project_with_repo, shared: shared) }
let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.wiki_repo_bundle_filename) }
subject { described_class.new(path_to_bundle: bundle_path, shared: shared, project: ProjectWiki.new(project)) }
subject { described_class.new(path_to_bundle: bundle_path, shared: shared, importable: ProjectWiki.new(project)) }
after do
Gitlab::Shell.new.remove_repository(project.wiki.repository_storage, project.wiki.disk_path)

View File

@ -3522,7 +3522,19 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline status is success' do
let(:pipeline) { create(:ci_pipeline, :success, project: project) }
it { expect(subject).to be_truthy }
it 'can generate a codequality report' do
expect(subject).to be_truthy
end
context 'when feature is disabled' do
before do
stub_feature_flags(codequality_mr_diff: false)
end
it 'can not generate a codequality report' do
expect(subject).to be_falsey
end
end
end
end

View File

@ -7,18 +7,8 @@ RSpec.describe PipelineEntity do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let(:request) { double('request') }
before do
stub_not_protect_default_branch
allow(request).to receive(:current_user).and_return(user)
allow(request).to receive(:project).and_return(project)
end
let(:entity) do
described_class.represent(pipeline, request: request)
end
let(:request) { double('request', current_user: user) }
let(:entity) { described_class.represent(pipeline, request: request) }
describe '#as_json' do
subject { entity.as_json }
@ -54,70 +44,72 @@ RSpec.describe PipelineEntity do
end
end
context 'when pipeline is retryable' do
let(:project) { create(:project) }
let(:pipeline) do
create(:ci_pipeline, status: :success, project: project)
end
context 'when default branch not protected' do
before do
create(:ci_build, :failed, pipeline: pipeline)
stub_not_protect_default_branch
end
it 'does not serialize stage builds' do
subject.with_indifferent_access.dig(:details, :stages, 0).tap do |stage|
expect(stage).not_to include(:groups, :latest_statuses, :retries)
context 'when pipeline is retryable' do
let_it_be(:pipeline) do
create(:ci_pipeline, status: :success, project: project)
end
end
context 'user has ability to retry pipeline' do
before do
project.add_developer(user)
create(:ci_build, :failed, pipeline: pipeline)
end
it 'contains retry path' do
expect(subject[:retry_path]).to be_present
it 'does not serialize stage builds' do
subject.with_indifferent_access.dig(:details, :stages, 0).tap do |stage|
expect(stage).not_to include(:groups, :latest_statuses, :retries)
end
end
context 'user has ability to retry pipeline' do
before do
project.add_developer(user)
end
it 'contains retry path' do
expect(subject[:retry_path]).to be_present
end
end
context 'user does not have ability to retry pipeline' do
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
end
end
end
context 'user does not have ability to retry pipeline' do
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
context 'when pipeline is cancelable' do
let_it_be(:pipeline) do
create(:ci_pipeline, status: :running, project: project)
end
end
end
context 'when pipeline is cancelable' do
let(:project) { create(:project) }
let(:pipeline) do
create(:ci_pipeline, status: :running, project: project)
end
before do
create(:ci_build, :pending, pipeline: pipeline)
end
it 'does not serialize stage builds' do
subject.with_indifferent_access.dig(:details, :stages, 0).tap do |stage|
expect(stage).not_to include(:groups, :latest_statuses, :retries)
end
end
context 'user has ability to cancel pipeline' do
before do
project.add_developer(user)
create(:ci_build, :pending, pipeline: pipeline)
end
it 'contains cancel path' do
expect(subject[:cancel_path]).to be_present
it 'does not serialize stage builds' do
subject.with_indifferent_access.dig(:details, :stages, 0).tap do |stage|
expect(stage).not_to include(:groups, :latest_statuses, :retries)
end
end
end
context 'user does not have ability to cancel pipeline' do
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
context 'user has ability to cancel pipeline' do
before do
project.add_developer(user)
end
it 'contains cancel path' do
expect(subject[:cancel_path]).to be_present
end
end
context 'user does not have ability to cancel pipeline' do
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
end
end
end
end
@ -133,7 +125,6 @@ RSpec.describe PipelineEntity do
end
context 'user does not have ability to delete pipeline' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
it 'does not contain delete path' do
@ -167,79 +158,85 @@ RSpec.describe PipelineEntity do
end
end
context 'when pipeline is detached merge request pipeline' do
let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) }
let(:project) { merge_request.target_project }
let(:pipeline) { merge_request.pipelines_for_merge_request.first }
it 'makes detached flag true' do
expect(subject[:flags][:detached_merge_request_pipeline]).to be_truthy
context 'when request has a project' do
before do
allow(request).to receive(:project).and_return(project)
end
it 'does not expose source sha and target sha' do
expect(subject[:source_sha]).to be_nil
expect(subject[:target_sha]).to be_nil
end
context 'when pipeline is detached merge request pipeline' do
let_it_be(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) }
let(:project) { merge_request.target_project }
let(:pipeline) { merge_request.pipelines_for_merge_request.first }
context 'when user is a developer' do
before do
project.add_developer(user)
it 'makes detached flag true' do
expect(subject[:flags][:detached_merge_request_pipeline]).to be_truthy
end
it 'has merge request information' do
expect(subject[:merge_request][:iid]).to eq(merge_request.iid)
it 'does not expose source sha and target sha' do
expect(subject[:source_sha]).to be_nil
expect(subject[:target_sha]).to be_nil
end
expect(project_merge_request_path(project, merge_request))
.to include(subject[:merge_request][:path])
context 'when user is a developer' do
before do
project.add_developer(user)
end
expect(subject[:merge_request][:title]).to eq(merge_request.title)
it 'has merge request information' do
expect(subject[:merge_request][:iid]).to eq(merge_request.iid)
expect(subject[:merge_request][:source_branch])
.to eq(merge_request.source_branch)
expect(project_merge_request_path(project, merge_request))
.to include(subject[:merge_request][:path])
expect(project_commits_path(project, merge_request.source_branch))
.to include(subject[:merge_request][:source_branch_path])
expect(subject[:merge_request][:title]).to eq(merge_request.title)
expect(subject[:merge_request][:target_branch])
.to eq(merge_request.target_branch)
expect(subject[:merge_request][:source_branch])
.to eq(merge_request.source_branch)
expect(project_commits_path(project, merge_request.target_branch))
.to include(subject[:merge_request][:target_branch_path])
expect(project_commits_path(project, merge_request.source_branch))
.to include(subject[:merge_request][:source_branch_path])
expect(subject[:merge_request][:target_branch])
.to eq(merge_request.target_branch)
expect(project_commits_path(project, merge_request.target_branch))
.to include(subject[:merge_request][:target_branch_path])
end
end
context 'when user is an external user' do
it 'has no merge request information' do
expect(subject[:merge_request]).to be_nil
end
end
end
context 'when user is an external user' do
it 'has no merge request information' do
expect(subject[:merge_request]).to be_nil
context 'when pipeline is merge request pipeline' do
let_it_be(:merge_request) { create(:merge_request, :with_merge_request_pipeline, merge_sha: 'abc') }
let(:project) { merge_request.target_project }
let(:pipeline) { merge_request.pipelines_for_merge_request.first }
it 'makes detached flag false' do
expect(subject[:flags][:detached_merge_request_pipeline]).to be_falsy
end
end
end
context 'when pipeline is merge request pipeline' do
let(:merge_request) { create(:merge_request, :with_merge_request_pipeline, merge_sha: 'abc') }
let(:project) { merge_request.target_project }
let(:pipeline) { merge_request.pipelines_for_merge_request.first }
it 'makes atached flag true' do
expect(subject[:flags][:merge_request_pipeline]).to be_truthy
end
it 'makes detached flag false' do
expect(subject[:flags][:detached_merge_request_pipeline]).to be_falsy
end
it 'exposes source sha and target sha' do
expect(subject[:source_sha]).to be_present
expect(subject[:target_sha]).to be_present
end
it 'makes atached flag true' do
expect(subject[:flags][:merge_request_pipeline]).to be_truthy
end
it 'exposes source sha and target sha' do
expect(subject[:source_sha]).to be_present
expect(subject[:target_sha]).to be_present
end
it 'exposes merge request event type' do
expect(subject[:merge_request_event_type]).to be_present
it 'exposes merge request event type' do
expect(subject[:merge_request_event_type]).to be_present
end
end
end
context 'when pipeline has failed builds' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
let_it_be(:build) { create(:ci_build, :success, pipeline: pipeline) }
let_it_be(:failed_1) { create(:ci_build, :failed, pipeline: pipeline) }
let_it_be(:failed_2) { create(:ci_build, :failed, pipeline: pipeline) }

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Ci::PipelineArtifacts::CreateQualityReportService do
describe '#execute' do
subject(:pipeline_artifact) { described_class.new.execute(pipeline) }
context 'when pipeline has codequality reports' do
let(:project) { create(:project, :repository) }
describe 'pipeline completed status' do
using RSpec::Parameterized::TableSyntax
where(:status, :result) do
:success | 1
:failed | 1
:canceled | 1
:skipped | 1
end
with_them do
let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, status: status, project: project) }
it 'creates a pipeline artifact' do
expect { pipeline_artifact }.to change(Ci::PipelineArtifact, :count).by(result)
end
it 'persists the default file name' do
expect(pipeline_artifact.file.filename).to eq('code_quality.json')
end
it 'sets expire_at to 1 week' do
freeze_time do
expect(pipeline_artifact.expire_at).to eq(1.week.from_now)
end
end
end
end
context 'when pipeline artifact has already been created' do
let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) }
it 'does not persist the same artifact twice' do
2.times { described_class.new.execute(pipeline) }
expect(Ci::PipelineArtifact.count).to eq(1)
end
end
end
context 'when pipeline is not completed and codequality report does not exist' do
let(:pipeline) { create(:ci_pipeline, :running) }
it 'does not persist data' do
pipeline_artifact
expect(Ci::PipelineArtifact.count).to eq(0)
end
end
end
end

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Pages::MigrateFromLegacyStorageService do
let(:service) { described_class.new(Rails.logger, 3, 10) }
it 'does not try to migrate pages if pages are not deployed' do
expect(::Pages::MigrateLegacyStorageToDeploymentService).not_to receive(:new)
expect(service.execute).to eq(migrated: 0, errored: 0)
end
it 'uses multiple threads' do
projects = create_list(:project, 20)
projects.each do |project|
project.mark_pages_as_deployed
FileUtils.mkdir_p File.join(project.pages_path, "public")
File.open(File.join(project.pages_path, "public/index.html"), "w") do |f|
f.write("Hello!")
end
end
service = described_class.new(Rails.logger, 3, 2)
threads = Concurrent::Set.new
expect(service).to receive(:migrate_project).exactly(20).times.and_wrap_original do |m, *args|
threads.add(Thread.current)
# sleep to be 100% certain that once thread can't consume all the queue
# it works without it, but I want to avoid making this test flaky
sleep(0.01)
m.call(*args)
end
expect(service.execute).to eq(migrated: 20, errored: 0)
expect(threads.length).to eq(3)
end
context 'when pages are marked as deployed' do
let(:project) { create(:project) }
before do
project.mark_pages_as_deployed
end
context 'when pages directory does not exist' do
it 'tries to migrate the project, but does not crash' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project) do |service|
expect(service).to receive(:execute).and_call_original
end
expect(service.execute).to eq(migrated: 0, errored: 1)
end
end
context 'when pages directory exists on disk' do
before do
FileUtils.mkdir_p File.join(project.pages_path, "public")
File.open(File.join(project.pages_path, "public/index.html"), "w") do |f|
f.write("Hello!")
end
end
it 'migrates pages projects without deployments' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project) do |service|
expect(service).to receive(:execute).and_call_original
end
expect do
expect(service.execute).to eq(migrated: 1, errored: 0)
end.to change { project.pages_metadatum.reload.pages_deployment }.from(nil)
end
context 'when deployed already exists for the project' do
before do
deployment = create(:pages_deployment, project: project)
project.set_first_pages_deployment!(deployment)
end
it 'does not try to migrate project' do
expect(::Pages::MigrateLegacyStorageToDeploymentService).not_to receive(:new)
expect(service.execute).to eq(migrated: 0, errored: 0)
end
end
end
end
end

View File

@ -9,59 +9,31 @@ RSpec.describe 'gitlab:pages:migrate_legacy_storagerake task' do
subject { run_rake_task('gitlab:pages:migrate_legacy_storage') }
let(:project) { create(:project) }
it 'does not try to migrate pages if pages are not deployed' do
expect(::Pages::MigrateLegacyStorageToDeploymentService).not_to receive(:new)
it 'calls migration service' do
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, 3, 10) do |service|
expect(service).to receive(:execute).and_call_original
end
subject
end
context 'when pages are marked as deployed' do
before do
project.mark_pages_as_deployed
it 'uses PAGES_MIGRATION_THREADS environment variable' do
stub_env('PAGES_MIGRATION_THREADS', '5')
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, 5, 10) do |service|
expect(service).to receive(:execute).and_call_original
end
context 'when pages directory does not exist' do
it 'tries to migrate the project, but does not crash' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project) do |service|
expect(service).to receive(:execute).and_call_original
end
subject
end
subject
end
it 'uses PAGES_MIGRATION_BATCH_SIZE environment variable' do
stub_env('PAGES_MIGRATION_BATCH_SIZE', '100')
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, 3, 100) do |service|
expect(service).to receive(:execute).and_call_original
end
context 'when pages directory exists on disk' do
before do
FileUtils.mkdir_p File.join(project.pages_path, "public")
File.open(File.join(project.pages_path, "public/index.html"), "w") do |f|
f.write("Hello!")
end
end
it 'migrates pages projects without deployments' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project) do |service|
expect(service).to receive(:execute).and_call_original
end
expect do
subject
end.to change { project.pages_metadatum.reload.pages_deployment }.from(nil)
end
context 'when deployed already exists for the project' do
before do
deployment = create(:pages_deployment, project: project)
project.set_first_pages_deployment!(deployment)
end
it 'does not try to migrate project' do
expect(::Pages::MigrateLegacyStorageToDeploymentService).not_to receive(:new)
subject
end
end
end
subject
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Ci::PipelineArtifacts::CreateQualityReportWorker do
describe '#perform' do
subject { described_class.new.perform(pipeline_id) }
context 'when pipeline exists' do
let(:pipeline) { create(:ci_pipeline, :with_codequality_reports) }
let(:pipeline_id) { pipeline.id }
it 'calls pipeline codequality report service' do
expect_next_instance_of(::Ci::PipelineArtifacts::CreateQualityReportService) do |quality_report_service|
expect(quality_report_service).to receive(:execute)
end
subject
end
it_behaves_like 'an idempotent worker' do
let(:job_args) { pipeline_id }
it 'creates a pipeline artifact' do
expect { subject }.to change { pipeline.pipeline_artifacts.count }.by(1)
end
end
end
context 'when pipeline does not exist' do
let(:pipeline_id) { non_existing_record_id }
it 'does not call pipeline codequality report service' do
expect(Ci::PipelineArtifacts::CreateQualityReportService).not_to receive(:execute)
subject
end
end
end
end

View File

@ -876,10 +876,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@26.1.0":
version "26.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-26.1.0.tgz#c00b221d62b6ad7505bb0025e9becad523e3e00a"
integrity sha512-strCuRmmVKoOzh8Tlv8AyBvPsqY5l3FOeaySxzykZXS3lKt0iYoMPUv4iDwlg5E9J/1f91xaWryvLAWBV8qWHw==
"@gitlab/ui@26.3.0":
version "26.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-26.3.0.tgz#e25b2c67b2df92879711670ce53b26a73cd06f2a"
integrity sha512-WRSOp3WJkpxbRgJHq5mfCaVl7s2F6iGYnoMCAfbkt9rG4oLphtF7XWVIm/sVMnnVeqHonIaVJmsLi9rCQdxBzQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"