Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
1c8734ca5c
commit
60eaf3d906
|
@ -1 +1 @@
|
|||
15.2.0
|
||||
15.3.0
|
||||
|
|
6
Gemfile
6
Gemfile
|
@ -495,9 +495,9 @@ gem 'google-protobuf', '~> 3.21'
|
|||
gem 'toml-rb', '~> 2.0'
|
||||
|
||||
# Feature toggles
|
||||
gem 'flipper', '~> 0.21.0'
|
||||
gem 'flipper-active_record', '~> 0.21.0'
|
||||
gem 'flipper-active_support_cache_store', '~> 0.21.0'
|
||||
gem 'flipper', '~> 0.25.0'
|
||||
gem 'flipper-active_record', '~> 0.25.0'
|
||||
gem 'flipper-active_support_cache_store', '~> 0.25.0'
|
||||
gem 'unleash', '~> 3.2.2'
|
||||
gem 'gitlab-experiment', '~> 0.7.1'
|
||||
|
||||
|
|
20
Gemfile.lock
20
Gemfile.lock
|
@ -438,13 +438,13 @@ GEM
|
|||
libyajl2 (~> 1.2)
|
||||
filelock (1.1.1)
|
||||
find_a_port (1.0.1)
|
||||
flipper (0.21.0)
|
||||
flipper-active_record (0.21.0)
|
||||
activerecord (>= 5.0, < 7)
|
||||
flipper (~> 0.21.0)
|
||||
flipper-active_support_cache_store (0.21.0)
|
||||
activesupport (>= 5.0, < 7)
|
||||
flipper (~> 0.21.0)
|
||||
flipper (0.25.0)
|
||||
flipper-active_record (0.25.0)
|
||||
activerecord (>= 4.2, < 8)
|
||||
flipper (~> 0.25.0)
|
||||
flipper-active_support_cache_store (0.25.0)
|
||||
activesupport (>= 4.2, < 8)
|
||||
flipper (~> 0.25.0)
|
||||
flowdock (0.7.1)
|
||||
httparty (~> 0.7)
|
||||
multi_json
|
||||
|
@ -1557,9 +1557,9 @@ DEPENDENCIES
|
|||
faraday_middleware-aws-sigv4 (~> 0.3.0)
|
||||
fast_blank
|
||||
ffaker (~> 2.10)
|
||||
flipper (~> 0.21.0)
|
||||
flipper-active_record (~> 0.21.0)
|
||||
flipper-active_support_cache_store (~> 0.21.0)
|
||||
flipper (~> 0.25.0)
|
||||
flipper-active_record (~> 0.25.0)
|
||||
flipper-active_support_cache_store (~> 0.25.0)
|
||||
flowdock (~> 0.7)
|
||||
fog-aliyun (~> 0.3)
|
||||
fog-aws (~> 3.14)
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
<script>
|
||||
import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||
import {
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlDropdownDivider,
|
||||
GlModal,
|
||||
GlModalDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import Tracking from '~/tracking';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
deleteTask: s__('WorkItem|Delete task'),
|
||||
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
|
||||
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
|
||||
},
|
||||
components: {
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlDropdownDivider,
|
||||
GlModal,
|
||||
},
|
||||
directives: {
|
||||
|
@ -22,14 +31,33 @@ export default {
|
|||
required: false,
|
||||
default: null,
|
||||
},
|
||||
canUpdate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
canDelete: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isConfidential: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isParentConfidential: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['deleteWorkItem'],
|
||||
emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
|
||||
methods: {
|
||||
handleToggleWorkItemConfidentiality() {
|
||||
this.track('click_toggle_work_item_confidentiality');
|
||||
this.$emit('toggleWorkItemConfidentiality', !this.isConfidential);
|
||||
},
|
||||
handleDeleteWorkItem() {
|
||||
this.track('click_delete_work_item');
|
||||
this.$emit('deleteWorkItem');
|
||||
|
@ -44,7 +72,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="canDelete">
|
||||
<div>
|
||||
<gl-dropdown
|
||||
icon="ellipsis_v"
|
||||
text-sr-only
|
||||
|
@ -53,9 +81,24 @@ export default {
|
|||
no-caret
|
||||
right
|
||||
>
|
||||
<gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
|
||||
$options.i18n.deleteTask
|
||||
}}</gl-dropdown-item>
|
||||
<template v-if="canUpdate && !isParentConfidential">
|
||||
<gl-dropdown-item
|
||||
data-testid="confidentiality-toggle-action"
|
||||
@click="handleToggleWorkItemConfidentiality"
|
||||
>{{
|
||||
isConfidential
|
||||
? $options.i18n.disableTaskConfidentiality
|
||||
: $options.i18n.enableTaskConfidentiality
|
||||
}}</gl-dropdown-item
|
||||
>
|
||||
<gl-dropdown-divider />
|
||||
</template>
|
||||
<gl-dropdown-item
|
||||
v-if="canDelete"
|
||||
v-gl-modal="'work-item-confirm-delete'"
|
||||
data-testid="delete-action"
|
||||
>{{ $options.i18n.deleteTask }}</gl-dropdown-item
|
||||
>
|
||||
</gl-dropdown>
|
||||
<gl-modal
|
||||
modal-id="work-item-confirm-delete"
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
<script>
|
||||
import { GlAlert, GlSkeletonLoader, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
|
||||
import {
|
||||
GlAlert,
|
||||
GlSkeletonLoader,
|
||||
GlLoadingIcon,
|
||||
GlIcon,
|
||||
GlBadge,
|
||||
GlButton,
|
||||
GlTooltipDirective,
|
||||
} from '@gitlab/ui';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||
import {
|
||||
|
@ -11,8 +19,12 @@ import {
|
|||
WIDGET_TYPE_HIERARCHY,
|
||||
WORK_ITEM_VIEWED_STORAGE_KEY,
|
||||
} from '../constants';
|
||||
|
||||
import workItemQuery from '../graphql/work_item.query.graphql';
|
||||
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
|
||||
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
|
||||
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
|
||||
|
||||
import WorkItemActions from './work_item_actions.vue';
|
||||
import WorkItemState from './work_item_state.vue';
|
||||
import WorkItemTitle from './work_item_title.vue';
|
||||
|
@ -29,7 +41,9 @@ export default {
|
|||
},
|
||||
components: {
|
||||
GlAlert,
|
||||
GlBadge,
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
GlSkeletonLoader,
|
||||
GlIcon,
|
||||
WorkItemAssignees,
|
||||
|
@ -65,6 +79,7 @@ export default {
|
|||
error: undefined,
|
||||
workItem: {},
|
||||
showInfoBanner: true,
|
||||
updateInProgress: false,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
|
@ -125,6 +140,9 @@ export default {
|
|||
parentWorkItem() {
|
||||
return this.workItemHierarchy?.parent;
|
||||
},
|
||||
parentWorkItemConfidentiality() {
|
||||
return this.parentWorkItem?.confidential;
|
||||
},
|
||||
parentUrl() {
|
||||
return `../../issues/${this.parentWorkItem?.iid}`;
|
||||
},
|
||||
|
@ -138,6 +156,54 @@ export default {
|
|||
dismissBanner() {
|
||||
this.showInfoBanner = false;
|
||||
},
|
||||
toggleConfidentiality(confidentialStatus) {
|
||||
this.updateInProgress = true;
|
||||
let updateMutation = updateWorkItemMutation;
|
||||
let inputVariables = {
|
||||
id: this.workItemId,
|
||||
confidential: confidentialStatus,
|
||||
};
|
||||
|
||||
if (this.parentWorkItem) {
|
||||
updateMutation = updateWorkItemTaskMutation;
|
||||
inputVariables = {
|
||||
id: this.parentWorkItem.id,
|
||||
taskData: {
|
||||
id: this.workItemId,
|
||||
confidential: confidentialStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: updateMutation,
|
||||
variables: {
|
||||
input: inputVariables,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
({
|
||||
data: {
|
||||
workItemUpdate: { errors, workItem, task },
|
||||
},
|
||||
}) => {
|
||||
if (errors?.length) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
this.$emit('workItemUpdated', {
|
||||
confidential: workItem?.confidential || task?.confidential,
|
||||
});
|
||||
},
|
||||
)
|
||||
.catch((error) => {
|
||||
this.error = error.message;
|
||||
})
|
||||
.finally(() => {
|
||||
this.updateInProgress = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
WORK_ITEM_VIEWED_STORAGE_KEY,
|
||||
};
|
||||
|
@ -156,7 +222,7 @@ export default {
|
|||
</gl-skeleton-loader>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="gl-display-flex gl-align-items-center">
|
||||
<div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
|
||||
<ul
|
||||
v-if="parentWorkItem"
|
||||
class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0"
|
||||
|
@ -187,10 +253,19 @@ export default {
|
|||
data-testid="work-item-type"
|
||||
>{{ workItemType }}</span
|
||||
>
|
||||
<gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
|
||||
<gl-badge v-if="workItem.confidential" variant="warning" icon="eye-slash" class="gl-mr-3">{{
|
||||
__('Confidential')
|
||||
}}</gl-badge>
|
||||
<work-item-actions
|
||||
v-if="canUpdate || canDelete"
|
||||
:work-item-id="workItem.id"
|
||||
:can-delete="canDelete"
|
||||
:can-update="canUpdate"
|
||||
:is-confidential="workItem.confidential"
|
||||
:is-parent-confidential="parentWorkItemConfidentiality"
|
||||
@deleteWorkItem="$emit('deleteWorkItem')"
|
||||
@toggleWorkItemConfidentiality="toggleConfidentiality"
|
||||
@error="error = $event"
|
||||
/>
|
||||
<gl-button
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { GlButton, GlBadge, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { produce } from 'immer';
|
||||
import { s__ } from '~/locale';
|
||||
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
|
@ -30,6 +30,9 @@ export default {
|
|||
WorkItemLinksMenu,
|
||||
WorkItemDetailModal,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
inject: ['projectPath'],
|
||||
props: {
|
||||
workItemId: {
|
||||
|
@ -284,7 +287,15 @@ export default {
|
|||
class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
|
||||
data-testid="links-child"
|
||||
>
|
||||
<div class="gl-overflow-hidden">
|
||||
<div class="gl-overflow-hidden gl-display-flex gl-align-items-center">
|
||||
<gl-icon
|
||||
v-if="child.confidential"
|
||||
v-gl-tooltip.top
|
||||
name="eye-slash"
|
||||
class="gl-mr-3 gl-text-orange-500"
|
||||
data-testid="confidential-icon"
|
||||
:title="__('Confidential')"
|
||||
/>
|
||||
<gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
|
||||
<gl-button
|
||||
:href="childPath(child.id)"
|
||||
|
|
|
@ -5,6 +5,7 @@ fragment WorkItem on WorkItem {
|
|||
title
|
||||
state
|
||||
description
|
||||
confidential
|
||||
workItemType {
|
||||
id
|
||||
name
|
||||
|
@ -34,6 +35,7 @@ fragment WorkItem on WorkItem {
|
|||
id
|
||||
iid
|
||||
title
|
||||
confidential
|
||||
}
|
||||
children {
|
||||
nodes {
|
||||
|
|
|
@ -20,6 +20,7 @@ query workItemQuery($id: WorkItemID!) {
|
|||
children {
|
||||
nodes {
|
||||
id
|
||||
confidential
|
||||
workItemType {
|
||||
id
|
||||
}
|
||||
|
|
|
@ -11,13 +11,20 @@ module Mutations
|
|||
null: true,
|
||||
description: 'Job after the mutation.'
|
||||
|
||||
argument :variables, [::Types::Ci::VariableInputType],
|
||||
required: false,
|
||||
default_value: [],
|
||||
replace_null_with_default: true,
|
||||
description: 'Variables to use when retrying a manual job.'
|
||||
|
||||
authorize :update_build
|
||||
|
||||
def resolve(id:)
|
||||
def resolve(id:, variables:)
|
||||
job = authorized_find!(id: id)
|
||||
project = job.project
|
||||
variables = variables.map(&:to_h)
|
||||
|
||||
response = ::Ci::RetryJobService.new(project, current_user).execute(job)
|
||||
response = ::Ci::RetryJobService.new(project, current_user).execute(job, variables: variables)
|
||||
|
||||
if response.success?
|
||||
{
|
||||
|
|
|
@ -194,7 +194,7 @@ module Types
|
|||
end
|
||||
|
||||
def manual_variables
|
||||
if object.manual? && object.respond_to?(:job_variables)
|
||||
if object.action? && object.respond_to?(:job_variables)
|
||||
object.job_variables
|
||||
else
|
||||
[]
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
module Ci
|
||||
class VariableInputType < BaseInputObject
|
||||
graphql_name 'CiVariableInput'
|
||||
description 'Attributes for defining a CI/CD variable.'
|
||||
|
||||
argument :key, GraphQL::Types::String, description: 'Name of the variable.'
|
||||
argument :value, GraphQL::Types::String, description: 'Value of the variable.'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1161,6 +1161,17 @@ module Ci
|
|||
end
|
||||
end
|
||||
|
||||
def clone(current_user:, new_job_variables_attributes: [])
|
||||
new_build = super
|
||||
|
||||
if action? && new_job_variables_attributes.any?
|
||||
new_build.job_variables = []
|
||||
new_build.job_variables_attributes = new_job_variables_attributes
|
||||
end
|
||||
|
||||
new_build
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def run_status_commit_hooks!
|
||||
|
|
|
@ -22,7 +22,8 @@ module Ci
|
|||
accessibility: %w[accessibility],
|
||||
coverage: %w[cobertura],
|
||||
codequality: %w[codequality],
|
||||
terraform: %w[terraform]
|
||||
terraform: %w[terraform],
|
||||
sbom: %w[cyclonedx]
|
||||
}.freeze
|
||||
|
||||
DEFAULT_FILE_NAMES = {
|
||||
|
|
|
@ -101,7 +101,7 @@ module Ci
|
|||
:merge_train_pipeline?,
|
||||
to: :pipeline
|
||||
|
||||
def clone(current_user:)
|
||||
def clone(current_user:, new_job_variables_attributes: [])
|
||||
new_attributes = self.class.clone_accessors.to_h do |attribute|
|
||||
[attribute, public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
|
||||
end
|
||||
|
|
|
@ -19,6 +19,8 @@ class ContainerRepository < ApplicationRecord
|
|||
MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
|
||||
MIGRATION_PHASE_1_ENDED_AT = Date.new(2022, 01, 23).freeze
|
||||
|
||||
MAX_TAGS_PAGES = 2000
|
||||
|
||||
TooManyImportsError = Class.new(StandardError)
|
||||
|
||||
belongs_to :project
|
||||
|
@ -377,6 +379,10 @@ class ContainerRepository < ApplicationRecord
|
|||
migration_retries_count >= ContainerRegistry::Migration.max_retries - 1
|
||||
end
|
||||
|
||||
def migrated?
|
||||
MIGRATION_PHASE_1_ENDED_AT < self.created_at || import_done?
|
||||
end
|
||||
|
||||
def last_import_step_done_at
|
||||
[migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at].compact.max
|
||||
end
|
||||
|
@ -427,6 +433,32 @@ class ContainerRepository < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def each_tags_page(page_size: 100, &block)
|
||||
raise ArgumentError, 'not a migrated repository' unless migrated?
|
||||
raise ArgumentError, 'block not given' unless block
|
||||
|
||||
# dummy uri to initialize the loop
|
||||
next_page_uri = URI('')
|
||||
page_count = 0
|
||||
|
||||
while next_page_uri && page_count < MAX_TAGS_PAGES
|
||||
last = Rack::Utils.parse_nested_query(next_page_uri.query)['last']
|
||||
current_page = gitlab_api_client.tags(self.path, page_size: page_size, last: last)
|
||||
|
||||
if current_page&.key?(:response_body)
|
||||
yield transform_tags_page(current_page[:response_body])
|
||||
next_page_uri = current_page.dig(:pagination, :next, :uri)
|
||||
else
|
||||
# no current page. Break the loop
|
||||
next_page_uri = nil
|
||||
end
|
||||
|
||||
page_count += 1
|
||||
end
|
||||
|
||||
raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES
|
||||
end
|
||||
|
||||
def tags_count
|
||||
return 0 unless manifest && manifest['tags']
|
||||
|
||||
|
@ -559,6 +591,16 @@ class ContainerRepository < ApplicationRecord
|
|||
self.migration_skipped_reason = reason
|
||||
finish_import
|
||||
end
|
||||
|
||||
def transform_tags_page(tags_response_body)
|
||||
return [] unless tags_response_body
|
||||
|
||||
tags_response_body.map do |raw_tag|
|
||||
tag = ContainerRegistry::Tag.new(self, raw_tag['name'])
|
||||
tag.force_created_at_from_iso8601(raw_tag['created_at'])
|
||||
tag
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ContainerRepository.prepend_mod_with('ContainerRepository')
|
||||
|
|
|
@ -4,10 +4,10 @@ module Ci
|
|||
class RetryJobService < ::BaseService
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
def execute(job)
|
||||
def execute(job, variables: [])
|
||||
if job.retryable?
|
||||
job.ensure_scheduling_type!
|
||||
new_job = retry_job(job)
|
||||
new_job = retry_job(job, variables: variables)
|
||||
|
||||
ServiceResponse.success(payload: { job: new_job })
|
||||
else
|
||||
|
@ -19,7 +19,7 @@ module Ci
|
|||
end
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def clone!(job)
|
||||
def clone!(job, variables: [])
|
||||
# Cloning a job requires a strict type check to ensure
|
||||
# the attributes being used for the clone are taken straight
|
||||
# from the model and not overridden by other abstractions.
|
||||
|
@ -27,7 +27,7 @@ module Ci
|
|||
|
||||
check_access!(job)
|
||||
|
||||
new_job = job.clone(current_user: current_user)
|
||||
new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables)
|
||||
|
||||
new_job.run_after_commit do
|
||||
::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
|
||||
|
@ -55,8 +55,8 @@ module Ci
|
|||
|
||||
def check_assignable_runners!(job); end
|
||||
|
||||
def retry_job(job)
|
||||
clone!(job).tap do |new_job|
|
||||
def retry_job(job, variables: [])
|
||||
clone!(job, variables: variables).tap do |new_job|
|
||||
check_assignable_runners!(new_job) if new_job.is_a?(Ci::Build)
|
||||
|
||||
next if new_job.failed?
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Warning: gitlab.Admin
|
||||
# Suggestion: gitlab.Admin
|
||||
#
|
||||
# Checks for "admin" and recommends using the full word instead. "Admin Area" is OK.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Suggestion: gitlab.BadPlurals
|
||||
# Warning: gitlab.BadPlurals
|
||||
#
|
||||
# Don't write plural words with the '(s)' construction. "HTTP(S)" is acceptable.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Error: gitlab.EOLWhitespace
|
||||
# Warning: gitlab.EOLWhitespace
|
||||
#
|
||||
# Checks that there is no useless whitespace at the end of lines.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Suggestion: gitlab.FutureTense
|
||||
# Warning: gitlab.FutureTense
|
||||
#
|
||||
# Checks for use of future tense in sentences. Present tense is strongly preferred.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Error: gitlab.HeadingContent
|
||||
# Warning: gitlab.HeadingContent
|
||||
#
|
||||
# Checks for generic, unhelpful subheadings.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Warning: gitlab.OutdatedVersions
|
||||
# Suggestion: gitlab.OutdatedVersions
|
||||
#
|
||||
# Checks for references to versions of GitLab that are no longer supported.
|
||||
#
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Warning: gitlab.Possessive
|
||||
# Error: gitlab.Possessive
|
||||
#
|
||||
# The word GitLab should not be used in the possessive form.
|
||||
#
|
||||
|
|
|
@ -1549,7 +1549,7 @@ Input type: `CreateIssueInput`
|
|||
|
||||
WARNING:
|
||||
**Deprecated** in 14.0.
|
||||
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
|
||||
Use iterationCreate.
|
||||
|
||||
Input type: `CreateIterationInput`
|
||||
|
||||
|
@ -1561,7 +1561,7 @@ Input type: `CreateIterationInput`
|
|||
| <a id="mutationcreateiterationdescription"></a>`description` | [`String`](#string) | Description of the iteration. |
|
||||
| <a id="mutationcreateiterationduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
|
||||
| <a id="mutationcreateiterationgrouppath"></a>`groupPath` | [`ID`](#id) | Full path of the group with which the resource is associated. |
|
||||
| <a id="mutationcreateiterationiterationscadenceid"></a>`iterationsCadenceId` **{warning-solid}** | [`IterationsCadenceID`](#iterationscadenceid) | **Deprecated:** `iterationCadenceId` is deprecated and will be removed in the future. This argument is ignored, because you can't create an iteration in a specific cadence. In the future only automatic iteration cadences will be allowed. Deprecated in 14.10. |
|
||||
| <a id="mutationcreateiterationiterationscadenceid"></a>`iterationsCadenceId` | [`IterationsCadenceID`](#iterationscadenceid) | Global ID of the iteration cadence to be assigned to the new iteration. |
|
||||
| <a id="mutationcreateiterationprojectpath"></a>`projectPath` | [`ID`](#id) | Full path of the project with which the resource is associated. |
|
||||
| <a id="mutationcreateiterationstartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
|
||||
| <a id="mutationcreateiterationtitle"></a>`title` | [`String`](#string) | Title of the iteration. |
|
||||
|
@ -3370,10 +3370,6 @@ Input type: `IterationCadenceUpdateInput`
|
|||
|
||||
### `Mutation.iterationCreate`
|
||||
|
||||
WARNING:
|
||||
**Deprecated** in 14.10.
|
||||
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
|
||||
|
||||
Input type: `iterationCreateInput`
|
||||
|
||||
#### Arguments
|
||||
|
@ -3384,7 +3380,7 @@ Input type: `iterationCreateInput`
|
|||
| <a id="mutationiterationcreatedescription"></a>`description` | [`String`](#string) | Description of the iteration. |
|
||||
| <a id="mutationiterationcreateduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
|
||||
| <a id="mutationiterationcreategrouppath"></a>`groupPath` | [`ID`](#id) | Full path of the group with which the resource is associated. |
|
||||
| <a id="mutationiterationcreateiterationscadenceid"></a>`iterationsCadenceId` **{warning-solid}** | [`IterationsCadenceID`](#iterationscadenceid) | **Deprecated:** `iterationCadenceId` is deprecated and will be removed in the future. This argument is ignored, because you can't create an iteration in a specific cadence. In the future only automatic iteration cadences will be allowed. Deprecated in 14.10. |
|
||||
| <a id="mutationiterationcreateiterationscadenceid"></a>`iterationsCadenceId` | [`IterationsCadenceID`](#iterationscadenceid) | Global ID of the iteration cadence to be assigned to the new iteration. |
|
||||
| <a id="mutationiterationcreateprojectpath"></a>`projectPath` | [`ID`](#id) | Full path of the project with which the resource is associated. |
|
||||
| <a id="mutationiterationcreatestartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
|
||||
| <a id="mutationiterationcreatetitle"></a>`title` | [`String`](#string) | Title of the iteration. |
|
||||
|
@ -3399,10 +3395,6 @@ Input type: `iterationCreateInput`
|
|||
|
||||
### `Mutation.iterationDelete`
|
||||
|
||||
WARNING:
|
||||
**Deprecated** in 14.10.
|
||||
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
|
||||
|
||||
Input type: `IterationDeleteInput`
|
||||
|
||||
#### Arguments
|
||||
|
@ -3510,6 +3502,7 @@ Input type: `JobRetryInput`
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="mutationjobretryclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
|
||||
| <a id="mutationjobretryid"></a>`id` | [`CiBuildID!`](#cibuildid) | ID of the job to mutate. |
|
||||
| <a id="mutationjobretryvariables"></a>`variables` | [`[CiVariableInput!]`](#civariableinput) | Variables to use when retrying a manual job. |
|
||||
|
||||
#### Fields
|
||||
|
||||
|
@ -5218,11 +5211,11 @@ Input type: `UpdateIterationInput`
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="mutationupdateiterationclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
|
||||
| <a id="mutationupdateiterationdescription"></a>`description` | [`String`](#string) | Description of the iteration. |
|
||||
| <a id="mutationupdateiterationduedate"></a>`dueDate` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
|
||||
| <a id="mutationupdateiterationduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
|
||||
| <a id="mutationupdateiterationgrouppath"></a>`groupPath` | [`ID!`](#id) | Group of the iteration. |
|
||||
| <a id="mutationupdateiterationid"></a>`id` | [`ID!`](#id) | Global ID of the iteration. |
|
||||
| <a id="mutationupdateiterationstartdate"></a>`startDate` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
|
||||
| <a id="mutationupdateiterationtitle"></a>`title` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
|
||||
| <a id="mutationupdateiterationstartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
|
||||
| <a id="mutationupdateiterationtitle"></a>`title` | [`String`](#string) | Title of the iteration. |
|
||||
|
||||
#### Fields
|
||||
|
||||
|
@ -22103,6 +22096,17 @@ Field that are available while modifying the custom mapping attributes for an HT
|
|||
| <a id="boardissueinputweight"></a>`weight` | [`String`](#string) | Filter by weight. |
|
||||
| <a id="boardissueinputweightwildcardid"></a>`weightWildcardId` | [`WeightWildcardId`](#weightwildcardid) | Filter by weight ID wildcard. Incompatible with weight. |
|
||||
|
||||
### `CiVariableInput`
|
||||
|
||||
Attributes for defining a CI/CD variable.
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="civariableinputkey"></a>`key` | [`String!`](#string) | Name of the variable. |
|
||||
| <a id="civariableinputvalue"></a>`value` | [`String!`](#string) | Value of the variable. |
|
||||
|
||||
### `CommitAction`
|
||||
|
||||
#### Arguments
|
||||
|
|
|
@ -62,16 +62,14 @@ After you configure the OIDC and role, the GitLab CI/CD job can retrieve a tempo
|
|||
assume role:
|
||||
script:
|
||||
- >
|
||||
STS=($(aws sts assume-role-with-web-identity
|
||||
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
|
||||
$(aws sts assume-role-with-web-identity
|
||||
--role-arn ${ROLE_ARN}
|
||||
--role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
|
||||
--web-identity-token $CI_JOB_JWT_V2
|
||||
--duration-seconds 3600
|
||||
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
|
||||
--output text))
|
||||
- export AWS_ACCESS_KEY_ID="${STS[0]}"
|
||||
- export AWS_SECRET_ACCESS_KEY="${STS[1]}"
|
||||
- export AWS_SESSION_TOKEN="${STS[2]}"
|
||||
- aws sts get-caller-identity
|
||||
```
|
||||
|
||||
|
|
|
@ -889,3 +889,23 @@ Filter entries where stale runners were removed:
|
|||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Determine which runners need to be upgraded **(ULTIMATE)**
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/365078) in GitLab 15.3.
|
||||
|
||||
The version of GitLab Runner used by your runners should be
|
||||
[kept up-to-date](https://docs.gitlab.com/runner/index.html#gitlab-runner-versions).
|
||||
|
||||
To determine which runners need to be upgraded:
|
||||
|
||||
1. View the list of runners:
|
||||
- For a group, on the top bar, select **Menu > Groups** and on the left sidebar, select **CI/CD > Runners**.
|
||||
- For the instance, select **Menu > Admin** and on the left sidebar, select **Runners**.
|
||||
|
||||
1. Above the list of runners, view the status:
|
||||
- **Outdated - recommended**: The runner does not have the latest `PATCH` version, which may make it vulnerable
|
||||
to security or high severity bugs. Or, the runner is one or more `MAJOR` versions behind your GitLab instance, so some features may not be available or work properly.
|
||||
- **Outdated - available**: Newer versions are available but upgrading is not critical.
|
||||
|
||||
1. Filter the list by status to view which individual runners need to be upgraded.
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
|
@ -18,5 +18,25 @@ The number of minutes you can use on these runners depends on the
|
|||
[maximum number of CI/CD minutes](../pipelines/cicd_minutes.md)
|
||||
in your [subscription plan](https://about.gitlab.com/pricing/).
|
||||
|
||||
If you use self-managed GitLab or you use GitLab.com but want to use your own runners, you can
|
||||
[install and configure your own runners](https://docs.gitlab.com/runner/install/).
|
||||
## Security for GitLab SaaS runners
|
||||
|
||||
GitLab SaaS runners on Linux and Windows run on Google Compute Platform. The [Google Infrastructure Security Design Overview whitepaper](https://cloud.google.com/docs/security/infrastructure/design/resources/google_infrastructure_whitepaper_fa.pdf) provides an overview of how Google designs security into its technical infrastructure. The GitLab [Trust Center](https://about.gitlab.com/security/) and [GitLab Security Compliance Controls](https://about.staging.gitlab.com/handbook/engineering/security/security-assurance/security-compliance/sec-controls.html) pages provide an overview of the Security and Compliance controls that govern the GitLab SaaS runners.
|
||||
|
||||
The runner that serves as a Runner Manager automatically initiates the creation and deletion of the virtual machines (VMs) used for CI jobs. When the Runner Manager picks up a GitLab SaaS CI job, it automatically executes that job on a new VM. There is no human or manual intervention in this process. The following section provides an overview of the additional built-in layers that harden the security of the GitLab Runner SaaS CI build environment.
|
||||
|
||||
### Security of CI job execution on GitLab Runner SaaS (Linux, Windows)
|
||||
|
||||
A dedicated temporary runner VM hosts and runs each CI job. On GitLab SaaS, two CI jobs never run on the same VM.
|
||||
|
||||
![Job isolation](img/build_isolation.png)
|
||||
|
||||
In this example, there are three jobs in the project's pipeline. Therefore, there are three temporary VMs used to run that pipeline, or one VM per job.
|
||||
|
||||
GitLab sends the command to remove the temporary runner VM to the Google Compute API immediately after the CI job completes. The [Google Compute Engine hypervisor](https://cloud.google.com/blog/products/gcp/7-ways-we-harden-our-kvm-hypervisor-at-google-cloud-security-in-plaintext) takes over the task of securely deleting the virtual machine and associated data.
|
||||
|
||||
### Network security of CI job virtual machines on GitLab Runner SaaS (Linux, Windows)
|
||||
|
||||
- Firewall rules only allow outbound communication from the temporary VM to the public internet.
|
||||
- Inbound communication from the public internet to the temporary VM is not allowed.
|
||||
- Firewall rules do not permit communication between VMs.
|
||||
- The only internal communication allowed to the temporary VMs is from the Runner Manager.
|
||||
|
|
|
@ -21,6 +21,8 @@ module ContainerRegistry
|
|||
|
||||
REGISTRY_GITLAB_V1_API_FEATURE = 'gitlab_v1_api'
|
||||
|
||||
MAX_TAGS_PAGE_SIZE = 1000
|
||||
|
||||
def self.supports_gitlab_api?
|
||||
with_dummy_client(return_value_if_disabled: false) do |client|
|
||||
client.supports_gitlab_api?
|
||||
|
@ -86,6 +88,7 @@ module ContainerRegistry
|
|||
end
|
||||
end
|
||||
|
||||
# https://gitlab.com/gitlab-org/container-registry/-/blob/master/docs-gitlab/api.md#get-repository-details
|
||||
def repository_details(path, sizing: nil)
|
||||
with_token_faraday do |faraday_client|
|
||||
req = faraday_client.get("/gitlab/v1/repositories/#{path}/") do |req|
|
||||
|
@ -98,6 +101,26 @@ module ContainerRegistry
|
|||
end
|
||||
end
|
||||
|
||||
# https://gitlab.com/gitlab-org/container-registry/-/blob/master/docs-gitlab/api.md#list-repository-tags
|
||||
def tags(path, page_size: 100, last: nil)
|
||||
limited_page_size = [page_size, MAX_TAGS_PAGE_SIZE].min
|
||||
with_token_faraday do |faraday_client|
|
||||
response = faraday_client.get("/gitlab/v1/repositories/#{path}/tags/list/") do |req|
|
||||
req.params['n'] = limited_page_size
|
||||
req.params['last'] = last if last
|
||||
end
|
||||
|
||||
break {} unless response.success?
|
||||
|
||||
link_parser = Gitlab::Utils::LinkHeaderParser.new(response.headers['link'])
|
||||
|
||||
{
|
||||
pagination: link_parser.parse,
|
||||
response_body: response_body(response)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_import_for(path, pre:)
|
||||
|
|
|
@ -75,15 +75,28 @@ module ContainerRegistry
|
|||
|
||||
def created_at
|
||||
return @created_at if @created_at
|
||||
return unless config
|
||||
|
||||
strong_memoize(:memoized_created_at) do
|
||||
next unless config
|
||||
|
||||
DateTime.rfc3339(config['created'])
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# this function will set and memoize a created_at
|
||||
# to avoid a #config_blob call.
|
||||
def force_created_at_from_iso8601(string_value)
|
||||
date =
|
||||
begin
|
||||
DateTime.iso8601(string_value)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
instance_variable_set(ivar(:memoized_created_at), date)
|
||||
end
|
||||
|
||||
def layers
|
||||
return unless manifest
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ module Gitlab
|
|||
accessibility: ::Gitlab::Ci::Parsers::Accessibility::Pa11y,
|
||||
codequality: ::Gitlab::Ci::Parsers::Codequality::CodeClimate,
|
||||
sast: ::Gitlab::Ci::Parsers::Security::Sast,
|
||||
secret_detection: ::Gitlab::Ci::Parsers::Security::SecretDetection
|
||||
secret_detection: ::Gitlab::Ci::Parsers::Security::SecretDetection,
|
||||
cyclonedx: ::Gitlab::Ci::Parsers::Sbom::Cyclonedx
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -8,13 +8,9 @@ module Gitlab
|
|||
SUPPORTED_SPEC_VERSIONS = %w[1.4].freeze
|
||||
COMPONENT_ATTRIBUTES = %w[type name version].freeze
|
||||
|
||||
def initialize(json_data, report)
|
||||
@json_data = json_data
|
||||
@report = report
|
||||
end
|
||||
|
||||
def parse!
|
||||
@data = Gitlab::Json.parse(json_data)
|
||||
def parse!(blob, sbom_report)
|
||||
@report = sbom_report
|
||||
@data = Gitlab::Json.parse(blob)
|
||||
|
||||
return unless valid?
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
module Reports
|
||||
module Sbom
|
||||
class Reports
|
||||
attr_reader :reports
|
||||
|
||||
def initialize
|
||||
@reports = []
|
||||
end
|
||||
|
||||
def add_report(report)
|
||||
@reports << report
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Utils
|
||||
# Parses Link http headers (as defined in https://www.rfc-editor.org/rfc/rfc5988.txt)
|
||||
#
|
||||
# The URI-references with their relation type are extracted and returned as a hash
|
||||
# Example:
|
||||
#
|
||||
# header = '<http://test.org/TheBook/chapter2>; rel="previous", <http://test.org/TheBook/chapter4>; rel="next"'
|
||||
#
|
||||
# Gitlab::Utils::LinkHeaderParser.new(header).parse
|
||||
# {
|
||||
# previous: {
|
||||
# uri: #<URI::HTTP http://test.org/TheBook/chapter2>
|
||||
# },
|
||||
# next: {
|
||||
# uri: #<URI::HTTP http://test.org/TheBook/chapter4>
|
||||
# }
|
||||
# }
|
||||
class LinkHeaderParser
|
||||
REL_PATTERN = %r{rel="(\w+)"}.freeze
|
||||
# to avoid parse really long URIs we limit the amount of characters allowed
|
||||
URI_PATTERN = %r{<(.{1,500})>}.freeze
|
||||
|
||||
def initialize(header)
|
||||
@header = header
|
||||
end
|
||||
|
||||
def parse
|
||||
return {} if @header.blank?
|
||||
|
||||
links = @header.split(',')
|
||||
result = {}
|
||||
links.each do |link|
|
||||
direction = link[REL_PATTERN, 1]&.to_sym
|
||||
uri = link[URI_PATTERN, 1]
|
||||
|
||||
result[direction] = { uri: URI(uri) } if direction && uri
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22006,6 +22006,9 @@ msgstr ""
|
|||
msgid "Iteration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iteration cannot be created for cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iteration changed to"
|
||||
msgstr ""
|
||||
|
||||
|
@ -22021,7 +22024,10 @@ msgstr ""
|
|||
msgid "Iterations"
|
||||
msgstr ""
|
||||
|
||||
msgid "IterationsCadence|Manual iteration cadences are deprecated. Only automatic iteration cadences are allowed."
|
||||
msgid "Iterations cadence not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations cannot be manually added to cadences that use automatic scheduling"
|
||||
msgstr ""
|
||||
|
||||
msgid "IterationsCadence|The automation start date must come after the active iteration %{iteration_dates}."
|
||||
|
@ -23999,9 +24005,6 @@ msgstr ""
|
|||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
msgid "Manual iteration cadences are deprecated. Only automatic iteration cadences are allowed."
|
||||
msgstr ""
|
||||
|
||||
msgid "ManualOrdering|Couldn't save the order of the issues"
|
||||
msgstr ""
|
||||
|
||||
|
@ -44398,6 +44401,12 @@ msgstr ""
|
|||
msgid "WorkItem|Task deleted"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Turn off confidentiality"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Turn on confidentiality"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Type"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -84,8 +84,10 @@ RSpec.describe 'Issue Sidebar' do
|
|||
click_link user2.name
|
||||
end
|
||||
|
||||
find('.js-right-sidebar').click
|
||||
find('.block.assignee .edit-link').click
|
||||
within '.js-right-sidebar' do
|
||||
find('.block.assignee').click(x: 0, y: 0)
|
||||
find('.block.assignee .edit-link').click
|
||||
end
|
||||
|
||||
expect(page.all('.dropdown-menu-user li').length).to eq(1)
|
||||
expect(find('.dropdown-input-field').value).to eq(user2.name)
|
||||
|
|
|
@ -155,7 +155,7 @@ RSpec.describe "Issues > User edits issue", :js do
|
|||
|
||||
page.within '.block.labels' do
|
||||
# Remove `verisimilitude` label
|
||||
within '.gl-label' do
|
||||
within '.gl-label', text: 'verisimilitude' do
|
||||
click_button 'Remove label'
|
||||
end
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { GlDropdownItem, GlModal } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlModal } from '@gitlab/ui';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
|
||||
|
||||
describe('WorkItemActions component', () => {
|
||||
|
@ -7,12 +7,19 @@ describe('WorkItemActions component', () => {
|
|||
let glModalDirective;
|
||||
|
||||
const findModal = () => wrapper.findComponent(GlModal);
|
||||
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
|
||||
const findConfidentialityToggleButton = () =>
|
||||
wrapper.findByTestId('confidentiality-toggle-action');
|
||||
const findDeleteButton = () => wrapper.findByTestId('delete-action');
|
||||
|
||||
const createComponent = ({ canDelete = true } = {}) => {
|
||||
const createComponent = ({
|
||||
canUpdate = true,
|
||||
canDelete = true,
|
||||
isConfidential = false,
|
||||
isParentConfidential = false,
|
||||
} = {}) => {
|
||||
glModalDirective = jest.fn();
|
||||
wrapper = shallowMount(WorkItemActions, {
|
||||
propsData: { workItemId: '123', canDelete },
|
||||
wrapper = shallowMountExtended(WorkItemActions, {
|
||||
propsData: { workItemId: '123', canUpdate, canDelete, isConfidential, isParentConfidential },
|
||||
directives: {
|
||||
glModal: {
|
||||
bind(_, { value }) {
|
||||
|
@ -34,27 +41,69 @@ describe('WorkItemActions component', () => {
|
|||
expect(findModal().props('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows confirm modal when clicking Delete work item', () => {
|
||||
it('renders dropdown actions', () => {
|
||||
createComponent();
|
||||
|
||||
findDeleteButton().vm.$emit('click');
|
||||
|
||||
expect(glModalDirective).toHaveBeenCalled();
|
||||
expect(findConfidentialityToggleButton().exists()).toBe(true);
|
||||
expect(findDeleteButton().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits event when clicking OK button', () => {
|
||||
createComponent();
|
||||
describe('toggle confidentiality action', () => {
|
||||
it.each`
|
||||
isConfidential | buttonText
|
||||
${true} | ${'Turn off confidentiality'}
|
||||
${false} | ${'Turn on confidentiality'}
|
||||
`(
|
||||
'renders confidentiality toggle button with text "$buttonText"',
|
||||
({ isConfidential, buttonText }) => {
|
||||
createComponent({ isConfidential });
|
||||
|
||||
findModal().vm.$emit('ok');
|
||||
expect(findConfidentialityToggleButton().text()).toBe(buttonText);
|
||||
},
|
||||
);
|
||||
|
||||
expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]);
|
||||
});
|
||||
it('emits `toggleWorkItemConfidentiality` event when clicked', () => {
|
||||
createComponent();
|
||||
|
||||
it('does not render when canDelete is false', () => {
|
||||
createComponent({
|
||||
canDelete: false,
|
||||
findConfidentialityToggleButton().vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('toggleWorkItemConfidentiality')[0]).toEqual([true]);
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
it.each`
|
||||
props | propName | value
|
||||
${{ isParentConfidential: true }} | ${'isParentConfidential'} | ${true}
|
||||
${{ canUpdate: false }} | ${'canUpdate'} | ${false}
|
||||
`('does not render when $propName is $value', ({ props }) => {
|
||||
createComponent(props);
|
||||
|
||||
expect(findConfidentialityToggleButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('shows confirm modal when clicked', () => {
|
||||
createComponent();
|
||||
|
||||
findDeleteButton().vm.$emit('click');
|
||||
|
||||
expect(glModalDirective).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits event when clicking OK button', () => {
|
||||
createComponent();
|
||||
|
||||
findModal().vm.$emit('ok');
|
||||
|
||||
expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('does not render when canDelete is false', () => {
|
||||
createComponent({
|
||||
canDelete: false,
|
||||
});
|
||||
|
||||
expect(wrapper.findByTestId('delete-action').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -132,6 +132,14 @@ describe('WorkItemLinks', () => {
|
|||
expect(findFirstLinksMenu().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders confidentiality icon when child item is confidential', () => {
|
||||
const children = wrapper.findAll('[data-testid="links-child"]');
|
||||
const confidentialIcon = children.at(0).find('[data-testid="confidential-icon"]');
|
||||
|
||||
expect(confidentialIcon.exists()).toBe(true);
|
||||
expect(confidentialIcon.props('name')).toBe('eye-slash');
|
||||
});
|
||||
|
||||
describe('when no permission to update', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({ response: workItemHierarchyNoUpdatePermissionResponse });
|
||||
|
|
|
@ -25,6 +25,7 @@ export const workItemQueryResponse = {
|
|||
title: 'Test',
|
||||
state: 'OPEN',
|
||||
description: 'description',
|
||||
confidential: false,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
|
@ -57,6 +58,7 @@ export const workItemQueryResponse = {
|
|||
id: 'gid://gitlab/Issue/1',
|
||||
iid: '5',
|
||||
title: 'Parent title',
|
||||
confidential: false,
|
||||
},
|
||||
children: {
|
||||
nodes: [
|
||||
|
@ -82,6 +84,7 @@ export const updateWorkItemMutationResponse = {
|
|||
title: 'Updated title',
|
||||
state: 'OPEN',
|
||||
description: 'description',
|
||||
confidential: false,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
|
@ -115,12 +118,23 @@ export const updateWorkItemMutationResponse = {
|
|||
},
|
||||
};
|
||||
|
||||
export const mockParent = {
|
||||
parent: {
|
||||
id: 'gid://gitlab/Issue/1',
|
||||
iid: '5',
|
||||
title: 'Parent title',
|
||||
confidential: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const workItemResponseFactory = ({
|
||||
canUpdate = false,
|
||||
canDelete = false,
|
||||
allowsMultipleAssignees = true,
|
||||
assigneesWidgetPresent = true,
|
||||
weightWidgetPresent = true,
|
||||
parent = null,
|
||||
confidential = false,
|
||||
parent = mockParent.parent,
|
||||
} = {}) => ({
|
||||
data: {
|
||||
workItem: {
|
||||
|
@ -129,13 +143,14 @@ export const workItemResponseFactory = ({
|
|||
title: 'Updated title',
|
||||
state: 'OPEN',
|
||||
description: 'description',
|
||||
confidential,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
name: 'Task',
|
||||
},
|
||||
userPermissions: {
|
||||
deleteWorkItem: false,
|
||||
deleteWorkItem: canDelete,
|
||||
updateWorkItem: canUpdate,
|
||||
},
|
||||
widgets: [
|
||||
|
@ -216,6 +231,7 @@ export const createWorkItemMutationResponse = {
|
|||
title: 'Updated title',
|
||||
state: 'OPEN',
|
||||
description: 'description',
|
||||
confidential: false,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
|
@ -243,6 +259,7 @@ export const createWorkItemFromTaskMutationResponse = {
|
|||
id: 'gid://gitlab/WorkItem/1',
|
||||
title: 'Updated title',
|
||||
state: 'OPEN',
|
||||
confidential: false,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
|
@ -267,6 +284,7 @@ export const createWorkItemFromTaskMutationResponse = {
|
|||
title: 'Updated title',
|
||||
state: 'OPEN',
|
||||
description: '',
|
||||
confidential: false,
|
||||
workItemType: {
|
||||
__typename: 'WorkItemType',
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
|
@ -399,6 +417,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
|
|||
},
|
||||
title: 'xyz',
|
||||
state: 'OPEN',
|
||||
confidential: false,
|
||||
__typename: 'WorkItem',
|
||||
},
|
||||
],
|
||||
|
@ -444,6 +463,7 @@ export const workItemHierarchyResponse = {
|
|||
},
|
||||
title: 'xyz',
|
||||
state: 'OPEN',
|
||||
confidential: true,
|
||||
__typename: 'WorkItem',
|
||||
},
|
||||
{
|
||||
|
@ -454,6 +474,7 @@ export const workItemHierarchyResponse = {
|
|||
},
|
||||
title: 'abc',
|
||||
state: 'CLOSED',
|
||||
confidential: false,
|
||||
__typename: 'WorkItem',
|
||||
},
|
||||
{
|
||||
|
@ -464,6 +485,7 @@ export const workItemHierarchyResponse = {
|
|||
},
|
||||
title: 'bar',
|
||||
state: 'OPEN',
|
||||
confidential: false,
|
||||
__typename: 'WorkItem',
|
||||
},
|
||||
{
|
||||
|
@ -474,6 +496,7 @@ export const workItemHierarchyResponse = {
|
|||
},
|
||||
title: 'foobar',
|
||||
state: 'OPEN',
|
||||
confidential: false,
|
||||
__typename: 'WorkItem',
|
||||
},
|
||||
],
|
||||
|
@ -505,6 +528,7 @@ export const changeWorkItemParentMutationResponse = {
|
|||
id: 'gid://gitlab/WorkItem/2',
|
||||
state: 'OPEN',
|
||||
title: 'Foo',
|
||||
confidential: false,
|
||||
widgets: [
|
||||
{
|
||||
__typename: 'WorkItemWidgetHierarchy',
|
||||
|
@ -662,11 +686,3 @@ export const projectLabelsResponse = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockParent = {
|
||||
parent: {
|
||||
id: 'gid://gitlab/Issue/1',
|
||||
iid: '5',
|
||||
title: 'Parent title',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { GlAlert, GlSkeletonLoader, GlButton } from '@gitlab/ui';
|
||||
import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
|
||||
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
|
||||
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
|
||||
import WorkItemState from '~/work_items/components/work_item_state.vue';
|
||||
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
|
||||
|
@ -16,6 +17,8 @@ import WorkItemInformation from '~/work_items/components/work_item_information.v
|
|||
import { i18n } from '~/work_items/constants';
|
||||
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
|
||||
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
|
||||
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
|
||||
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
|
||||
import { temporaryConfig } from '~/work_items/graphql/provider';
|
||||
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
|
||||
import {
|
||||
|
@ -30,12 +33,19 @@ describe('WorkItemDetail component', () => {
|
|||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const workItemQueryResponse = workItemResponseFactory();
|
||||
const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
|
||||
const workItemQueryResponseWithoutParent = workItemResponseFactory({
|
||||
parent: null,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
});
|
||||
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
|
||||
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
|
||||
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader);
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findWorkItemActions = () => wrapper.findComponent(WorkItemActions);
|
||||
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
|
||||
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
|
||||
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
|
||||
|
@ -51,17 +61,21 @@ describe('WorkItemDetail component', () => {
|
|||
|
||||
const createComponent = ({
|
||||
isModal = false,
|
||||
updateInProgress = false,
|
||||
workItemId = workItemQueryResponse.data.workItem.id,
|
||||
handler = successHandler,
|
||||
subscriptionHandler = initialSubscriptionHandler,
|
||||
confidentialityMock = [updateWorkItemMutation, jest.fn()],
|
||||
workItemsMvc2Enabled = false,
|
||||
includeWidgets = false,
|
||||
error = undefined,
|
||||
} = {}) => {
|
||||
wrapper = shallowMount(WorkItemDetail, {
|
||||
apolloProvider: createMockApollo(
|
||||
[
|
||||
[workItemQuery, handler],
|
||||
[workItemTitleSubscription, subscriptionHandler],
|
||||
confidentialityMock,
|
||||
],
|
||||
{},
|
||||
{
|
||||
|
@ -69,6 +83,12 @@ describe('WorkItemDetail component', () => {
|
|||
},
|
||||
),
|
||||
propsData: { isModal, workItemId },
|
||||
data() {
|
||||
return {
|
||||
updateInProgress,
|
||||
error,
|
||||
};
|
||||
},
|
||||
provide: {
|
||||
glFeatures: {
|
||||
workItemsMvc2: workItemsMvc2Enabled,
|
||||
|
@ -146,6 +166,145 @@ describe('WorkItemDetail component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('confidentiality', () => {
|
||||
const errorMessage = 'Mutation failed';
|
||||
const confidentialWorkItem = workItemResponseFactory({
|
||||
confidential: true,
|
||||
});
|
||||
|
||||
// Mocks for work item without parent
|
||||
const withoutParentExpectedInputVars = {
|
||||
id: workItemQueryResponse.data.workItem.id,
|
||||
confidential: true,
|
||||
};
|
||||
const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
workItemUpdate: {
|
||||
workItem: confidentialWorkItem.data.workItem,
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const withoutParentHandlerMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue(workItemQueryResponseWithoutParent);
|
||||
const confidentialityWithoutParentMock = [
|
||||
updateWorkItemMutation,
|
||||
toggleConfidentialityWithoutParentHandler,
|
||||
];
|
||||
const confidentialityWithoutParentFailureMock = [
|
||||
updateWorkItemMutation,
|
||||
jest.fn().mockRejectedValue(new Error(errorMessage)),
|
||||
];
|
||||
|
||||
// Mocks for work item with parent
|
||||
const withParentExpectedInputVars = {
|
||||
id: mockParent.parent.id,
|
||||
taskData: { id: workItemQueryResponse.data.workItem.id, confidential: true },
|
||||
};
|
||||
const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
workItemUpdate: {
|
||||
workItem: {
|
||||
id: confidentialWorkItem.data.workItem.id,
|
||||
descriptionHtml: confidentialWorkItem.data.workItem.description,
|
||||
},
|
||||
task: {
|
||||
workItem: confidentialWorkItem.data.workItem,
|
||||
confidential: true,
|
||||
},
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const confidentialityWithParentMock = [
|
||||
updateWorkItemTaskMutation,
|
||||
toggleConfidentialityWithParentHandler,
|
||||
];
|
||||
const confidentialityWithParentFailureMock = [
|
||||
updateWorkItemTaskMutation,
|
||||
jest.fn().mockRejectedValue(new Error(errorMessage)),
|
||||
];
|
||||
|
||||
describe.each`
|
||||
context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables
|
||||
${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars}
|
||||
${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars}
|
||||
`(
|
||||
'when work item has $context',
|
||||
({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => {
|
||||
it('renders confidential badge when work item is confidential', async () => {
|
||||
createComponent({
|
||||
handler: jest.fn().mockResolvedValue(confidentialWorkItem),
|
||||
confidentialityMock,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const confidentialBadge = wrapper.findComponent(GlBadge);
|
||||
expect(confidentialBadge.exists()).toBe(true);
|
||||
expect(confidentialBadge.props()).toMatchObject({
|
||||
variant: 'warning',
|
||||
icon: 'eye-slash',
|
||||
});
|
||||
expect(confidentialBadge.text()).toBe('Confidential');
|
||||
});
|
||||
|
||||
it('renders gl-loading-icon while update mutation is in progress', async () => {
|
||||
createComponent({
|
||||
handler: handlerMock,
|
||||
confidentialityMock,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits workItemUpdated and shows confidentiality badge when mutation is successful', async () => {
|
||||
createComponent({
|
||||
handler: handlerMock,
|
||||
confidentialityMock,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]);
|
||||
expect(confidentialityMock[1]).toHaveBeenCalledWith({
|
||||
input: inputVariables,
|
||||
});
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows alert message when mutation fails', async () => {
|
||||
createComponent({
|
||||
handler: handlerMock,
|
||||
confidentialityMock: confidentialityFailureMock,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.emitted('workItemUpdated')).toBeFalsy();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
expect(findAlert().text()).toBe(errorMessage);
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('does not show description widget if loading description fails', () => {
|
||||
createComponent();
|
||||
|
@ -169,7 +328,7 @@ describe('WorkItemDetail component', () => {
|
|||
});
|
||||
|
||||
it('does not show secondary breadcrumbs if there is not a parent', async () => {
|
||||
createComponent();
|
||||
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
|
@ -177,7 +336,7 @@ describe('WorkItemDetail component', () => {
|
|||
});
|
||||
|
||||
it('shows work item type if there is not a parent', async () => {
|
||||
createComponent();
|
||||
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
|
||||
|
||||
await waitForPromises();
|
||||
expect(findWorkItemType().exists()).toBe(true);
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['CiVariableInput'] do
|
||||
include GraphqlHelpers
|
||||
|
||||
it 'has the correct arguments' do
|
||||
expect(described_class.arguments.keys).to match_array(%w[key value])
|
||||
end
|
||||
end
|
|
@ -212,6 +212,105 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#tags' do
|
||||
let(:path) { 'namespace/path/to/repository' }
|
||||
let(:page_size) { 100 }
|
||||
let(:last) { nil }
|
||||
let(:response) do
|
||||
[
|
||||
{
|
||||
name: '0.1.0',
|
||||
digest: 'sha256:1234567890',
|
||||
media_type: 'application/vnd.oci.image.manifest.v1+json',
|
||||
size_bytes: 1234567890,
|
||||
created_at: 5.minutes.ago
|
||||
},
|
||||
{
|
||||
name: 'latest',
|
||||
digest: 'sha256:1234567892',
|
||||
media_type: 'application/vnd.oci.image.manifest.v1+json',
|
||||
size_bytes: 1234567892,
|
||||
created_at: 10.minutes.ago
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
subject { client.tags(path, page_size: page_size, last: last) }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:expected) do
|
||||
{
|
||||
pagination: {},
|
||||
response_body: ::Gitlab::Json.parse(response.to_json)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_tags(path, page_size: page_size, respond_with: response)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'with a response with a link header' do
|
||||
let(:next_page_url) { 'http://sandbox.org/test?last=b' }
|
||||
let(:expected) do
|
||||
{
|
||||
pagination: { next: { uri: URI(next_page_url) } },
|
||||
response_body: ::Gitlab::Json.parse(response.to_json)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_tags(path, page_size: page_size, next_page_url: next_page_url, respond_with: response)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'with a large page size set' do
|
||||
let(:page_size) { described_class::MAX_TAGS_PAGE_SIZE + 1000 }
|
||||
|
||||
let(:expected) do
|
||||
{
|
||||
pagination: {},
|
||||
response_body: ::Gitlab::Json.parse(response.to_json)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_tags(path, page_size: described_class::MAX_TAGS_PAGE_SIZE, respond_with: response)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'with a last parameter set' do
|
||||
let(:last) { 'test' }
|
||||
|
||||
let(:expected) do
|
||||
{
|
||||
pagination: {},
|
||||
response_body: ::Gitlab::Json.parse(response.to_json)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_tags(path, page_size: page_size, last: last, respond_with: response)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'with non successful response' do
|
||||
before do
|
||||
stub_tags(path, page_size: page_size, status_code: 404)
|
||||
end
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.supports_gitlab_api?' do
|
||||
subject { described_class.supports_gitlab_api? }
|
||||
|
||||
|
@ -389,4 +488,30 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
|
|||
.with(headers: headers)
|
||||
.to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE })
|
||||
end
|
||||
|
||||
def stub_tags(path, page_size: nil, last: nil, next_page_url: nil, status_code: 200, respond_with: {})
|
||||
params = { n: page_size, last: last }.compact
|
||||
|
||||
url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/tags/list/"
|
||||
|
||||
if params.present?
|
||||
url += "?#{params.map { |param, val| "#{param}=#{val}" }.join('&')}"
|
||||
end
|
||||
|
||||
request_headers = { 'Accept' => described_class::JSON_TYPE }
|
||||
request_headers['Authorization'] = "bearer #{token}" if token
|
||||
|
||||
response_headers = { 'Content-Type' => described_class::JSON_TYPE }
|
||||
if next_page_url
|
||||
response_headers['Link'] = "<#{next_page_url}>; rel=\"next\""
|
||||
end
|
||||
|
||||
stub_request(:get, url)
|
||||
.with(headers: request_headers)
|
||||
.to_return(
|
||||
status: status_code,
|
||||
body: respond_with.to_json,
|
||||
headers: response_headers
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -205,6 +205,41 @@ RSpec.describe ContainerRegistry::Tag do
|
|||
|
||||
it_behaves_like 'a processable'
|
||||
end
|
||||
|
||||
describe '#force_created_at_from_iso8601' do
|
||||
subject { tag.force_created_at_from_iso8601(input) }
|
||||
|
||||
shared_examples 'setting and caching the created_at value' do
|
||||
it 'sets and caches the created_at value' do
|
||||
expect(tag).not_to receive(:config)
|
||||
|
||||
subject
|
||||
|
||||
expect(tag.created_at).to eq(expected_value)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid input' do
|
||||
let(:input) { 2.days.ago.iso8601 }
|
||||
let(:expected_value) { DateTime.iso8601(input) }
|
||||
|
||||
it_behaves_like 'setting and caching the created_at value'
|
||||
end
|
||||
|
||||
context 'with a nil input' do
|
||||
let(:input) { nil }
|
||||
let(:expected_value) { nil }
|
||||
|
||||
it_behaves_like 'setting and caching the created_at value'
|
||||
end
|
||||
|
||||
context 'with an invalid input' do
|
||||
let(:input) { 'not a timestamp' }
|
||||
let(:expected_value) { nil }
|
||||
|
||||
it_behaves_like 'setting and caching the created_at value'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe Gitlab::Ci::Parsers::Sbom::Cyclonedx do
|
|||
}
|
||||
end
|
||||
|
||||
subject(:parse!) { described_class.new(raw_report_data, report).parse! }
|
||||
subject(:parse!) { described_class.new.parse!(raw_report_data, report) }
|
||||
|
||||
before do
|
||||
allow_next_instance_of(Gitlab::Ci::Parsers::Sbom::Validators::CyclonedxSchemaValidator) do |validator|
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Reports::Sbom::Reports do
|
||||
subject(:reports_list) { described_class.new }
|
||||
|
||||
describe '#add_report' do
|
||||
let(:rep1) { Gitlab::Ci::Reports::Sbom::Report.new }
|
||||
let(:rep2) { Gitlab::Ci::Reports::Sbom::Report.new }
|
||||
|
||||
it 'appends the report to the report list' do
|
||||
reports_list.add_report(rep1)
|
||||
reports_list.add_report(rep2)
|
||||
|
||||
expect(reports_list.reports.length).to eq(2)
|
||||
expect(reports_list.reports.first).to eq(rep1)
|
||||
expect(reports_list.reports.last).to eq(rep2)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Utils::LinkHeaderParser do
|
||||
let(:parser) { described_class.new(header) }
|
||||
|
||||
describe '#parse' do
|
||||
subject { parser.parse }
|
||||
|
||||
context 'with a valid header' do
|
||||
let(:header) { generate_header(next: 'http://sandbox.org/next') }
|
||||
let(:expected) { { next: { uri: URI('http://sandbox.org/next') } } }
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
|
||||
context 'with multiple links' do
|
||||
let(:header) { generate_header(next: 'http://sandbox.org/next', previous: 'http://sandbox.org/previous') }
|
||||
let(:expected) do
|
||||
{
|
||||
next: { uri: URI('http://sandbox.org/next') },
|
||||
previous: { uri: URI('http://sandbox.org/previous') }
|
||||
}
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'with an incomplete uri' do
|
||||
let(:header) { '<http://sandbox.org/next; rel="next"' }
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
|
||||
context 'with no rel' do
|
||||
let(:header) { '<http://sandbox.org/next>; direction="next"' }
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
|
||||
context 'with multiple rel elements' do
|
||||
# check https://datatracker.ietf.org/doc/html/rfc5988#section-5.3:
|
||||
# occurrences after the first MUST be ignored by parsers
|
||||
let(:header) { '<http://sandbox.org/next>; rel="next"; rel="dummy"' }
|
||||
|
||||
it { is_expected.to eq(expected) }
|
||||
end
|
||||
|
||||
context 'when the url is too long' do
|
||||
let(:header) { "<http://sandbox.org/#{'a' * 500}>; rel=\"next\"" }
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil header' do
|
||||
let(:header) { nil }
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
|
||||
context 'with empty header' do
|
||||
let(:header) { '' }
|
||||
|
||||
it { is_expected.to eq({}) }
|
||||
end
|
||||
|
||||
def generate_header(links)
|
||||
stringified_links = links.map do |rel, url|
|
||||
"<#{url}>; rel=\"#{rel}\""
|
||||
end
|
||||
stringified_links.join(', ')
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5609,4 +5609,58 @@ RSpec.describe Ci::Build do
|
|||
let!(:model) { create(:ci_build, user: create(:user)) }
|
||||
let!(:parent) { model.user }
|
||||
end
|
||||
|
||||
describe '#clone' do
|
||||
let_it_be(:user) { FactoryBot.build(:user) }
|
||||
|
||||
context 'when given new job variables' do
|
||||
context 'when the cloned build has an action' do
|
||||
it 'applies the new job variables' do
|
||||
build = create(:ci_build, :actionable)
|
||||
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
|
||||
create(:ci_job_variable, job: build, key: 'OLD_KEY', value: 'i will not live for long')
|
||||
|
||||
new_build = build.clone(current_user: user, new_job_variables_attributes: [
|
||||
{ key: 'TEST_KEY', value: 'new value' },
|
||||
{ key: 'NEW_KEY', value: 'exciting new value' }
|
||||
])
|
||||
new_build.save!
|
||||
|
||||
expect(new_build.job_variables.count).to be(2)
|
||||
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY', 'NEW_KEY')
|
||||
expect(new_build.job_variables.map(&:value)).to contain_exactly('new value', 'exciting new value')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the cloned build does not have an action' do
|
||||
it 'applies the old job variables' do
|
||||
build = create(:ci_build)
|
||||
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
|
||||
|
||||
new_build = build.clone(current_user: user, new_job_variables_attributes: [
|
||||
{ key: 'TEST_KEY', value: 'new value' }
|
||||
])
|
||||
new_build.save!
|
||||
|
||||
expect(new_build.job_variables.count).to be(1)
|
||||
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY')
|
||||
expect(new_build.job_variables.map(&:value)).to contain_exactly('old value')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not given new job variables' do
|
||||
it 'applies the old job variables' do
|
||||
build = create(:ci_build)
|
||||
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
|
||||
|
||||
new_build = build.clone(current_user: user)
|
||||
new_build.save!
|
||||
|
||||
expect(new_build.job_variables.count).to be(1)
|
||||
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY')
|
||||
expect(new_build.job_variables.map(&:value)).to contain_exactly('old value')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -525,6 +525,162 @@ RSpec.describe ContainerRepository, :aggregate_failures do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#each_tags_page' do
|
||||
let(:page_size) { 100 }
|
||||
|
||||
shared_examples 'iterating through a page' do |expected_tags: true|
|
||||
it 'iterates through one page' do
|
||||
expect(repository.gitlab_api_client).to receive(:tags)
|
||||
.with(repository.path, page_size: page_size, last: nil)
|
||||
.and_return(client_response)
|
||||
expect { |b| repository.each_tags_page(page_size: page_size, &b) }
|
||||
.to yield_with_args(expected_tags ? expected_tags_from(client_response_tags) : [])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an empty page' do
|
||||
let(:client_response) { { pagination: {}, response_body: [] } }
|
||||
|
||||
it_behaves_like 'iterating through a page', expected_tags: false
|
||||
end
|
||||
|
||||
context 'with one page' do
|
||||
let(:client_response) { { pagination: {}, response_body: client_response_tags } }
|
||||
let(:client_response_tags) do
|
||||
[
|
||||
{
|
||||
'name' => '0.1.0',
|
||||
'created_at' => '2022-06-07T12:10:12.412+00:00'
|
||||
},
|
||||
{
|
||||
'name' => 'latest',
|
||||
'created_at' => '2022-06-07T12:11:13.633+00:00'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
context 'with a nil created_at' do
|
||||
let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => nil] }
|
||||
|
||||
it_behaves_like 'iterating through a page'
|
||||
end
|
||||
|
||||
context 'with an invalid created_at' do
|
||||
let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => 'not_a_timestamp'] }
|
||||
|
||||
it_behaves_like 'iterating through a page'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with two pages' do
|
||||
let(:client_response1) { { pagination: { next: { uri: URI('http://localhost/next?last=latest') } }, response_body: client_response_tags1 } }
|
||||
let(:client_response_tags1) do
|
||||
[
|
||||
{
|
||||
'name' => '0.1.0',
|
||||
'created_at' => '2022-06-07T12:10:12.412+00:00'
|
||||
},
|
||||
{
|
||||
'name' => 'latest',
|
||||
'created_at' => '2022-06-07T12:11:13.633+00:00'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
let(:client_response2) { { pagination: {}, response_body: client_response_tags2 } }
|
||||
let(:client_response_tags2) do
|
||||
[
|
||||
{
|
||||
'name' => '1.2.3',
|
||||
'created_at' => '2022-06-10T12:10:15.412+00:00'
|
||||
},
|
||||
{
|
||||
'name' => '2.3.4',
|
||||
'created_at' => '2022-06-11T12:11:17.633+00:00'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'iterates through two pages' do
|
||||
expect(repository.gitlab_api_client).to receive(:tags)
|
||||
.with(repository.path, page_size: page_size, last: nil)
|
||||
.and_return(client_response1)
|
||||
expect(repository.gitlab_api_client).to receive(:tags)
|
||||
.with(repository.path, page_size: page_size, last: 'latest')
|
||||
.and_return(client_response2)
|
||||
expect { |b| repository.each_tags_page(page_size: page_size, &b) }
|
||||
.to yield_successive_args(expected_tags_from(client_response_tags1), expected_tags_from(client_response_tags2))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when max pages is reached' do
|
||||
before do
|
||||
stub_const('ContainerRepository::MAX_TAGS_PAGES', 0)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { repository.each_tags_page(page_size: page_size) {} }
|
||||
.to raise_error(StandardError, 'too many pages requested')
|
||||
end
|
||||
end
|
||||
|
||||
context 'without a block set' do
|
||||
it 'raises an Argument error' do
|
||||
expect { repository.each_tags_page(page_size: page_size) }.to raise_error(ArgumentError, 'block not given')
|
||||
end
|
||||
end
|
||||
|
||||
context 'without a page size set' do
|
||||
let(:client_response) { { pagination: {}, response_body: [] } }
|
||||
|
||||
it 'uses a default size' do
|
||||
expect(repository.gitlab_api_client).to receive(:tags)
|
||||
.with(repository.path, page_size: 100, last: nil)
|
||||
.and_return(client_response)
|
||||
expect { |b| repository.each_tags_page(&b) }.to yield_with_args([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an empty client response' do
|
||||
let(:client_response) { {} }
|
||||
|
||||
it 'breaks the loop' do
|
||||
expect(repository.gitlab_api_client).to receive(:tags)
|
||||
.with(repository.path, page_size: page_size, last: nil)
|
||||
.and_return(client_response)
|
||||
expect { |b| repository.each_tags_page(page_size: page_size, &b) }.not_to yield_control
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a nil page' do
|
||||
let(:client_response) { { pagination: {}, response_body: nil } }
|
||||
|
||||
it_behaves_like 'iterating through a page', expected_tags: false
|
||||
end
|
||||
|
||||
context 'calling on a non migrated repository' do
|
||||
before do
|
||||
repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
|
||||
end
|
||||
|
||||
it 'raises an Argument error' do
|
||||
expect { repository.each_tags_page }.to raise_error(ArgumentError, 'not a migrated repository')
|
||||
end
|
||||
end
|
||||
|
||||
def expected_tags_from(client_tags)
|
||||
client_tags.map do |tag|
|
||||
created_at =
|
||||
begin
|
||||
DateTime.iso8601(tag['created_at'])
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
an_object_having_attributes(name: tag['name'], created_at: created_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tags_count' do
|
||||
it 'returns the count of tags' do
|
||||
expect(repository.tags_count).to eq(1)
|
||||
|
@ -1348,6 +1504,28 @@ RSpec.describe ContainerRepository, :aggregate_failures do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#migrated?' do
|
||||
subject { repository.migrated? }
|
||||
|
||||
it { is_expected.to eq(true) }
|
||||
|
||||
context 'with a created_at older than phase 1 ends' do
|
||||
before do
|
||||
repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(false) }
|
||||
|
||||
context 'with migration state set to import_done' do
|
||||
before do
|
||||
repository.update!(migration_state: 'import_done')
|
||||
end
|
||||
|
||||
it { is_expected.to eq(true) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with repositories' do
|
||||
let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) }
|
||||
let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) }
|
||||
|
|
|
@ -35,8 +35,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do
|
|||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it 'returns the manual variables for the jobs' do
|
||||
job = create(:ci_build, :manual, pipeline: pipeline)
|
||||
it 'returns the manual variables for actionable jobs' do
|
||||
job = create(:ci_build, :actionable, pipeline: pipeline)
|
||||
create(:ci_job_variable, key: 'MANUAL_TEST_VAR', job: job)
|
||||
|
||||
post_graphql(query, current_user: user)
|
||||
|
@ -46,8 +46,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do
|
|||
expect(variables_data.map { |var| var['key'] }).to match_array(['MANUAL_TEST_VAR'])
|
||||
end
|
||||
|
||||
it 'does not fetch job variables for jobs that are not manual' do
|
||||
job = create(:ci_build, pipeline: pipeline)
|
||||
it 'does not fetch job variables for jobs that are not actionable' do
|
||||
job = create(:ci_build, pipeline: pipeline, status: :manual)
|
||||
create(:ci_job_variable, key: 'THIS_VAR_WOULD_SHOULD_NEVER_EXIST', job: job)
|
||||
|
||||
post_graphql(query, current_user: user)
|
||||
|
|
|
@ -47,6 +47,38 @@ RSpec.describe 'JobRetry' do
|
|||
expect(new_job).not_to be_retried
|
||||
end
|
||||
|
||||
context 'when given CI variables' do
|
||||
let(:job) { create(:ci_build, :success, :actionable, pipeline: pipeline, name: 'build') }
|
||||
|
||||
let(:mutation) do
|
||||
variables = {
|
||||
id: job.to_global_id.to_s,
|
||||
variables: { key: 'MANUAL_VAR', value: 'test manual var' }
|
||||
}
|
||||
|
||||
graphql_mutation(:job_retry, variables,
|
||||
<<-QL
|
||||
errors
|
||||
job {
|
||||
id
|
||||
}
|
||||
QL
|
||||
)
|
||||
end
|
||||
|
||||
it 'applies them to a retried manual job' do
|
||||
post_graphql_mutation(mutation, current_user: user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
|
||||
new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
|
||||
new_job = ::Ci::Build.find(new_job_id)
|
||||
expect(new_job.job_variables.count).to be(1)
|
||||
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
|
||||
expect(new_job.job_variables.first.value).to eq('test manual var')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the job is not retryable' do
|
||||
let(:job) { create(:ci_build, :retried, pipeline: pipeline) }
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ RSpec.describe Ci::RetryJobService do
|
|||
name: 'test')
|
||||
end
|
||||
|
||||
let(:job_variables_attributes) { [{ key: 'MANUAL_VAR', value: 'manual test var' }] }
|
||||
let(:user) { developer }
|
||||
|
||||
let(:service) { described_class.new(project, user) }
|
||||
|
@ -206,6 +207,14 @@ RSpec.describe Ci::RetryJobService do
|
|||
include_context 'retryable bridge'
|
||||
|
||||
it_behaves_like 'clones the job'
|
||||
|
||||
context 'when given variables' do
|
||||
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
|
||||
|
||||
it 'does not give variables to the new bridge' do
|
||||
expect { new_job }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the job to be cloned is a build' do
|
||||
|
@ -250,6 +259,28 @@ RSpec.describe Ci::RetryJobService do
|
|||
expect { new_job }.not_to change { Environment.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given variables' do
|
||||
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
|
||||
|
||||
context 'when the build is actionable' do
|
||||
let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) }
|
||||
|
||||
it 'gives variables to the new build' do
|
||||
expect(new_job.job_variables.count).to be(1)
|
||||
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
|
||||
expect(new_job.job_variables.first.value).to eq('manual test var')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the build is not actionable' do
|
||||
let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) }
|
||||
|
||||
it 'does not give variables to the new build' do
|
||||
expect(new_job.job_variables.count).to be_zero
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -260,6 +291,14 @@ RSpec.describe Ci::RetryJobService do
|
|||
include_context 'retryable bridge'
|
||||
|
||||
it_behaves_like 'retries the job'
|
||||
|
||||
context 'when given variables' do
|
||||
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
|
||||
|
||||
it 'does not give variables to the new bridge' do
|
||||
expect { new_job }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the job to be retried is a build' do
|
||||
|
@ -288,6 +327,28 @@ RSpec.describe Ci::RetryJobService do
|
|||
expect { service.execute(job) }.not_to exceed_all_query_limit(control_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given variables' do
|
||||
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
|
||||
|
||||
context 'when the build is actionable' do
|
||||
let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) }
|
||||
|
||||
it 'gives variables to the new build' do
|
||||
expect(new_job.job_variables.count).to be(1)
|
||||
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
|
||||
expect(new_job.job_variables.first.value).to eq('manual test var')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the build is not actionable' do
|
||||
let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) }
|
||||
|
||||
it 'does not give variables to the new build' do
|
||||
expect(new_job.job_variables.count).to be_zero
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue