Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-06-08 09:09:56 +00:00
parent fe30598cbd
commit e612fbe905
64 changed files with 12568 additions and 132 deletions

View File

@ -112,3 +112,7 @@ overrides:
import/no-nodejs-modules: off
filenames/match-regex: off
no-console: off
- files:
- '*.stories.js'
rules:
filenames/match-regex: off

View File

@ -344,3 +344,18 @@ startup-css-check as-if-foss:
needs:
- job: "compile-test-assets as-if-foss"
- job: "rspec frontend_fixture as-if-foss"
compile-storybook:
extends:
- .compile-assets-base
script:
- source scripts/utils.sh
- cd storybook/
- run_timed_command "retry yarn install --frozen-lockfile"
- yarn build
artifacts:
name: storybook
expire_in: 31d
when: always
paths:
- storybook/public

View File

@ -8,12 +8,14 @@ pages:
- coverage-frontend
- karma
- compile-production-assets
- compile-storybook
script:
- mv public/ .public/
- mkdir public/
- mv coverage/ public/coverage-ruby/ || true
- mv coverage-frontend/ public/coverage-frontend/ || true
- mv coverage-javascript/ public/coverage-javascript/ || true
- mv storybook/public public/storybook || true
- cp .public/assets/application-*.css public/application.css || true
- cp .public/assets/application-*.css.gz public/application.css.gz || true
artifacts:

View File

@ -1,9 +1,9 @@
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
@ -23,11 +23,9 @@ export default {
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
SidebarSubscriptionsWidget,
BoardSidebarMilestoneSelect,
SidebarDropdownWidget,
BoardSidebarWeightInput: () =>
import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
SidebarDropdownWidget: () =>
import('ee_component/sidebar/components/sidebar_dropdown_widget.vue'),
},
inject: {
multipleAssigneesFeatureAvailable: {
@ -97,7 +95,14 @@ export default {
data-testid="sidebar-epic"
/>
<div>
<board-sidebar-milestone-select />
<sidebar-dropdown-widget
:iid="activeBoardItem.iid"
issuable-attribute="milestone"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-milestones"
/>
<sidebar-dropdown-widget
v-if="iterationFeatureAvailable"
:iid="activeBoardItem.iid"

View File

@ -135,6 +135,13 @@ export default {
resolveWithIssuePath() {
return !this.discussionResolved ? this.discussion.resolve_with_issue_path : '';
},
canShowReplyActions() {
if (this.shouldRenderDiffs && !this.discussion.diff_file.diff_refs) {
return false;
}
return true;
},
},
created() {
eventHub.$on('startReplying', this.onStartReplying);
@ -263,7 +270,7 @@ export default {
:draft="draftForDiscussion(discussion.reply_id)"
/>
<div
v-else-if="showReplies"
v-else-if="canShowReplyActions && showReplies"
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder gl-border-t-0! clearfix"
>

View File

@ -26,10 +26,10 @@ const PRIVATE_VISIBILITY = 'private';
const INTERNAL_VISIBILITY = 'internal';
const PUBLIC_VISIBILITY = 'public';
const ALLOWED_VISIBILITY = {
private: [PRIVATE_VISIBILITY],
internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY],
public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY],
const VISIBILITY_LEVEL = {
[PRIVATE_VISIBILITY]: 0,
[INTERNAL_VISIBILITY]: 10,
[PUBLIC_VISIBILITY]: 20,
};
const initFormField = ({ value, required = true, skipValidation = false }) => ({
@ -124,14 +124,23 @@ export default {
projectUrl() {
return `${gon.gitlab_url}/`;
},
projectAllowedVisibility() {
return ALLOWED_VISIBILITY[this.projectVisibility];
projectVisibilityLevel() {
return VISIBILITY_LEVEL[this.projectVisibility];
},
namespaceAllowedVisibility() {
return (
ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] ||
ALLOWED_VISIBILITY[PUBLIC_VISIBILITY]
);
namespaceVisibilityLevel() {
const visibility = this.form.fields.namespace.value?.visibility || PUBLIC_VISIBILITY;
return VISIBILITY_LEVEL[visibility];
},
visibilityLevelCap() {
return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel);
},
allowedVisibilityLevels() {
return Object.entries(VISIBILITY_LEVEL).reduce((levels, [levelName, levelValue]) => {
if (levelValue <= this.visibilityLevelCap) {
levels.push(levelName);
}
return levels;
}, []);
},
visibilityLevels() {
return [
@ -179,11 +188,8 @@ export default {
const { data } = await axios.get(this.endpoint);
this.namespaces = data.namespaces;
},
isVisibilityLevelDisabled(visibilityLevel) {
return !(
this.projectAllowedVisibility.includes(visibilityLevel) &&
this.namespaceAllowedVisibility.includes(visibilityLevel)
);
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
async onSubmit() {
this.form.showValidation = true;

View File

@ -0,0 +1,344 @@
<script>
import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { __, s__, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import {
IssuableAttributeState,
IssuableAttributeType,
issuableAttributesQueries,
noAttributeId,
} from '../constants';
export default {
noAttributeId,
IssuableAttributeState,
issuableAttributesQueries,
i18n: {
[IssuableAttributeType.Milestone]: __('Milestone'),
none: __('None'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
SidebarEditableItem,
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlIcon,
GlLoadingIcon,
},
inject: {
isClassicSidebar: {
default: false,
},
},
props: {
issuableAttribute: {
type: String,
required: true,
validator(value) {
return [IssuableAttributeType.Milestone].includes(value);
},
},
workspacePath: {
required: true,
type: String,
},
iid: {
required: true,
type: String,
},
attrWorkspacePath: {
required: true,
type: String,
},
issuableType: {
type: String,
required: true,
validator(value) {
return value === IssuableType.Issue;
},
},
},
apollo: {
currentAttribute: {
query() {
const { current } = this.issuableAttributeQuery;
const { query } = current[this.issuableType];
return query;
},
variables() {
return {
fullPath: this.workspacePath,
iid: this.iid,
};
},
update(data) {
return data?.workspace?.issuable.attribute;
},
error(error) {
createFlash({
message: this.i18n.currentFetchError,
captureError: true,
error,
});
},
},
attributesList: {
query() {
const { list } = this.issuableAttributeQuery;
const { query } = list[this.issuableType];
return query;
},
skip() {
return !this.editing;
},
debounce: 250,
variables() {
return {
fullPath: this.attrWorkspacePath,
title: this.searchTerm,
state: this.$options.IssuableAttributeState[this.issuableAttribute],
};
},
update(data) {
if (data?.workspace) {
return data?.workspace?.attributes.nodes;
}
return [];
},
error(error) {
createFlash({ message: this.i18n.listFetchError, captureError: true, error });
},
},
},
data() {
return {
searchTerm: '',
editing: false,
updating: false,
selectedTitle: null,
currentAttribute: null,
attributesList: [],
tracking: {
label: 'right_sidebar',
event: 'click_edit_button',
property: this.issuableAttribute,
},
};
},
computed: {
issuableAttributeQuery() {
return this.$options.issuableAttributesQueries[this.issuableAttribute];
},
attributeTitle() {
return this.currentAttribute?.title || this.i18n.noAttribute;
},
attributeUrl() {
return this.currentAttribute?.webUrl;
},
dropdownText() {
return this.currentAttribute
? this.currentAttribute?.title
: this.$options.i18n[this.issuableAttribute];
},
loading() {
return this.$apollo.queries.currentAttribute.loading;
},
emptyPropsList() {
return this.attributesList.length === 0;
},
attributeTypeTitle() {
return this.$options.i18n[this.issuableAttribute];
},
i18n() {
return {
noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
issuableAttribute: this.issuableAttribute,
}),
assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
issuableAttribute: this.issuableAttribute,
}),
noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
issuableAttribute: this.issuableAttribute,
}),
updateError: sprintf(
s__(
'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
listFetchError: sprintf(
s__(
'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
currentFetchError: sprintf(
s__(
'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
};
},
},
methods: {
updateAttribute(attributeId) {
if (this.currentAttribute === null && attributeId === null) return;
if (attributeId === this.currentAttribute?.id) return;
this.updating = true;
const selectedAttribute =
Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none;
const { current } = this.issuableAttributeQuery;
const { mutation } = current[this.issuableType];
this.$apollo
.mutate({
mutation,
variables: {
fullPath: this.workspacePath,
attributeId:
this.issuableAttribute === IssuableAttributeType.Milestone
? getIdFromGraphQLId(attributeId)
: attributeId,
iid: this.iid,
},
})
.then(({ data }) => {
if (data.issuableSetAttribute?.errors?.length) {
createFlash({
message: data.issuableSetAttribute.errors[0],
captureError: true,
error: data.issuableSetAttribute.errors[0],
});
} else {
this.$emit('attribute-updated', data);
}
})
.catch((error) => {
createFlash({ message: this.i18n.updateError, captureError: true, error });
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
});
},
isAttributeChecked(attributeId = undefined) {
return (
attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
);
},
showDropdown() {
this.$refs.newDropdown.show();
},
handleOpen() {
this.editing = true;
this.showDropdown();
},
handleClose() {
this.editing = false;
},
setFocus() {
this.$refs.search.focusInput();
},
},
};
</script>
<template>
<sidebar-editable-item
ref="editable"
:title="attributeTypeTitle"
:data-testid="`${issuableAttribute}-edit`"
:tracking="tracking"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
<div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
<span class="collapse-truncated-title">{{ attributeTitle }}</span>
</div>
<div
:data-testid="`select-${issuableAttribute}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
<span v-else-if="!currentAttribute" class="gl-text-gray-500">
{{ $options.i18n.none }}
</span>
<gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl">
{{ attributeTitle }}
</gl-link>
</div>
</template>
<template #default>
<gl-dropdown
ref="newDropdown"
lazy
:header-text="i18n.assignAttribute"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@shown="setFocus"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
:data-testid="`no-${issuableAttribute}-item`"
:is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
>
{{ i18n.noAttribute }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.attributesList.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="emptyPropsList">
{{ i18n.noAttributesFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="attrItem in attributesList"
:key="attrItem.id"
:is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${issuableAttribute}-items`"
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
</sidebar-editable-item>
</template>

View File

@ -29,6 +29,9 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
@ -143,3 +146,33 @@ export const timelogQueries = {
query: getMrTimelogsQuery,
},
};
export const noAttributeId = null;
export const issuableMilestoneQueries = {
[IssuableType.Issue]: {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
};
export const milestonesQueries = {
[IssuableType.Issue]: {
query: projectMilestonesQuery,
},
};
export const IssuableAttributeType = {
Milestone: 'milestone',
};
export const IssuableAttributeState = {
[IssuableAttributeType.Milestone]: 'active',
};
export const issuableAttributesQueries = {
[IssuableAttributeType.Milestone]: {
current: issuableMilestoneQueries,
list: milestonesQueries,
},
};

View File

@ -0,0 +1,5 @@
fragment MilestoneFragment on Milestone {
id
title
webUrl: webPath
}

View File

@ -0,0 +1,17 @@
mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attributeId: ID) {
issuableSetAttribute: updateIssue(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
__typename
errors
issuable: issue {
__typename
id
attribute: milestone {
title
id
state
}
}
}
}

View File

@ -0,0 +1,14 @@
#import "./milestone.fragment.graphql"
query projectIssueMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
attribute: milestone {
...MilestoneFragment
}
}
}
}

View File

@ -0,0 +1,13 @@
#import "./milestone.fragment.graphql"
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
__typename
attributes: milestones(searchTitle: $title, state: $state) {
nodes {
...MilestoneFragment
state
}
}
}
}

View File

@ -0,0 +1,23 @@
/* eslint-disable @gitlab/require-i18n-strings */
import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
title: 'vue_shared/components/todo_button',
};
const Template = (args, { argTypes }) => ({
components: { TodoButton },
props: Object.keys(argTypes),
template: '<todo-button v-bind="$props" v-on="$props" />',
});
export const Default = Template.bind({});
Default.argTypes = {
isTodo: {
description: 'True if to-do is unresolved (i.e. not "done")',
control: { type: 'boolean' },
},
click: { action: 'clicked' },
};

View File

@ -63,6 +63,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
end
def edit
exclude_legacy_flags_check
end
def update
@ -158,4 +159,12 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
render json: { message: messages },
status: status
end
def exclude_legacy_flags_check
if Feature.enabled?(:remove_legacy_flags, project, default_enabled: :yaml) &&
Feature.disabled?(:remove_legacy_flags_override, project, default_enabled: :yaml) &&
feature_flag.legacy_flag?
not_found
end
end
end

View File

@ -1076,6 +1076,10 @@ module Ci
::Ci::PendingBuild.where(build_id: self.id)
end
def create_queuing_entry!
::Ci::PendingBuild.upsert_from_build!(self)
end
protected
def run_status_commit_hooks!

View File

@ -9,7 +9,7 @@ class MergeRequestDiffEntity < Grape::Entity
@merge_request_diffs = options[:merge_request_diffs]
diff = options[:merge_request_diff]
next unless diff.present?
next unless @merge_request_diffs.include?(diff)
next unless @merge_request_diffs.size > 1
version_index(merge_request_diff)

View File

@ -287,15 +287,11 @@ module Ci
.order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC')
end
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def builds_for_project_runner
new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC')
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def builds_for_group_runner
# Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
@ -307,17 +303,23 @@ module Ci
.without_deleted
new_builds.where(project: projects).order('id ASC')
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def running_builds_for_shared_runners
Ci::Build.running.where(runner: Ci::Runner.instance_type)
.group(:project_id).select(:project_id, 'count(*) AS running_builds')
end
def all_builds
if Feature.enabled?(:ci_pending_builds_queue_join, runner, default_enabled: :yaml)
Ci::Build.joins(:queuing_entry)
else
Ci::Build.all
end
end
# rubocop: enable CodeReuse/ActiveRecord
def new_builds
builds = Ci::Build.pending.unstarted
builds = all_builds.pending.unstarted
builds = builds.ref_protected if runner.ref_protected?
builds
end

View File

@ -19,7 +19,7 @@ module Ci
raise InvalidQueueTransition unless transition.to == 'pending'
transition.within_transaction do
result = ::Ci::PendingBuild.upsert_from_build!(build)
result = build.create_queuing_entry!
unless result.empty?
metrics.increment_queue_operation(:build_queue_push)

View File

@ -9,6 +9,8 @@ module Deployments
delegate :variables, to: :deployable
delegate :options, to: :deployable, allow_nil: true
EnvironmentUpdateFailure = Class.new(StandardError)
def initialize(deployment)
@deployment = deployment
@deployable = deployment.deployable
@ -31,8 +33,18 @@ module Deployments
renew_deployment_tier
environment.fire_state_event(action)
if environment.save && !environment.stopped?
deployment.update_merge_request_metrics!
if environment.save
deployment.update_merge_request_metrics! unless environment.stopped?
else
# If there is a validation error on environment update, such as
# the external URL is malformed, the error message is recorded for debugging purpose.
# We should surface the error message to users for letting them to take an action.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/21182.
Gitlab::ErrorTracking.track_exception(
EnvironmentUpdateFailure.new,
project_id: deployment.project_id,
environment_id: environment.id,
reason: environment.errors.full_messages.to_sentence)
end
end
end

View File

@ -6,6 +6,8 @@
.form-group
= f.label :email
= f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.')
.form-text.text-muted
= _('Requires your primary GitLab email address.')
.clearfix
= f.submit _("Reset password"), class: "gl-button btn-confirm btn"

View File

@ -8,10 +8,11 @@
.flash-container
%table.table.table-bordered
%colgroup
%col{ width: "30%" }
%col{ width: "20%" }
%col{ width: "20%" }
%col{ width: "20%" }
%col{ width: "20%" }
%col{ width: "10%" }
%col{ width: "10%" }
- if can_admin_project
%col
%thead
@ -23,8 +24,8 @@
%th
= s_("ProtectedBranch|Allowed to push")
%th
= s_("ProtectedBranch|Allow force push")
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow force push for all users with push access.'), 'aria-hidden': 'true' }
= s_("ProtectedBranch|Allowed to force push")
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' }
= sprite_icon('question', size: 16, css_class: 'gl-text-gray-500')
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head'

View File

@ -22,11 +22,13 @@
.col-md-10
= yield :push_access_levels
.form-group.row
= f.label :allow_force_push, s_("ProtectedBranch|Allow force push:"), class: 'col-md-2 gl-text-left text-md-right'
= f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right'
.col-md-10
= render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle"
.form-text.gl-text-gray-600.gl-mt-0
= s_("ProtectedBranch|Allow force push for all users with push access.")
- force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push')
- force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
= (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f
.card-footer
= f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' }

View File

@ -14,7 +14,7 @@
%ul
%li Allow only users with Maintainer #{link_to "permissions", help_page_path("user/permissions")} to create new protected branches.
%li Allow only users with Maintainer permissions to push code.
%li Prevent <strong>anyone</strong> from force-pushing to the branch.
%li Prevent <strong>anyone</strong> from #{link_to "force-pushing", help_page_path('topics/git/git_rebase', anchor: 'force-push')} to the branch.
%li Prevent <strong>anyone</strong> from deleting the branch.
- if can? current_user, :admin_project, @project

View File

@ -34,4 +34,4 @@
= _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence }
%td
= render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allow force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name }
= render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allowed to force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name }

View File

@ -0,0 +1,8 @@
---
name: ci_pending_builds_queue_join
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62195
rollout_issue_url:
milestone: '13.12'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: ci_pending_builds_queue_maintain
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61581
rollout_issue_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331496
milestone: '13.12'
type: development
group: group::continuous integration

View File

@ -385,7 +385,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
# The wiki and repository routing contains wildcard characters so
# its preferable to keep it below all other project routes
draw :repository_scoped
draw :repository
draw :wiki
namespace :import do

View File

@ -2,7 +2,7 @@
# Repository routes without /-/ scope.
# Issue https://gitlab.com/gitlab-org/gitlab/-/issues/28848.
# Do not add new routes here. Add new routes to repository_scoped.rb instead
# Do not add new routes here. Add new routes to repository.rb instead
# (see https://docs.gitlab.com/ee/development/routing.html#project-routes).
resource :repository, only: [:create]

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class CleanUpPendingBuildsTable < ActiveRecord::Migration[6.0]
BATCH_SIZE = 1000
disable_ddl_transaction!
def up
return unless Gitlab.dev_or_test_env? || Gitlab.com?
each_batch('ci_pending_builds', of: BATCH_SIZE) do |min, max|
execute <<~SQL
DELETE FROM ci_pending_builds
USING ci_builds
WHERE ci_builds.id = ci_pending_builds.build_id
AND ci_builds.status != 'pending'
AND ci_builds.type = 'Ci::Build'
AND ci_pending_builds.id BETWEEN #{min} AND #{max}
SQL
end
end
def down
# noop
end
private
def each_batch(table_name, scope: ->(table) { table.all }, of: 1000)
table = Class.new(ActiveRecord::Base) do
include EachBatch
self.table_name = table_name
self.inheritance_column = :_type_disabled
end
scope.call(table).each_batch(of: of) do |batch|
yield batch.pluck('MIN(id), MAX(id)').first
end
end
end

View File

@ -0,0 +1 @@
5dc1119c5efe28225bb7ac8a9ed2c4c5cfaeaff202194ed4419cfd54eaf7483d

View File

@ -1176,6 +1176,28 @@ Prints the metrics saved in `conversational_development_index_metrics`.
rake gitlab:usage_data:generate_and_send
```
## Kubernetes integration
Find cluster:
```ruby
cluster = Clusters::Cluster.find(1)
cluster = Clusters::Cluster.find_by(name: 'cluster_name')
```
Delete cluster without associated resources:
```ruby
# Find an admin user
user = User.find_by(username: 'admin_user')
# Find the cluster with the ID
cluster = Clusters::Cluster.find(1)
# Delete the cluster
Clusters::DestroyService.new(user).execute(cluster)
```
## Elasticsearch
### Configuration attributes

View File

@ -204,7 +204,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla
| `push_access_level` | string | no | Access levels allowed to push (defaults: `40`, Maintainer role) |
| `merge_access_level` | string | no | Access levels allowed to merge (defaults: `40`, Maintainer role) |
| `unprotect_access_level` | string | no | Access levels allowed to unprotect (defaults: `40`, Maintainer role) |
| `allow_force_push` | boolean | no | Allow force push for all users with push access. (defaults: false) |
| `allow_force_push` | boolean | no | Allow all users with push access to force push. (default: `false`) |
| `allowed_to_push` | array | no | **(PREMIUM)** Array of access levels allowed to push, with each described by a hash |
| `allowed_to_merge` | array | no | **(PREMIUM)** Array of access levels allowed to merge, with each described by a hash |
| `allowed_to_unprotect` | array | no | **(PREMIUM)** Array of access levels allowed to unprotect, with each described by a hash |

View File

@ -0,0 +1,50 @@
---
stage: none
group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Storybook
The Storybook for the `gitlab-org/gitlab` project is available on our [GitLab Pages site](https://gitlab-org.gitlab.io/gitlab/storybook).
## Storybook in local development
Storybook dependencies and configuration are located under the `storybook/` directory.
To build and launch Storybook locally, in the root directory of the `gitlab` project:
1. Install Storybook dependencies:
```shell
yarn storybook:install
```
1. Build the Storybook site:
```shell
yarn storybook:start
```
## Adding components to Storybook
Stories can be added for any Vue component in the `gitlab` repository.
To add a story:
1. Create a new `.stories.js` file in the same directory as the Vue component.
The file name should have the same prefix as the Vue component.
```txt
vue_shared/
├─ components/
│ ├─ todo_button.vue
│ ├─ todo_button.stories.js
```
1. Write the story as per the [official Storybook instructions](https://storybook.js.org/docs/vue/writing-stories/introduction)
Notes:
- Specify the `title` field of the story as the component's file path from the `javascripts/` directory,
e.g. if the component is located at `app/assets/javascripts/vue_shared/components/todo_button.vue`, specify the `title` as
`vue_shared/components/To-do Button`. This will ensure the Storybook navigation maps closely to our internal directory structure.

View File

@ -17,7 +17,7 @@ as the hardware requirements that are needed to install and use GitLab.
- Ubuntu (16.04/18.04/20.04)
- Debian (9/10)
- CentOS (7/8)
- openSUSE Leap (15.1/15.2)
- openSUSE Leap (15.2)
- SUSE Linux Enterprise Server (12 SP2/12 SP5)
- Red Hat Enterprise Linux (please use the CentOS packages and instructions)
- Scientific Linux (please use the CentOS packages and instructions)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -184,17 +184,17 @@ command line or a Git client application.
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
You can allow force pushes to protected branches by either setting **Allow force push**
You can allow [force pushes](../../topics/git/git_rebase.md#force-push) to
protected branches by either setting **Allowed to force push**
when you protect a new branch, or by configuring an already-protected branch.
To protect a new branch and enable Force push:
1. Navigate to your project's **Settings > Repository**.
1. Expand **Protected branches**, and scroll to **Protect a branch**.
![Code Owners approval - new protected branch](img/code_owners_approval_new_protected_branch_v13_10.png)
1. Select a **Branch** or wildcard you'd like to protect.
1. Select the user levels **Allowed to merge** and **Allowed to push**.
1. To allow all users with push access to force push, toggle the **Allow force push** slider.
1. To allow all users with push access to force push, toggle the **Allowed to force push** slider.
1. To reject code pushes that change files listed in the `CODEOWNERS` file, toggle
**Require approval from code owners**.
1. Click **Protect**.
@ -203,8 +203,7 @@ To enable force pushes on branches already protected:
1. Navigate to your project's **Settings > Repository**.
1. Expand **Protected branches** and scroll to **Protected branch**.
![Code Owners approval - branch already protected](img/code_owners_approval_protected_branch_v13_10.png)
1. Toggle the **Allow force push** slider for the chosen branch.
1. Toggle the **Allowed to force push** slider for the chosen branch.
When enabled, members who are allowed to push to this branch can also force push.
@ -224,15 +223,11 @@ To protect a new branch and enable Code Owner's approval:
1. Scroll down to **Protect a branch**, select a **Branch** or wildcard you'd like to protect, select who's **Allowed to merge** and **Allowed to push**, and toggle the **Require approval from code owners** slider.
1. Click **Protect**.
![Code Owners approval - new protected branch](img/code_owners_approval_new_protected_branch_v13_10.png)
To enable Code Owner's approval to branches already protected:
1. Navigate to your project's **Settings > Repository** and expand **Protected branches**.
1. Scroll down to **Protected branch** and toggle the **Code owner approval** slider for the chosen branch.
![Code Owners approval - branch already protected](img/code_owners_approval_protected_branch_v13_10.png)
When enabled, all merge requests targeting these branches require approval
by a Code Owner per matched rule before they can be merged.
Additionally, direct pushes to the protected branch are denied if a rule is matched.

View File

@ -90,6 +90,7 @@ module API
end
get do
authorize_read_feature_flag!
exclude_legacy_flags_check!
present_entity(feature_flag)
end
@ -104,6 +105,7 @@ module API
end
post :enable do
not_found! unless Feature.enabled?(:feature_flag_api, user_project)
exclude_legacy_flags_check!
render_api_error!('Version 2 flags not supported', :unprocessable_entity) if new_version_flag_present?
result = ::FeatureFlags::EnableService
@ -127,6 +129,7 @@ module API
end
post :disable do
not_found! unless Feature.enabled?(:feature_flag_api, user_project)
exclude_legacy_flags_check!
render_api_error!('Version 2 flags not supported', :unprocessable_entity) if feature_flag.new_version_flag?
result = ::FeatureFlags::DisableService
@ -162,6 +165,7 @@ module API
end
put do
authorize_update_feature_flag!
exclude_legacy_flags_check!
render_api_error!('PUT operations are not supported for legacy feature flags', :unprocessable_entity) if feature_flag.legacy_flag?
attrs = declared_params(include_missing: false)
@ -232,6 +236,10 @@ module API
@feature_flag ||= user_project.operations_feature_flags.find_by_name!(params[:feature_flag_name])
end
def project
@project ||= feature_flag.project
end
def new_version_flag_present?
user_project.operations_feature_flags.new_version_flag.find_by_name(params[:name]).present?
end
@ -245,6 +253,14 @@ module API
hash[key] = yield(hash[key]) if hash.key?(key)
hash
end
def exclude_legacy_flags_check!
if Feature.enabled?(:remove_legacy_flags, project, default_enabled: :yaml) &&
Feature.disabled?(:remove_legacy_flags_override, project, default_enabled: :yaml) &&
feature_flag.legacy_flag?
not_found!
end
end
end
end
end

View File

@ -26651,13 +26651,16 @@ msgstr ""
msgid "ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported."
msgstr ""
msgid "ProtectedBranch|Allow force push"
msgid "ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}."
msgstr ""
msgid "ProtectedBranch|Allow force push for all users with push access."
msgid "ProtectedBranch|Allow all users with push access to force push."
msgstr ""
msgid "ProtectedBranch|Allow force push:"
msgid "ProtectedBranch|Allowed to force push"
msgstr ""
msgid "ProtectedBranch|Allowed to force push:"
msgstr ""
msgid "ProtectedBranch|Allowed to merge"
@ -26702,7 +26705,7 @@ msgstr ""
msgid "ProtectedBranch|There are currently no protected branches, protect a branch with the form above."
msgstr ""
msgid "ProtectedBranch|Toggle allow force push"
msgid "ProtectedBranch|Toggle allowed to force push"
msgstr ""
msgid "ProtectedBranch|Toggle code owner approval"
@ -27987,6 +27990,9 @@ msgstr[1] ""
msgid "Requires values to meet regular expression requirements."
msgstr ""
msgid "Requires your primary GitLab email address."
msgstr ""
msgid "Resend"
msgstr ""

View File

@ -40,6 +40,8 @@
"markdownlint:no-trailing-spaces": "markdownlint --config doc/.markdownlint/markdownlint-no-trailing-spaces.yml",
"markdownlint:no-trailing-spaces:fix": "yarn run markdownlint:no-trailing-spaces --fix",
"postinstall": "node ./scripts/frontend/postinstall.js",
"storybook:install": "yarn --cwd ./storybook install",
"storybook:start": "yarn --cwd ./storybook start",
"stylelint-create-utility-map": "node scripts/frontend/stylelint/stylelint-utility-map.js",
"webpack": "NODE_OPTIONS=\"--max-old-space-size=3584\" webpack --config config/webpack.config.js",
"webpack-vendor": "NODE_OPTIONS=\"--max-old-space-size=3584\" webpack --config config/webpack.vendor.config.js",

View File

@ -6,7 +6,7 @@ module QA
RSpec.describe 'Create' do
context 'Gitaly' do
# Issue to track removal of feature flag: https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/602
describe 'Distributed reads', :orchestrated, :gitaly_cluster, :skip_live_env, :requires_admin do
describe 'Distributed reads', :orchestrated, :gitaly_cluster, :skip_live_env, :requires_admin, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/322814', type: :investigating } do
let(:number_of_reads_per_loop) { 9 }
let(:praefect_manager) { Service::PraefectManager.new }
let(:project) do

View File

@ -4,7 +4,7 @@ require 'parallel'
module QA
RSpec.describe 'Create' do
context 'Gitaly Cluster replication queue', :orchestrated, :gitaly_cluster, :skip_live_env do
context 'Gitaly Cluster replication queue', :orchestrated, :gitaly_cluster, :skip_live_env, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/331989', type: :investigating } do
let(:praefect_manager) { Service::PraefectManager.new }
let(:project) do
Resource::Project.fabricate! do |project|

View File

@ -371,6 +371,58 @@ RSpec.describe Projects::FeatureFlagsController do
end
end
describe 'GET edit' do
subject { get(:edit, params: params) }
context 'with legacy flags' do
let!(:feature_flag) { create(:operations_feature_flag, project: project) }
let(:params) do
{
namespace_id: project.namespace,
project_id: project,
iid: feature_flag.iid
}
end
context 'removed' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
is_expected.to have_gitlab_http_status(:not_found)
end
end
context 'removed' do
before do
stub_feature_flags(remove_legacy_flags: false)
end
it 'returns ok' do
is_expected.to have_gitlab_http_status(:ok)
end
end
end
context 'with new version flags' do
let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
let(:params) do
{
namespace_id: project.namespace,
project_id: project,
iid: feature_flag.iid
}
end
it 'returns successfully' do
is_expected.to have_gitlab_http_status(:ok)
end
end
end
describe 'POST create.json' do
subject { post(:create, params: params, format: :json) }

View File

@ -79,6 +79,7 @@ FactoryBot.define do
trait :pending do
queued_at { 'Di 29. Okt 09:50:59 CET 2013' }
status { 'pending' }
end
@ -286,6 +287,15 @@ FactoryBot.define do
trait :queued do
queued_at { Time.now }
after(:create) do |build|
build.create_queuing_entry!
end
end
trait :picked do
running
runner factory: :ci_runner
end

View File

@ -38,7 +38,7 @@ RSpec.describe 'Project issue boards sidebar milestones', :js do
wait_for_requests
page.within('.value') do
page.within('[data-testid="select-milestone"]') do
expect(page).to have_content(milestone.title)
end
end
@ -56,7 +56,7 @@ RSpec.describe 'Project issue boards sidebar milestones', :js do
wait_for_requests
page.within('.value') do
page.within('[data-testid="select-milestone"]') do
expect(page).not_to have_content(milestone.title)
end
end

View File

@ -1,11 +1,11 @@
import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import { stubComponent } from 'helpers/stub_component';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
@ -68,6 +68,9 @@ describe('BoardContentSidebar', () => {
iterations: {
loading: false,
},
attributesList: {
loading: false,
},
},
},
},
@ -84,38 +87,41 @@ describe('BoardContentSidebar', () => {
});
it('confirms we render GlDrawer', () => {
expect(wrapper.find(GlDrawer).exists()).toBe(true);
expect(wrapper.findComponent(GlDrawer).exists()).toBe(true);
});
it('does not render GlDrawer when isSidebarOpen is false', () => {
createStore({ mockGetters: { isSidebarOpen: () => false } });
createComponent();
expect(wrapper.find(GlDrawer).exists()).toBe(false);
expect(wrapper.findComponent(GlDrawer).exists()).toBe(false);
});
it('applies an open attribute', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
});
it('renders BoardSidebarTitle', () => {
expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true);
expect(wrapper.findComponent(BoardSidebarTitle).exists()).toBe(true);
});
it('renders BoardSidebarDueDate', () => {
expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true);
expect(wrapper.findComponent(BoardSidebarDueDate).exists()).toBe(true);
});
it('renders BoardSidebarSubscription', () => {
expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true);
expect(wrapper.findComponent(SidebarSubscriptionsWidget).exists()).toBe(true);
});
it('renders BoardSidebarMilestoneSelect', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
it('renders SidebarDropdownWidget for milestones', () => {
expect(wrapper.findComponent(SidebarDropdownWidget).exists()).toBe(true);
expect(wrapper.findComponent(SidebarDropdownWidget).props('issuableAttribute')).toEqual(
'milestone',
);
});
describe('when we emit close', () => {
@ -128,7 +134,7 @@ describe('BoardContentSidebar', () => {
});
it('calls toggleBoardItem with correct parameters', async () => {
wrapper.find(GlDrawer).vm.$emit('close');
wrapper.findComponent(GlDrawer).vm.$emit('close');
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {

View File

@ -56,6 +56,18 @@ describe('noteable_discussion component', () => {
expect(wrapper.find('.discussion-header').exists()).toBe(true);
});
it('should hide actions when diff refs do not exists', async () => {
const discussion = { ...discussionMock };
discussion.diff_file = { ...mockDiffFile, diff_refs: null };
discussion.diff_discussion = true;
discussion.expanded = false;
wrapper.setProps({ discussion });
await nextTick();
expect(wrapper.vm.canShowReplyActions).toBe(false);
});
describe('actions', () => {
it('should toggle reply form', async () => {
await nextTick();

View File

@ -0,0 +1,503 @@
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLink,
GlSearchBoxByType,
GlFormInput,
GlLoadingIcon,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { IssuableAttributeType } from '~/sidebar/constants';
import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
import {
mockIssue,
mockProjectMilestonesResponse,
noCurrentMilestoneResponse,
mockMilestoneMutationResponse,
mockMilestone2,
emptyProjectMilestonesResponse,
} from '../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
describe('SidebarDropdownWidget', () => {
let wrapper;
let mockApollo;
const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } };
const firstErrorMsg = 'first error';
const promiseWithErrors = {
...promiseData,
issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] },
};
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
const mutationError = () =>
jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.');
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
const findGlLink = () => wrapper.findComponent(GlLink);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownText = () => wrapper.findComponent(GlDropdownText);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemWithText = (text) =>
findAllDropdownItems().wrappers.find((x) => x.text() === text);
const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]');
const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon);
const findAttributeItems = () => wrapper.findByTestId('milestone-items');
const findSelectedAttribute = () => wrapper.findByTestId('select-milestone');
const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item');
const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
const waitForDropdown = async () => {
// BDropdown first changes its `visible` property
// in a requestAnimationFrame callback.
// It then emits `shown` event in a watcher for `visible`
// Hence we need both of these:
await waitForPromises();
await wrapper.vm.$nextTick();
};
const waitForApollo = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
};
// Used with createComponentWithApollo which uses 'mount'
const clickEdit = async () => {
await findEditButton().trigger('click');
await waitForDropdown();
// We should wait for attributes list to be fetched.
await waitForApollo();
};
// Used with createComponent which shallow mounts components
const toggleDropdown = async () => {
wrapper.vm.$refs.editable.expand();
await waitForDropdown();
};
const createComponentWithApollo = async ({
requestHandlers = [],
projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse),
currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([
[projectMilestonesQuery, projectMilestonesSpy],
[projectIssueMilestoneQuery, currentMilestoneSpy],
...requestHandlers,
]);
wrapper = extendedWrapper(
mount(SidebarDropdownWidget, {
localVue,
provide: { canUpdate: true },
apolloProvider: mockApollo,
propsData: {
workspacePath: mockIssue.projectPath,
attrWorkspacePath: mockIssue.projectPath,
iid: mockIssue.iid,
issuableType: IssuableType.Issue,
issuableAttribute: IssuableAttributeType.Milestone,
},
attachTo: document.body,
}),
);
await waitForApollo();
};
const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(SidebarDropdownWidget, {
provide: { canUpdate: true },
data() {
return data;
},
propsData: {
workspacePath: '',
attrWorkspacePath: '',
iid: '',
issuableType: IssuableType.Issue,
issuableAttribute: IssuableAttributeType.Milestone,
},
mocks: {
$apollo: {
mutate: mutationPromise(),
queries: {
currentAttribute: { loading: false },
attributesList: { loading: false },
...queries,
},
},
},
stubs: {
SidebarEditableItem,
GlSearchBoxByType,
GlDropdown,
},
}),
);
// We need to mock out `showDropdown` which
// invokes `show` method of BDropdown used inside GlDropdown.
jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when not editing', () => {
beforeEach(() => {
createComponent({
data: {
currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' },
},
stubs: {
GlDropdown,
SidebarEditableItem,
},
});
});
it('shows the current attribute', () => {
expect(findSelectedAttribute().text()).toBe('title');
});
it('links to the current attribute', () => {
expect(findGlLink().attributes().href).toBe('webUrl');
});
it('does not show a loading spinner next to the heading', () => {
expect(findEditableLoadingIcon().exists()).toBe(false);
});
it('shows a loading spinner while fetching the current attribute', () => {
createComponent({
queries: {
currentAttribute: { loading: true },
},
});
expect(findEditableLoadingIcon().exists()).toBe(true);
});
it('shows the loading spinner and the title of the selected attribute while updating', () => {
createComponent({
data: {
updating: true,
selectedTitle: 'Some milestone title',
},
queries: {
currentAttribute: { loading: false },
},
});
expect(findEditableLoadingIcon().exists()).toBe(true);
expect(findSelectedAttribute().text()).toBe('Some milestone title');
});
describe('when current attribute does not exist', () => {
it('renders "None" as the selected attribute title', () => {
createComponent();
expect(findSelectedAttribute().text()).toBe('None');
});
});
});
describe('when a user can edit', () => {
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
it('shows a loading spinner while fetching a list of attributes', async () => {
createComponent({
queries: {
attributesList: { loading: true },
},
});
await toggleDropdown();
expect(findLoadingIconDropdown().exists()).toBe(true);
});
describe('GlDropdownItem with the right title and id', () => {
const id = 'id';
const title = 'title';
beforeEach(async () => {
createComponent({
data: { attributesList: [{ id, title }], currentAttribute: { id, title } },
});
await toggleDropdown();
});
it('does not show a loading spinner', () => {
expect(findLoadingIconDropdown().exists()).toBe(false);
});
it('renders title $title', () => {
expect(findDropdownItemWithText(title).exists()).toBe(true);
});
it('checks the correct dropdown item', () => {
expect(
findAllDropdownItems()
.filter((w) => w.props('isChecked') === true)
.at(0)
.text(),
).toBe(title);
});
});
describe('when no data is assigned', () => {
beforeEach(async () => {
createComponent();
await toggleDropdown();
});
it('finds GlDropdownItem with "No milestone"', () => {
expect(findNoAttributeItem().text()).toBe('No milestone');
});
it('"No milestone" is checked', () => {
expect(findNoAttributeItem().props('isChecked')).toBe(true);
});
it('does not render any dropdown item', () => {
expect(findAttributeItems().exists()).toBe(false);
});
});
describe('when clicking on dropdown item', () => {
describe('when currentAttribute is equal to attribute id', () => {
it('does not call setIssueAttribute mutation', async () => {
createComponent({
data: {
attributesList: [{ id: 'id', title: 'title' }],
currentAttribute: { id: 'id', title: 'title' },
},
});
await toggleDropdown();
findDropdownItemWithText('title').vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
});
});
describe('when currentAttribute is not equal to attribute id', () => {
describe('when error', () => {
const bootstrapComponent = (mutationResp) => {
createComponent({
data: {
attributesList: [
{ id: '123', title: '123' },
{ id: 'id', title: 'title' },
],
currentAttribute: '123',
},
mutationPromise: mutationResp,
});
};
describe.each`
description | mutationResp | expectedMsg
${'top-level error'} | ${mutationError} | ${'Failed to set milestone on this issue. Please try again.'}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(async () => {
bootstrapComponent(mutationResp);
await toggleDropdown();
findDropdownItemWithText('title').vm.$emit('click');
});
it(`calls createFlash with "${expectedMsg}"`, async () => {
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith({
message: expectedMsg,
captureError: true,
error: expectedMsg,
});
});
});
});
});
});
});
describe('when a user is searching', () => {
describe('when search result is not found', () => {
it('renders "No milestone found"', async () => {
createComponent();
await toggleDropdown();
findSearchBox().vm.$emit('input', 'non existing milestones');
await wrapper.vm.$nextTick();
expect(findDropdownText().text()).toBe('No milestone found');
});
});
});
});
});
describe('with mock apollo', () => {
let error;
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
error = new Error('mayday');
});
describe("when issuable type is 'issue'", () => {
describe('when dropdown is expanded and user can edit', () => {
let milestoneMutationSpy;
beforeEach(async () => {
milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse);
await createComponentWithApollo({
requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]],
});
await clickEdit();
});
it('renders the dropdown on clicking edit', async () => {
expect(findDropdown().isVisible()).toBe(true);
});
it('focuses on the input when dropdown is shown', async () => {
expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element);
});
describe('when currentAttribute is not equal to attribute id', () => {
describe('when update is successful', () => {
beforeEach(() => {
findDropdownItemWithText(mockMilestone2.title).vm.$emit('click');
});
it('calls setIssueAttribute mutation', () => {
expect(milestoneMutationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid,
attributeId: getIdFromGraphQLId(mockMilestone2.id),
fullPath: mockIssue.projectPath,
});
});
it('sets the value returned from the mutation to currentAttribute', async () => {
expect(findSelectedAttribute().text()).toBe(mockMilestone2.title);
});
});
});
describe('milestones', () => {
let projectMilestonesSpy;
it('should call createFlash if milestones query fails', async () => {
await createComponentWithApollo({
projectMilestonesSpy: jest.fn().mockRejectedValue(error),
});
await clickEdit();
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.i18n.listFetchError,
captureError: true,
error: expect.any(Error),
});
});
it('only fetches attributes when dropdown is opened', async () => {
projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse);
await createComponentWithApollo({ projectMilestonesSpy });
expect(projectMilestonesSpy).not.toHaveBeenCalled();
await clickEdit();
expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockIssue.projectPath,
title: '',
state: 'active',
});
});
describe('when a user is searching', () => {
const mockSearchTerm = 'foobar';
beforeEach(async () => {
projectMilestonesSpy = jest
.fn()
.mockResolvedValueOnce(emptyProjectMilestonesResponse);
await createComponentWithApollo({ projectMilestonesSpy });
await clickEdit();
});
it('sends a projectMilestones query with the entered search term "foo"', async () => {
findSearchBox().vm.$emit('input', mockSearchTerm);
await wrapper.vm.$nextTick();
// Account for debouncing
jest.runAllTimers();
expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, {
fullPath: mockIssue.projectPath,
title: mockSearchTerm,
state: 'active',
});
});
});
});
});
describe('currentAttributes', () => {
it('should call createFlash if currentAttributes query fails', async () => {
await createComponentWithApollo({
currentMilestoneSpy: jest.fn().mockRejectedValue(error),
});
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.i18n.currentFetchError,
captureError: true,
error: expect.any(Error),
});
});
});
});
});
});

View File

@ -513,4 +513,83 @@ export const participantsQueryResponse = {
},
};
export const mockGroupPath = 'gitlab-org';
export const mockProjectPath = `${mockGroupPath}/some-project`;
export const mockIssue = {
projectPath: mockProjectPath,
iid: '1',
groupPath: mockGroupPath,
};
export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockMilestone1 = {
__typename: 'Milestone',
id: 'gid://gitlab/Milestone/1',
title: 'Foobar Milestone',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1',
state: 'active',
};
export const mockMilestone2 = {
__typename: 'Milestone',
id: 'gid://gitlab/Milestone/2',
title: 'Awesome Milestone',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2',
state: 'active',
};
export const mockProjectMilestonesResponse = {
data: {
workspace: {
attributes: {
nodes: [mockMilestone1, mockMilestone2],
},
__typename: 'MilestoneConnection',
},
__typename: 'Project',
},
};
export const noCurrentMilestoneResponse = {
data: {
workspace: {
issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' },
__typename: 'Project',
},
},
};
export const mockMilestoneMutationResponse = {
data: {
issuableSetAttribute: {
errors: [],
issuable: {
id: 'gid://gitlab/Issue/1',
attribute: {
id: 'gid://gitlab/Milestone/2',
title: 'Awesome Milestone',
state: 'active',
__typename: 'Milestone',
},
__typename: 'Issue',
},
__typename: 'UpdateIssuePayload',
},
},
};
export const emptyProjectMilestonesResponse = {
data: {
workspace: {
attributes: {
nodes: [],
},
__typename: 'MilestoneConnection',
},
__typename: 'Project',
},
};
export default mockData;

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20210525075724_clean_up_pending_builds_table.rb')
RSpec.describe CleanUpPendingBuildsTable do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:queue) { table(:ci_pending_builds) }
let(:builds) { table(:ci_builds) }
before do
namespaces.create!(id: 123, name: 'sample', path: 'sample')
projects.create!(id: 123, name: 'sample', path: 'sample', namespace_id: 123)
builds.create!(id: 1, project_id: 123, status: 'pending', type: 'Ci::Build')
builds.create!(id: 2, project_id: 123, status: 'pending', type: 'GenericCommitStatus')
builds.create!(id: 3, project_id: 123, status: 'success', type: 'Ci::Bridge')
builds.create!(id: 4, project_id: 123, status: 'success', type: 'Ci::Build')
builds.create!(id: 5, project_id: 123, status: 'running', type: 'Ci::Build')
builds.create!(id: 6, project_id: 123, status: 'created', type: 'Ci::Build')
queue.create!(id: 1, project_id: 123, build_id: 1)
queue.create!(id: 2, project_id: 123, build_id: 4)
queue.create!(id: 3, project_id: 123, build_id: 5)
end
it 'removes duplicated data from pending builds table' do
migrate!
expect(queue.all.count).to eq 1
expect(queue.first.id).to eq 1
expect(builds.all.count).to eq 6
end
context 'when there are multiple batches' do
before do
stub_const("#{described_class}::BATCH_SIZE", 1)
end
it 'iterates the data correctly' do
migrate!
expect(queue.all.count).to eq 1
end
end
end

View File

@ -354,7 +354,7 @@ RSpec.describe Ci::Build do
it 'does not push build to the queue' do
build.enqueue
expect(::Ci::PendingBuild.all.count).to be_zero
expect(build.queuing_entry).not_to be_present
end
end

View File

@ -23,7 +23,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:user) { create(:user) }
let(:job) do
create(:ci_build, :artifacts, :extended_options,
create(:ci_build, :pending, :queued, :artifacts, :extended_options,
pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
end
@ -129,7 +129,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when other projects have pending jobs' do
before do
job.success
create(:ci_build, :pending)
create(:ci_build, :pending, :queued)
end
it_behaves_like 'no jobs available'
@ -239,7 +239,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when job is made for tag' do
let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
it 'sets branch as ref_type' do
request_job
@ -297,7 +297,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when job filtered by job_age' do
let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, queued_at: 60.seconds.ago) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, queued_at: 60.seconds.ago) }
context 'job is queued less than job_age parameter' do
let(:job_age) { 120 }
@ -359,7 +359,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when job is for a release' do
let!(:job) { create(:ci_build, :release_options, pipeline: pipeline) }
let!(:job) { create(:ci_build, :pending, :queued, :release_options, pipeline: pipeline) }
context 'when `multi_build_steps` is passed by the runner' do
it 'exposes release info' do
@ -398,7 +398,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when job is made for merge request' do
let(:pipeline) { create(:ci_pipeline, source: :merge_request_event, project: project, ref: 'feature', merge_request: merge_request) }
let!(:job) { create(:ci_build, pipeline: pipeline, name: 'spinach', ref: 'feature', stage: 'test', stage_idx: 0) }
let!(:job) { create(:ci_build, :pending, :queued, pipeline: pipeline, name: 'spinach', ref: 'feature', stage: 'test', stage_idx: 0) }
let(:merge_request) { create(:merge_request) }
it 'sets branch as ref_type' do
@ -479,9 +479,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when project and pipeline have multiple jobs' do
let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:test_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
before do
job.success
@ -531,8 +531,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when pipeline have jobs with artifacts' do
let!(:job) { create(:ci_build, :tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:test_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
before do
job.success
@ -551,10 +551,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when explicit dependencies are defined' do
let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:test_job) do
create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'deploy',
create(:ci_build, :pending, :queued, pipeline: pipeline, token: 'test-job-token', name: 'deploy',
stage: 'deploy', stage_idx: 1,
options: { script: ['bash'], dependencies: [job2.name] })
end
@ -575,10 +575,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when dependencies is an empty array' do
let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:job) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let!(:job2) { create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
let!(:empty_dependencies_job) do
create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'empty_dependencies_job',
create(:ci_build, :pending, :queued, pipeline: pipeline, token: 'test-job-token', name: 'empty_dependencies_job',
stage: 'deploy', stage_idx: 1,
options: { script: ['bash'], dependencies: [] })
end
@ -739,7 +739,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe 'port support' do
let(:job) { create(:ci_build, pipeline: pipeline, options: options) }
let(:job) { create(:ci_build, :pending, :queued, pipeline: pipeline, options: options) }
context 'when job image has ports' do
let(:options) do
@ -791,7 +791,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
describe 'a job with excluded artifacts' do
context 'when excluded paths are defined' do
let(:job) do
create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'test',
create(:ci_build, :pending, :queued, pipeline: pipeline, token: 'test-job-token', name: 'test',
stage: 'deploy', stage_idx: 1,
options: { artifacts: { paths: ['abc'], exclude: ['cde'] } })
end
@ -839,7 +839,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
subject { request_job }
context 'when triggered by a user' do
let(:job) { create(:ci_build, user: user, project: project) }
let(:job) { create(:ci_build, :pending, :queued, user: user, project: project) }
subject { request_job(id: job.id) }

View File

@ -148,6 +148,18 @@ RSpec.describe API::FeatureFlags do
expect(json_response['version']).to eq('legacy_flag')
end
context 'without legacy flags' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
it_behaves_like 'check user permission'
end
@ -492,6 +504,18 @@ RSpec.describe API::FeatureFlags do
end
it_behaves_like 'check user permission'
context 'without legacy flags' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when feature flag exists already' do
@ -537,6 +561,18 @@ RSpec.describe API::FeatureFlags do
end
end
end
context 'without legacy flags' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with a version 2 flag' do
@ -612,6 +648,18 @@ RSpec.describe API::FeatureFlags do
})
end
context 'without legacy flags' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
it_behaves_like 'check user permission'
context 'when strategies become empty array after the removal' do
@ -976,6 +1024,20 @@ RSpec.describe API::FeatureFlags do
expect(feature_flag.reload.strategies.first.scopes.count).to eq(0)
end
end
context 'without legacy flags' do
before do
stub_feature_flags(remove_legacy_flags: true, remove_legacy_flags_override: false)
end
it 'returns not found' do
params = { description: 'new description' }
put api("/projects/#{project.id}/feature_flags/other_flag_name", user), params: params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'DELETE /projects/:id/feature_flags/:name' do

View File

@ -11,7 +11,13 @@ RSpec.describe MergeRequestDiffEntity do
let(:merge_request_diff) { merge_request_diffs.first }
let(:entity) do
described_class.new(merge_request_diff, request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs)
described_class.new(
merge_request_diff,
request: request,
merge_request: merge_request,
merge_request_diff: merge_request_diff,
merge_request_diffs: merge_request_diffs
)
end
subject { entity.as_json }
@ -26,6 +32,46 @@ RSpec.describe MergeRequestDiffEntity do
end
end
describe '#version_index' do
shared_examples 'version_index is nil' do
it 'returns nil' do
expect(subject[:version_index]).to be_nil
end
end
context 'when diff is not present' do
let(:entity) do
described_class.new(
merge_request_diff,
request: request,
merge_request: merge_request,
merge_request_diffs: merge_request_diffs
)
end
it_behaves_like 'version_index is nil'
end
context 'when diff is not included in @merge_request_diffs' do
let(:merge_request_diff) { create(:merge_request_diff) }
let(:merge_request_diff_2) { create(:merge_request_diff) }
before do
merge_request_diffs << merge_request_diff_2
end
it_behaves_like 'version_index is nil'
end
context 'when @merge_request_diffs.size <= 1' do
before do
expect(merge_request_diffs.size).to eq(1)
end
it_behaves_like 'version_index is nil'
end
end
describe '#short_commit_sha' do
it 'returns short sha' do
expect(subject[:short_commit_sha]).to eq('b83d6e39')

View File

@ -11,7 +11,7 @@ module Ci
let!(:shared_runner) { create(:ci_runner, :instance) }
let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
let!(:group_runner) { create(:ci_runner, :group, groups: [group]) }
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
describe '#execute' do
context 'checks database loadbalancing stickiness' do
@ -104,11 +104,11 @@ module Ci
let!(:project3) { create :project, shared_runners_enabled: true }
let!(:pipeline3) { create :ci_pipeline, project: project3 }
let!(:build1_project1) { pending_job }
let!(:build2_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
let!(:build3_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
let!(:build1_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
let!(:build2_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
let!(:build1_project3) { FactoryBot.create :ci_build, pipeline: pipeline3 }
let!(:build2_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
let!(:build3_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
let!(:build1_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
let!(:build2_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
let!(:build1_project3) { create(:ci_build, :pending, :queued, pipeline: pipeline3) }
context 'when using fair scheduling' do
context 'when all builds are pending' do
@ -255,17 +255,17 @@ module Ci
let!(:pipeline3) { create(:ci_pipeline, project: project3) }
let!(:build1_project1) { pending_job }
let!(:build2_project1) { create(:ci_build, pipeline: pipeline) }
let!(:build3_project1) { create(:ci_build, pipeline: pipeline) }
let!(:build1_project2) { create(:ci_build, pipeline: pipeline2) }
let!(:build2_project2) { create(:ci_build, pipeline: pipeline2) }
let!(:build1_project3) { create(:ci_build, pipeline: pipeline3) }
let!(:build2_project1) { create(:ci_build, :queued, pipeline: pipeline) }
let!(:build3_project1) { create(:ci_build, :queued, pipeline: pipeline) }
let!(:build1_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
let!(:build2_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
let!(:build1_project3) { create(:ci_build, :queued, pipeline: pipeline3) }
# these shouldn't influence the scheduling
let!(:unrelated_group) { create(:group) }
let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) }
let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) }
let!(:build1_unrelated_project) { create(:ci_build, pipeline: unrelated_pipeline) }
let!(:build1_unrelated_project) { create(:ci_build, :pending, :queued, pipeline: unrelated_pipeline) }
let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
it 'does not consider builds from other group runners' do
@ -346,7 +346,7 @@ module Ci
subject { described_class.new(specific_runner).execute }
context 'with multiple builds are in queue' do
let!(:other_build) { create :ci_build, pipeline: pipeline }
let!(:other_build) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
before do
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
@ -387,7 +387,7 @@ module Ci
let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
context 'when a job is protected' do
let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
it 'picks the job' do
expect(execute(specific_runner)).to eq(pending_job)
@ -395,7 +395,7 @@ module Ci
end
context 'when a job is unprotected' do
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
it 'picks the job' do
expect(execute(specific_runner)).to eq(pending_job)
@ -403,7 +403,7 @@ module Ci
end
context 'when protected attribute of a job is nil' do
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
before do
pending_job.update_attribute(:protected, nil)
@ -419,7 +419,7 @@ module Ci
let!(:specific_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) }
context 'when a job is protected' do
let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
it 'picks the job' do
expect(execute(specific_runner)).to eq(pending_job)
@ -427,7 +427,7 @@ module Ci
end
context 'when a job is unprotected' do
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
it 'does not pick the job' do
expect(execute(specific_runner)).to be_nil
@ -435,7 +435,7 @@ module Ci
end
context 'when protected attribute of a job is nil' do
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
before do
pending_job.update_attribute(:protected, nil)
@ -449,7 +449,7 @@ module Ci
context 'runner feature set is verified' do
let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } }
let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, options: options) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, options: options) }
subject { execute(specific_runner, params) }
@ -485,7 +485,7 @@ module Ci
shared_examples 'validation is active' do
context 'when depended job has not been completed yet' do
let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
let!(:pre_stage_job) { create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
it { expect(subject).to eq(pending_job) }
end
@ -522,7 +522,7 @@ module Ci
shared_examples 'validation is not active' do
context 'when depended job has not been completed yet' do
let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
let!(:pre_stage_job) { create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
it { expect(subject).to eq(pending_job) }
end
@ -547,7 +547,7 @@ module Ci
let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) }
let!(:pending_job) do
create(:ci_build, :pending,
create(:ci_build, :pending, :queued,
pipeline: pipeline, stage_idx: 1,
options: { script: ["bash"], dependencies: ['test'] })
end
@ -558,7 +558,7 @@ module Ci
end
context 'when build is degenerated' do
let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, :degenerated, pipeline: pipeline) }
subject { execute(specific_runner, {}) }
@ -573,7 +573,7 @@ module Ci
context 'when build has data integrity problem' do
let!(:pending_job) do
create(:ci_build, :pending, pipeline: pipeline)
create(:ci_build, :pending, :queued, pipeline: pipeline)
end
before do
@ -598,7 +598,7 @@ module Ci
context 'when build fails to be run!' do
let!(:pending_job) do
create(:ci_build, :pending, pipeline: pipeline)
create(:ci_build, :pending, :queued, pipeline: pipeline)
end
before do
@ -640,12 +640,12 @@ module Ci
context 'when only some builds can be matched by runner' do
let!(:specific_runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[matching]) }
let!(:pending_job) { create(:ci_build, pipeline: pipeline, tag_list: %w[matching]) }
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[matching]) }
before do
# create additional matching and non-matching jobs
create_list(:ci_build, 2, pipeline: pipeline, tag_list: %w[matching])
create(:ci_build, pipeline: pipeline, tag_list: %w[non-matching])
create_list(:ci_build, 2, :pending, :queued, pipeline: pipeline, tag_list: %w[matching])
create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[non-matching])
end
it 'observes queue size of only matching jobs' do
@ -693,7 +693,7 @@ module Ci
end
context 'when there is another build in queue' do
let!(:next_pending_job) { create(:ci_build, pipeline: pipeline) }
let!(:next_pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
it 'skips this build and picks another build' do
expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
@ -732,6 +732,22 @@ module Ci
include_examples 'handles runner assignment'
end
context 'when joining with pending builds table' do
before do
stub_feature_flags(ci_pending_builds_queue_join: true)
end
include_examples 'handles runner assignment'
end
context 'when not joining with pending builds table' do
before do
stub_feature_flags(ci_pending_builds_queue_join: false)
end
include_examples 'handles runner assignment'
end
end
describe '#register_success' do
@ -775,8 +791,8 @@ module Ci
end
context 'when project already has running jobs' do
let!(:build2) { create( :ci_build, :running, pipeline: pipeline, runner: shared_runner) }
let!(:build3) { create( :ci_build, :running, pipeline: pipeline, runner: shared_runner) }
let!(:build2) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) }
let!(:build3) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) }
it 'counts job queuing time histogram with expected labels' do
allow(attempt_counter).to receive(:increment)
@ -859,9 +875,9 @@ module Ci
end
context 'when max queue depth is reached' do
let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
let!(:pending_job_2) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
let!(:pending_job_3) { create(:ci_build, :pending, pipeline: pipeline) }
let!(:pending_job) { create(:ci_build, :pending, :queued, :degenerated, pipeline: pipeline) }
let!(:pending_job_2) { create(:ci_build, :pending, :queued, :degenerated, pipeline: pipeline) }
let!(:pending_job_3) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
before do
stub_const("#{described_class}::MAX_QUEUE_DEPTH", 2)

View File

@ -66,7 +66,7 @@ RSpec.describe Ci::RetryBuildService do
let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
let_it_be(:build) do
create(:ci_build, :failed, :expired, :erased, :queued, :coverage, :tags,
create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
:allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
description: 'my-job', stage: 'test', stage_id: stage.id,
pipeline: pipeline, auto_canceled_by: another_pipeline,

View File

@ -95,6 +95,42 @@ RSpec.describe Deployments::UpdateEnvironmentService do
end
end
context 'when external URL is specified and the tier is unset' do
let(:options) { { name: 'production', url: external_url } }
before do
environment.update_columns(external_url: external_url, tier: nil)
job.update!(environment: 'production')
end
context 'when external URL is valid' do
let(:external_url) { 'https://google.com' }
it 'succeeds to update the tier automatically' do
expect { subject.execute }.to change { environment.tier }.from(nil).to('production')
end
end
context 'when external URL is invalid' do
let(:external_url) { 'google.com' }
it 'fails to update the tier due to validation error' do
expect { subject.execute }.not_to change { environment.tier }
end
it 'tracks an exception' do
expect(Gitlab::ErrorTracking).to receive(:track_exception)
.with(an_instance_of(described_class::EnvironmentUpdateFailure),
project_id: project.id,
environment_id: environment.id,
reason: %q{External url is blocked: Only allowed schemes are http, https})
.once
subject.execute
end
end
end
context 'when variables are used' do
let(:options) do
{ name: 'review-apps/$CI_COMMIT_REF_NAME',

2
storybook/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
public/

8
storybook/config/main.js Normal file
View File

@ -0,0 +1,8 @@
/* eslint-disable import/no-commonjs */
module.exports = {
stories: [
'../../app/assets/javascripts/**/*.stories.js',
'../../ee/app/assets/javascripts/**/*.stories.js',
],
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
};

View File

@ -0,0 +1,7 @@
const stylesheetsRequireCtx = require.context(
'../../app/assets/stylesheets',
true,
/application\.scss$/,
);
stylesheetsRequireCtx('./application.scss');

View File

@ -0,0 +1,103 @@
/* eslint-disable no-param-reassign */
const { statSync } = require('fs');
const path = require('path');
const sass = require('node-sass'); // eslint-disable-line import/no-unresolved
const { buildIncludePaths, resolveGlobUrl } = require('node-sass-magic-importer/dist/toolbox'); // eslint-disable-line import/no-unresolved
const webpack = require('webpack');
const gitlabWebpackConfig = require('../../config/webpack.config.js');
const ROOT = path.resolve(__dirname, '../../');
const TRANSPARENT_1X1_PNG =
'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)';
const SASS_INCLUDE_PATHS = [
'app/assets/stylesheets',
'ee/app/assets/stylesheets',
'ee/app/assets/stylesheets/_ee',
'node_modules',
].map((p) => path.resolve(ROOT, p));
/**
* Custom importer for node-sass, used when LibSass encounters the `@import` directive.
* Doc source: https://github.com/sass/node-sass#importer--v200---experimental
* @param {*} url the path in import as-is, which LibSass encountered.
* @param {*} prev the previously resolved path.
* @returns {Object | null} the new import string.
*/
function sassSmartImporter(url, prev) {
const nodeSassOptions = this.options;
const includePaths = buildIncludePaths(nodeSassOptions.includePaths, prev).filter(
(includePath) => !includePath.includes('node_modules'),
);
// GitLab extensively uses glob-style import paths, but
// Sass doesn't support glob-style URLs out of the box.
// Here, we try and resolve the glob URL.
// If it resolves, we update the @import statement with the resolved path.
const filePaths = resolveGlobUrl(url, includePaths);
if (filePaths) {
const contents = filePaths
.filter((file) => statSync(file).isFile())
.map((x) => `@import '${x}';`)
.join(`\n`);
return { contents };
}
return null;
}
const sassLoaderOptions = {
functions: {
'image-url($url)': function sassImageUrlStub() {
return new sass.types.String(TRANSPARENT_1X1_PNG);
},
'asset_path($url)': function sassAssetPathStub() {
return new sass.types.String(TRANSPARENT_1X1_PNG);
},
'asset_url($url)': function sassAssetUrlStub() {
return new sass.types.String(TRANSPARENT_1X1_PNG);
},
'url($url)': function sassUrlStub() {
return new sass.types.String(TRANSPARENT_1X1_PNG);
},
},
includePaths: SASS_INCLUDE_PATHS,
importer: sassSmartImporter,
};
module.exports = function storybookWebpackConfig({ config }) {
// Add any missing extensions from the main GitLab webpack config
config.resolve.extensions = Array.from(
new Set([...config.resolve.extensions, ...gitlabWebpackConfig.resolve.extensions]),
);
// Replace any Storybook-defined CSS loaders with our custom one.
config.module.rules = [
...config.module.rules.filter((r) => !r.test.test('.css')),
{
test: /\.s?css$/,
exclude: /typescale\/\w+_demo\.scss$/, // skip typescale demo stylesheets
loaders: [
'style-loader',
'css-loader',
{
loader: 'sass-loader',
options: sassLoaderOptions,
},
],
},
];
// Silence webpack warnings about moment/pikaday not being able to resolve.
config.plugins.push(new webpack.IgnorePlugin(/moment/, /pikaday/));
// Add any missing aliases from the main GitLab webpack config
Object.assign(config.resolve.alias, gitlabWebpackConfig.resolve.alias);
// The main GitLab project aliases this `icons.svg` file to app/assets/javascripts/lib/utils/icons_path.js,
// which depends on the existence of a global `gon` variable.
// By deleting the alias, imports of this path will resolve as expected.
delete config.resolve.alias['@gitlab/svgs/dist/icons.svg'];
return config;
};

20
storybook/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "start-storybook -p 9002 -c config",
"build": "build-storybook -c config -o public"
},
"dependencies": {},
"devDependencies": {
"@storybook/addon-a11y": "^6.2.9",
"@storybook/addon-actions": "^6.2.9",
"@storybook/addon-controls": "^6.2.9",
"@storybook/addon-essentials": "^6.2.9",
"@storybook/vue": "6.2.9",
"node-sass": "^4.14.1",
"node-sass-magic-importer": "^5.3.2",
"postcss-loader": "3.0.0",
"sass-loader": "^7.1.0"
}
}

10766
storybook/yarn.lock Normal file

File diff suppressed because it is too large Load Diff