Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-17 18:10:42 +00:00
parent 68c476dbd8
commit 49bb78aac3
116 changed files with 2293 additions and 388 deletions

View File

@ -1,8 +1,9 @@
include:
- template: Jobs/Code-Quality.gitlab-ci.yml
# - template: Security/SAST.gitlab-ci.yml
# - template: Security/Dependency-Scanning.gitlab-ci.yml
# - template: Security/DAST.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml
code_quality:
extends:
@ -13,85 +14,55 @@ code_quality:
- gl-code-quality-report.json # GitLab-specific
rules: !reference [".reports:rules:code_quality", rules]
# We need to duplicate this job's definition because the rules
# defined in the extended jobs rely on local YAML anchors
# (`*if-default-refs`)
.sast:
.sast-analyzer:
# We need to re-`extends` from `sast` as the `extends` here overrides the one from the template.
extends:
- .default-retry
- .reports:rules:sast
stage: test
# `needs: []` starts the job immediately in the pipeline
# https://docs.gitlab.com/ee/ci/yaml/README.html#needs
- sast
needs: []
artifacts:
paths:
- gl-sast-report.json # GitLab-specific
reports:
sast: gl-sast-report.json
expire_in: 1 week # GitLab-specific
variables:
DOCKER_TLS_CERTDIR: ""
SAST_ANALYZER_IMAGE_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers"
SAST_ANALYZER_IMAGE_TAG: 2
SAST_BRAKEMAN_LEVEL: 2 # GitLab-specific
SAST_EXCLUDED_PATHS: qa,spec,doc,ee/spec,config/gitlab.yml.example # GitLab-specific
SAST_EXCLUDED_PATHS: "qa, spec, doc, ee/spec, config/gitlab.yml.example, tmp" # GitLab-specific
SAST_DISABLE_BABEL: "true"
script:
- /analyzer run
brakeman-sast:
extends: .sast
image:
name: "$SAST_ANALYZER_IMAGE_PREFIX/brakeman:$SAST_ANALYZER_IMAGE_TAG"
rules: !reference [".reports:rules:sast", rules]
eslint-sast:
extends: .sast
image:
name: "$SAST_ANALYZER_IMAGE_PREFIX/eslint:$SAST_ANALYZER_IMAGE_TAG"
rules: !reference [".reports:rules:sast", rules]
nodejs-scan-sast:
extends: .sast
image:
name: "$SAST_ANALYZER_IMAGE_PREFIX/nodejs-scan:$SAST_ANALYZER_IMAGE_TAG"
rules: !reference [".reports:rules:sast", rules]
secrets-sast:
extends: .sast
image:
name: "$SAST_ANALYZER_IMAGE_PREFIX/secrets:3"
.secret-analyzer:
extends: .default-retry
needs: []
artifacts:
paths:
- gl-secret-detection-report.json # GitLab-specific
reports:
sast: gl-secret-detection-report.json
expire_in: 1 week # GitLab-specific
# We need to duplicate this job's definition because the rules
# defined in the extended jobs rely on local YAML anchors
# (`*if-default-refs`)
.dependency_scanning:
secret_detection:
rules: !reference [".reports:rules:secret_detection", rules]
.ds-analyzer:
# We need to re-`extends` from `dependency_scanning` as the `extends` here overrides the one from the template.
extends:
- .default-retry
- .reports:rules:dependency_scanning
stage: test
- dependency_scanning
needs: []
variables:
DS_MAJOR_VERSION: 2
DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports, spec, ee/spec" # GitLab-specific
SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers"
DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports, spec, ee/spec, tmp" # GitLab-specific
artifacts:
paths:
- gl-dependency-scanning-report.json # GitLab-specific
reports:
dependency_scanning: gl-dependency-scanning-report.json
expire_in: 1 week # GitLab-specific
script:
- /analyzer run
dependency_scanning gemnasium:
extends: .dependency_scanning
image:
name: "$SECURE_ANALYZERS_PREFIX/gemnasium:$DS_MAJOR_VERSION"
gemnasium-dependency_scanning:
before_script:
# git-lfs is needed for auto-remediation
- apk add git-lfs
@ -100,26 +71,22 @@ dependency_scanning gemnasium:
- apk add jq
# Lower execa severity based on https://gitlab.com/gitlab-org/gitlab/-/issues/223859#note_452922390
- jq '(.vulnerabilities[] | select (.cve == "yarn.lock:execa:gemnasium:05cfa2e8-2d0c-42c1-8894-638e2f12ff3d")).severity = "Medium"' gl-dependency-scanning-report.json > temp.json && mv temp.json gl-dependency-scanning-report.json
rules: !reference [".reports:rules:dependency_scanning", rules]
dependency_scanning bundler-audit:
extends: .dependency_scanning
image:
name: "$SECURE_ANALYZERS_PREFIX/bundler-audit:$DS_MAJOR_VERSION"
bundler-audit-dependency_scanning:
rules: !reference [".reports:rules:dependency_scanning", rules]
dependency_scanning retire-js:
extends: .dependency_scanning
image:
name: "$SECURE_ANALYZERS_PREFIX/retire.js:$DS_MAJOR_VERSION"
retire-js-dependency_scanning:
rules: !reference [".reports:rules:dependency_scanning", rules]
dependency_scanning gemnasium-python:
extends: .dependency_scanning
image:
name: "$SECURE_ANALYZERS_PREFIX/gemnasium-python:$DS_MAJOR_VERSION"
gemnasium-python-dependency_scanning:
rules: !reference [".reports:rules:dependency_scanning", rules]
# Analyze dependencies for malicious behavior
# See https://gitlab.com/gitlab-com/gl-security/security-research/package-hunter
package_hunter:
extends:
- .default-retry
- .reports:rules:package_hunter
stage: test
image:
@ -133,24 +100,14 @@ package_hunter:
- DEBUG=* HTR_user=$PACKAGE_HUNTER_USER HTR_pass=$PACKAGE_HUNTER_PASS node /usr/src/app/cli.js analyze --format gitlab gitlab.tgz | tee $CI_PROJECT_DIR/gl-dependency-scanning-report.json
artifacts:
paths:
- gl-dependency-scanning-report.json # GitLab-specific
- gl-dependency-scanning-report.json
reports:
dependency_scanning: gl-dependency-scanning-report.json
expire_in: 1 week # GitLab-specific
expire_in: 1 week
license_scanning:
extends:
- .default-retry
- .reports:rules:license_scanning
stage: test
image:
name: "registry.gitlab.com/gitlab-org/security-products/analyzers/license-finder:3"
entrypoint: [""]
extends: .default-retry
needs: []
script:
- /run.sh analyze .
artifacts:
reports:
license_scanning: gl-license-scanning-report.json
expire_in: 1 week # GitLab-specific
dependencies: []
rules: !reference [".reports:rules:license_scanning", rules]

View File

@ -1001,6 +1001,16 @@
changes: *code-backstage-qa-patterns
allow_failure: true
.reports:rules:secret_detection:
rules:
- if: '$SECRET_DETECTION_DISABLED'
when: never
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # The Secret-Detection template already has a `secret_detection_default_branch` job
when: never
# - <<: *if-default-branch-refs # To be done in a later iteration: https://gitlab.com/gitlab-org/gitlab/issues/31160#note_278188255
- changes: *code-backstage-qa-patterns
allow_failure: true
.reports:rules:dependency_scanning:
rules:
- if: '$DEPENDENCY_SCANNING_DISABLED || $GITLAB_FEATURES !~ /\bdependency_scanning\b/'

View File

@ -8,8 +8,6 @@ const createAnchor = (href) => {
const fragment = new DocumentFragment();
const el = document.createElement('a');
el.classList.add('link-anchor');
el.setAttribute('data-qa-selector', 'line_link');
el.setAttribute('data-qa-number', href);
el.href = href;
fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => {

View File

@ -37,15 +37,16 @@ export const TRANSLATION_KEYS = {
},
};
export const FREQUENT_ITEMS_DROPDOWNS = [
{
namespace: 'projects',
key: 'project',
vuexModule: 'frequentProjects',
},
{
namespace: 'groups',
key: 'group',
vuexModule: 'frequentGroups',
},
];
export const FREQUENT_ITEMS_PROJECTS = {
namespace: 'projects',
key: 'project',
vuexModule: 'frequentProjects',
};
export const FREQUENT_ITEMS_GROUPS = {
namespace: 'groups',
key: 'group',
vuexModule: 'frequentGroups',
};
export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS];

View File

@ -13,14 +13,16 @@ export const createFrequentItemsModule = (initState = {}) => ({
state: state(initState),
});
export const createStoreOptions = () => ({
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
});
export const createStore = () => {
return new Vuex.Store({
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
});
return new Vuex.Store(createStoreOptions());
};

View File

@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import 'ee_else_ce/main_ee';
@ -80,6 +81,7 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
initTopNav();
initBreadcrumbs();
initTodoToggle();
initLogoAnimation();

View File

@ -0,0 +1,59 @@
<script>
import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
const TOOLTIP = s__('TopNav|Switch to...');
export default {
components: {
GlNav,
GlNavItemDropdown,
GlDropdownForm,
GlTooltip,
TopNavDropdownMenu,
},
props: {
navData: {
type: Object,
required: true,
},
},
methods: {
findTooltipTarget() {
// ### Why use a target function instead of `v-gl-tooltip`?
// To get the tooltip to align correctly, we need it to target the actual
// toggle button which we don't directly render.
return this.$el.querySelector('.js-top-nav-dropdown-toggle');
},
},
TOOLTIP,
};
</script>
<template>
<gl-nav class="navbar-sub-nav">
<gl-nav-item-dropdown
:text="navData.activeTitle"
icon="dot-grid"
menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!"
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
no-flip
>
<gl-dropdown-form>
<top-nav-dropdown-menu
:primary="navData.primary"
:secondary="navData.secondary"
:views="navData.views"
/>
</gl-dropdown-form>
</gl-nav-item-dropdown>
<gl-tooltip
boundary="window"
:boundary-padding="0"
:target="findTooltipTarget"
placement="right"
:title="$options.TOOLTIP"
/>
</gl-nav>
</template>

View File

@ -0,0 +1,74 @@
<script>
import FrequentItemsApp from '~/frequent_items/components/app.vue';
import eventHub from '~/frequent_items/event_hub';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import TopNavMenuItem from './top_nav_menu_item.vue';
export default {
components: {
FrequentItemsApp,
TopNavMenuItem,
VuexModuleProvider,
},
props: {
frequentItemsVuexModule: {
type: String,
required: true,
},
frequentItemsDropdownType: {
type: String,
required: true,
},
linksPrimary: {
type: Array,
required: false,
default: () => [],
},
linksSecondary: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
linkGroups() {
return [
{ key: 'primary', links: this.linksPrimary },
{ key: 'secondary', links: this.linksSecondary },
].filter((x) => x.links?.length);
},
},
mounted() {
// For historic reasons, the frequent-items-app component requires this too start up.
this.$nextTick(() => {
eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`);
});
},
};
</script>
<template>
<div class="top-nav-container-view gl-display-flex gl-flex-direction-column">
<div class="frequent-items-dropdown-container gl-w-auto">
<div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
<vuex-module-provider :vuex-module="frequentItemsVuexModule">
<frequent-items-app v-bind="$attrs" />
</vuex-module-provider>
</div>
</div>
<div
v-for="({ key, links }, groupIndex) in linkGroups"
:key="key"
:class="{ 'gl-mt-3': groupIndex !== 0 }"
class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
data-testid="menu-item-group"
>
<top-nav-menu-item
v-for="(link, linkIndex) in links"
:key="link.title"
:menu-item="link"
:class="{ 'gl-mt-1': linkIndex !== 0 }"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,144 @@
<script>
import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import TopNavContainerView from './top_nav_container_view.vue';
import TopNavMenuItem from './top_nav_menu_item.vue';
const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active';
const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
export default {
components: {
KeepAliveSlots,
TopNavContainerView,
TopNavMenuItem,
},
props: {
primary: {
type: Array,
required: false,
default: () => [],
},
secondary: {
type: Array,
required: false,
default: () => [],
},
views: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
activeId: '',
};
},
computed: {
menuItemGroups() {
return [
{ key: 'primary', items: this.primary, classes: '' },
{
key: 'secondary',
items: this.secondary,
classes: SECONDARY_GROUP_CLASS,
},
].filter((x) => x.items?.length);
},
allMenuItems() {
return this.menuItemGroups.flatMap((x) => x.items);
},
activeMenuItem() {
return this.allMenuItems.find((x) => x.id === this.activeId);
},
activeView() {
return this.activeMenuItem?.view;
},
menuClass() {
if (!this.activeView) {
return 'gl-w-full';
}
return '';
},
},
created() {
// Initialize activeId based on initialization prop
this.activeId = this.allMenuItems.find((x) => x.active)?.id;
},
methods: {
onClick({ id, href }) {
// If we're a link, let's just do the default behavior so the view won't change
if (href) {
return;
}
this.activeId = id;
},
menuItemClasses(menuItem) {
if (menuItem.id === this.activeId) {
return ACTIVE_CLASS;
}
return '';
},
},
FREQUENT_ITEMS_PROJECTS,
FREQUENT_ITEMS_GROUPS,
// expose for unit tests
ACTIVE_CLASS,
SECONDARY_GROUP_CLASS,
};
</script>
<template>
<div class="gl-display-flex gl-align-items-stretch">
<div
class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10"
:class="menuClass"
data-testid="menu-sidebar"
>
<div
class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column"
>
<div
v-for="group in menuItemGroups"
:key="group.key"
:class="group.classes"
data-testid="menu-item-group"
>
<top-nav-menu-item
v-for="(menu, index) in group.items"
:key="menu.id"
data-testid="menu-item"
:class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]"
:menu-item="menu"
@click="onClick(menu)"
/>
</div>
</div>
</div>
<keep-alive-slots
v-show="activeView"
:slot-key="activeView"
class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5"
data-testid="menu-subview"
>
<template #projects>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule"
v-bind="views.projects"
/>
</template>
<template #groups>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule"
v-bind="views.groups"
/>
</template>
</keep-alive-slots>
</div>
</template>

View File

@ -0,0 +1,31 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
},
props: {
menuItem: {
type: Object,
required: true,
},
},
};
</script>
<template>
<gl-button
category="tertiary"
:href="menuItem.href"
class="top-nav-menu-item gl-display-block"
v-on="$listeners"
>
<span class="gl-display-flex">
<gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" />
{{ menuItem.title }}
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
</span>
</gl-button>
</template>

View File

@ -0,0 +1,12 @@
export const initTopNav = async () => {
const el = document.getElementById('js-top-nav');
if (!el) {
return;
}
// With combined_menu feature flag, there's a benefit to splitting up the import
const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount');
mountTopNav(el);
};

View File

@ -0,0 +1,23 @@
import Vue from 'vue';
import Vuex from 'vuex';
import App from './components/top_nav_app.vue';
import { createStore } from './stores';
Vue.use(Vuex);
export const mountTopNav = (el) => {
const viewModel = JSON.parse(el.dataset.viewModel);
const store = createStore();
return new Vue({
el,
store,
render(h) {
return h(App, {
props: {
navData: viewModel,
},
});
},
});
};

View File

@ -0,0 +1,4 @@
import Vuex from 'vuex';
import { createStoreOptions } from '~/frequent_items/store';
export const createStore = () => new Vuex.Store(createStoreOptions());

View File

@ -839,8 +839,52 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.frequent-items-dropdown-container {
display: flex;
flex-direction: row;
width: 500px;
height: 354px;
height: $grid-size * 40;
&.with-deprecated-styles {
width: 500px;
height: 354px;
.section-header,
.frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
.search-icon {
position: absolute;
top: 13px;
right: 25px;
color: $gray-300;
}
}
@include media-breakpoint-down(xs) {
flex-direction: column;
width: 100%;
height: auto;
flex: 1;
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
width: 100%;
}
.frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
.frequent-items-list-container {
width: auto;
height: auto;
padding-bottom: 0;
}
}
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
@ -861,26 +905,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: 70%;
}
@include media-breakpoint-down(xs) {
flex-direction: column;
width: 100%;
height: auto;
flex: 1;
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
width: 100%;
}
.frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
.section-header,
.frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
@ -898,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
.search-icon {
position: absolute;
top: 13px;
right: 25px;
color: $gray-300;
}
}
.section-header {
font-weight: 700;
margin-top: 8px;
}
@include media-breakpoint-down(xs) {
.frequent-items-list-container {
width: auto;
height: auto;
padding-bottom: 0;
}
}
}
.frequent-items-list-item-container {
.frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
float: left;
flex-shrink: 0;
}
.frequent-items-item-metadata-container {

View File

@ -1,3 +1,5 @@
$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
.navbar-gitlab {
padding: 0 16px;
z-index: $header-zindex;
@ -254,6 +256,7 @@
}
}
.top-nav-toggle,
> button {
background: transparent;
border: 0;
@ -629,3 +632,36 @@
}
}
}
.top-nav-container-view {
.gl-new-dropdown & .gl-search-box-by-type {
@include gl-m-0;
}
.frequent-items-list-item-container > a:hover {
background-color: $top-nav-hover-bg;
}
}
.top-nav-toggle {
.dropdown-icon {
@include gl-mr-3;
}
.dropdown-chevron {
top: 0;
}
}
.top-nav-menu-item {
color: var(--indigo-900, $theme-indigo-900) !important;
&.active,
&:hover {
background-color: $top-nav-hover-bg;
}
.gl-icon {
color: inherit !important;
}
}

View File

@ -283,6 +283,8 @@ $indigo-700: #4b4ba3;
$indigo-800: #393982;
$indigo-900: #292961;
$indigo-950: #1a1a40;
// To do this variant right for darkmode, we need to create a variable for it.
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$theme-blue-50: #f4f8fc;
$theme-blue-100: #e6edf5;

View File

@ -70,6 +70,7 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0;
$indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$gray-lightest: #222;
$gray-light: $gray-50;
@ -160,6 +161,7 @@ body.gl-dark {
--indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950};
--indigo-900-alpha-008: #{$indigo-900-alpha-008};
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};

View File

@ -189,3 +189,21 @@ $gl-line-height-42: px-to-rem(42px);
.gl-line-height-42 {
line-height: $gl-line-height-42;
}
.gl-w-grid-size-30 {
width: $grid-size * 30;
}
.gl-w-grid-size-40 {
width: $grid-size * 40;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
.gl-max-w-none\! {
max-width: none !important;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
.gl-max-h-none\! {
max-height: none !important;
}

View File

@ -27,7 +27,9 @@ module Boards
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write?
if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
Issue.move_nulls_to_end(issues)
end
render_issues(issues, list_service.metadata)
end

View File

@ -176,7 +176,11 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def retry
pipeline.retry_failed(current_user)
if Gitlab::Ci::Features.background_pipeline_retry_endpoint?(@project)
::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
else
pipeline.retry_failed(current_user)
end
respond_to do |format|
format.html do

View File

@ -10,7 +10,7 @@ module BoardsHelper
boards_endpoint: @boards_endpoint,
lists_endpoint: board_lists_path(board),
board_id: board.id,
disabled: disabled?.to_s,
disabled: board.disabled_for?(current_user).to_s,
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
@ -105,10 +105,6 @@ module BoardsHelper
can?(current_user, :admin_issue, current_board_parent)
end
def disabled?
!can?(current_user, :create_non_backlog_issues, board)
end
def board_list_data
include_descendant_groups = @group&.present?

View File

@ -9,6 +9,22 @@ module IssuesHelper
classes.join(' ')
end
def issue_manual_ordering_class
is_sorting_by_relative_position = @sort == 'relative_position'
if is_sorting_by_relative_position && !issue_repositioning_disabled?
"manual-ordering"
end
end
def issue_repositioning_disabled?
if @group
@group.root_ancestor.issue_repositioning_disabled?
elsif @project
@project.root_namespace.issue_repositioning_disabled?
end
end
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'

View File

@ -11,16 +11,24 @@ module VersionCheckHelper
def link_to_version
if Gitlab.pre_release?
commit_link = link_to(Gitlab.revision, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', source_code_project, Gitlab.revision))
commit_link = link_to(Gitlab.revision, source_host_url + namespace_project_commits_path(source_code_group, source_code_project, Gitlab.revision))
[Gitlab::VERSION, content_tag(:small, commit_link)].join(' ').html_safe
else
link_to Gitlab::VERSION, Gitlab::COM_URL + namespace_project_tag_path('gitlab-org', source_code_project, "v#{Gitlab::VERSION}")
link_to Gitlab::VERSION, source_host_url + namespace_project_tag_path(source_code_group, source_code_project, "v#{Gitlab::VERSION}")
end
end
def source_host_url
Gitlab::COM_URL
end
def source_code_group
'gitlab-org'
end
def source_code_project
'gitlab-foss'
end
end
VersionCheckHelper.prepend_mod_with('VersionCheckHelper')
VersionCheckHelper.prepend_mod

View File

@ -45,6 +45,12 @@ class Board < ApplicationRecord
def to_type
self.class.to_type
end
def disabled_for?(current_user)
namespace = group_board? ? resource_parent.root_ancestor : resource_parent.root_namespace
namespace.issue_repositioning_disabled? || !Ability.allowed?(current_user, :create_non_backlog_issues, self)
end
end
Board.prepend_mod_with('Board')

View File

@ -23,6 +23,7 @@ module Enums
user_blocked: 14,
project_deleted: 15,
ci_quota_exceeded: 16,
pipeline_loop_detected: 17,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,

View File

@ -79,6 +79,8 @@ module RelativePositioning
objects = objects.reject(&:relative_position)
return 0 if objects.empty?
objects.first.check_repositioning_allowed!
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
representative = RelativePositioning.mover.context(objects.first)
@ -123,6 +125,12 @@ module RelativePositioning
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
end
# To be overriden on child classes whenever
# blocking position updates is necessary.
def check_repositioning_allowed!
nil
end
def move_between(before, after)
before, after = [before, after].sort_by(&:relative_position) if before && after

View File

@ -272,6 +272,18 @@ class Issue < ApplicationRecord
"id DESC")
end
# Temporary disable moving null elements because of performance problems
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
def check_repositioning_allowed!
if blocked_for_repositioning?
raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled."
end
end
def blocked_for_repositioning?
resource_parent.root_namespace&.issue_repositioning_disabled?
end
def hook_attrs
Gitlab::HookData::IssueBuilder.new(self).build
end

View File

@ -37,6 +37,7 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
'Ci::CompareMetricsReportsService' => ->(project) { ::Gitlab::Ci::Features.merge_base_pipeline_for_metrics_comparison?(project) },
'Ci::CompareCodequalityReportsService' => ->(project) { true }
}.freeze

View File

@ -425,6 +425,10 @@ class Namespace < ApplicationRecord
created_at >= 90.days.ago
end
def issue_repositioning_disabled?
Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml)
end
private
def expire_child_caches

View File

@ -15,6 +15,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator',
data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator',
forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run',
pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines',
invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid',
downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found',
insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline',

View File

@ -85,6 +85,12 @@ module Ci
return false
end
if has_cyclic_dependency?
@bridge.drop!(:pipeline_loop_detected)
return false
end
true
end
@ -109,11 +115,24 @@ module Ci
end
end
def has_cyclic_dependency?
return false if @bridge.triggers_child_pipeline?
if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml)
checksums = @bridge.pipeline.base_and_ancestors.map { |pipeline| config_checksum(pipeline) }
checksums.uniq.length != checksums.length
end
end
def has_max_descendants_depth?
return false unless @bridge.triggers_child_pipeline?
ancestors_of_new_child = @bridge.pipeline.base_and_ancestors(same_project: true)
ancestors_of_new_child.count > MAX_DESCENDANTS_DEPTH
end
def config_checksum(pipeline)
[pipeline.project_id, pipeline.ref].hash
end
end
end

View File

@ -100,6 +100,8 @@ module Issues
end
def handle_move_between_ids(issue)
issue.check_repositioning_allowed! if params[:move_between_ids]
super
rebalance_if_needed(issue)

View File

@ -73,3 +73,5 @@ class SubmitUsagePingService
end
end
end
SubmitUsagePingService.prepend_mod

View File

@ -1 +1,3 @@
= render 'shared/alerts/positioning_disabled'
= render "shared/boards/show", board: @board, group: true

View File

@ -20,7 +20,7 @@
= _('Next')
- if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
= render "layouts/nav/combined_menu"
= render "layouts/nav/top_nav"
- else
- if current_user
= render "layouts/nav/dashboard"

View File

@ -1,3 +0,0 @@
%button{ type: 'button', data: { toggle: "dropdown" } }
= sprite_icon('ellipsis_v')
= _('Projects')

View File

@ -0,0 +1,7 @@
- view_model = top_nav_view_model(project: @project, group: @group)
%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } }
%li
%a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } }
= sprite_icon('dot-grid', css_class: "dropdown-icon")
= view_model[:activeTitle]
= sprite_icon('chevron-down')

View File

@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted?
.frequent-items-dropdown-container
.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/groups#index') do

View File

@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
.frequent-items-dropdown-container
.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do

View File

@ -1,4 +1,5 @@
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
= render 'shared/alerts/positioning_disabled'
- if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
@ -15,7 +16,7 @@
'scoped-labels-available': scoped_labels_available?(@project).to_json } }
- else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
%ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
= render empty_state_path

View File

@ -1,5 +1,7 @@
= render 'shared/alerts/positioning_disabled'
- if @issues.to_a.any?
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } }
%ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } }
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else

View File

@ -0,0 +1,2 @@
- if issue_repositioning_disabled?
= render 'shared/alert_info', body: _('Issues manual ordering is temporarily disabled for technical reasons.')

View File

@ -7,6 +7,8 @@
- breadcrumb_title _("Epic Boards")
- else
- breadcrumb_title _("Issue Boards")
= render 'shared/alerts/positioning_disabled'
- page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards'

View File

@ -1515,6 +1515,15 @@
:weight: 3
:idempotent:
:tags: []
- :name: pipeline_default:ci_retry_pipeline
:worker_name: Ci::RetryPipelineWorker
:feature_category: :continuous_integration
:has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
:idempotent:
:tags: []
- :name: pipeline_default:pipeline_metrics
:worker_name: PipelineMetricsWorker
:feature_category: :continuous_integration

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Ci
class RetryPipelineWorker # rubocop:disable Scalability/IdempotentWorker
include ::ApplicationWorker
include ::PipelineQueue
urgency :high
worker_resource_boundary :cpu
def perform(pipeline_id, user_id)
::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
::User.find_by_id(user_id).try do |user|
pipeline.retry_failed(user)
end
end
end
end
end

View File

@ -20,6 +20,10 @@ class IssuePlacementWorker
issue = find_issue(issue_id, project_id)
return unless issue
# Temporary disable moving null elements because of performance problems
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
return if issue.blocked_for_repositioning?
# Move the oldest 100 unpositioned items to the end.
# This is to deal with out-of-order execution of the worker,
# while preserving creation order.

View File

@ -14,6 +14,11 @@ class IssueRebalancingWorker
return if project_id.nil?
project = Project.find(project_id)
# Temporary disable reabalancing for performance reasons
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
return if project.root_namespace&.issue_repositioning_disabled?
# All issues are equivalent as far as we are concerned
issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord

View File

@ -14,7 +14,8 @@ module SshKeys
def perform
return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
User.with_ssh_key_expired_today.find_each do |user|
# rubocop:disable CodeReuse/ActiveRecord
User.with_ssh_key_expired_today.find_each(batch_size: 10_000) do |user|
with_context(user: user) do
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired ssh key(s)"
@ -22,6 +23,7 @@ module SshKeys
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute
end
# rubocop:enable CodeReuse/ActiveRecord
end
end
end

View File

@ -14,7 +14,8 @@ module SshKeys
def perform
return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
User.with_ssh_key_expiring_soon.find_each do |user|
# rubocop:disable CodeReuse/ActiveRecord
User.with_ssh_key_expiring_soon.find_each(batch_size: 10_000) do |user|
with_context(user: user) do
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring soon ssh key(s)"
@ -23,6 +24,7 @@ module SshKeys
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: true }).execute
end
end
# rubocop:enable CodeReuse/ActiveRecord
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Optimize merge request permission check for references
merge_request: 61591
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Implement wildcard support for pipeline include file paths
merge_request: 61507
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Make find_remote_root_refs_inmemory feature flag enabled by default
merge_request: 61824
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Allow migrating scheduled and retried Sidekiq jobs to new queues
merge_request: 60724
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Make pipeline retry endpoint async.
merge_request: 61270
author:
type: changed

View File

@ -56,8 +56,9 @@ module Gitlab
config.generators.templates.push("#{config.root}/generator_templates")
foss_eager_load_paths = config.eager_load_paths.dup.freeze
load_paths = lambda do |dir:|
ext_paths = config.eager_load_paths.each_with_object([]) do |path, memo|
ext_paths = foss_eager_load_paths.each_with_object([]) do |path, memo|
ext_path = config.root.join(dir, Pathname.new(path).relative_path_from(config.root))
memo << ext_path.to_s
end

View File

@ -0,0 +1,8 @@
---
name: background_pipeline_retry_endpoint
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61270
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330915
milestone: '13.12'
type: development
group: group::pipeline authoring
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: ci_drop_cyclical_triggered_pipelines
introduced_by_url: https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1195
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329390
milestone: '13.12'
type: development
group: group::continuous integration
default_enabled: true

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327315
milestone: '13.11'
type: development
group: group::pipeline authoring
default_enabled: false
default_enabled: true

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329664
milestone: '13.12'
type: development
group: group::gitaly
default_enabled: false
default_enabled: true

View File

@ -0,0 +1,8 @@
---
name: merge_base_pipeline_for_metrics_comparison
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61282
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330809
milestone: '13.12'
type: development
group: group::testing
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: block_issue_repositioning
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60141
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329663
milestone: '13.12'
type: ops
group: group::project management
default_enabled: false

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 28d
data_source: database
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersCreatingIssuesMetric'
instrumentation_class: CountUsersCreatingIssuesMetric
distribution:
- ce
- ee

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 28d
data_source: redis_hll
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersUsingApproveQuickActionMetric'
instrumentation_class: CountUsersUsingApproveQuickActionMetric
distribution:
- ce
- ee

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 7d
data_source: redis_hll
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersUsingApproveQuickActionMetric'
instrumentation_class: CountUsersUsingApproveQuickActionMetric
distribution:
- ce
- ee

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountIssuesMetric'
instrumentation_class: CountIssuesMetric
distribution:
- ce
- ee

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersCreatingIssuesMetric'
instrumentation_class: CountUsersCreatingIssuesMetric
distribution:
- ce
- ee

View File

@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountBoardsMetric'
instrumentation_class: CountBoardsMetric
distribution:
- ce
- ee

View File

@ -11,7 +11,7 @@ milestone: "9.1"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521
time_frame: none
data_source: database
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::UuidMetric'
instrumentation_class: UuidMetric
distribution:
- ee
- ce

View File

@ -9,7 +9,7 @@ value_type: string
status: data_available
time_frame: none
data_source: system
instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::HostnameMetric'
instrumentation_class: HostnameMetric
distribution:
- ce
- ee

View File

@ -56,7 +56,7 @@
},
"instrumentation_class": {
"type": "string",
"pattern": "^(Gitlab::Usage::Metrics::Instrumentations::)(([A-Z][a-z]+)+::)*(([A-Z][a-z]+)+)$"
"pattern": "^(([A-Z][a-z]+)+::)*(([A-Z][a-z]+)+)$"
},
"distribution": {
"type": "array",

View File

@ -482,10 +482,15 @@ Use local includes instead of symbolic links.
##### `include:local` with wildcard file paths
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/25921) in GitLab 13.11.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it. **(CORE ONLY)**
> - [Deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/327315) in GitLab 13.12.
> - Enabled on GitLab.com.
> - Recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to disable it. **(CORE ONLY)**
There can be
[risks when disabling released features](../../user/feature_flags.md#risks-when-disabling-released-features).
Refer to this feature's version history for more details.
You can use wildcard paths (`*` and `**`) with `include:local`.
@ -509,10 +514,10 @@ When the pipeline runs, GitLab:
include: 'configs/**/*.yml'
```
The wildcard file paths feature is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
The wildcard file paths feature is under development but ready for production use.
It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can enable it.
can opt to disable it.
To enable it:

View File

@ -45,6 +45,30 @@ ENTITY_TITLE
You can [disable comments](#disable-comments-on-jira-issues) on issues.
### Require associated Jira issue for merge requests to be merged
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/280766) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.12 behind a feature flag, disabled by default.
> - [Deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - Disabled on GitLab.com.
> - Not recommended for production use.
> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-the-ability-to-require-an-associated-jira-issue-on-merge-requests). **(ULTIMATE SELF)**
This in-development feature might not be available for your use. There can be
[risks when enabling features still in development](../../user/application_security/index.md#security-approvals-in-merge-requests).
Refer to this feature's version history for more details.
You can prevent merge requests from being merged if they do not refer to a Jira issue.
To enforce this:
1. Navigate to your project's **Settings > General** page.
1. Expand the **Merge requests** section.
1. Under **Merge checks**, select the **Require an associated issue from Jira** check box.
1. Select **Save** for the changes to take effect.
After you enable this feature, a merge request that doesn't reference an associated
Jira issue can't be merged. The merge request displays the message
**To merge, a Jira issue key must be mentioned in the title or description.**
## Close Jira issues in GitLab
If you have configured GitLab transition IDs, you can close a Jira issue directly
@ -160,3 +184,22 @@ adding a comment to the Jira issue:
1. Refer to the [Configure GitLab](development_panel.md#configure-gitlab) instructions.
1. Clear the **Enable comments** check box.
## Enable or disable the ability to require an associated Jira issue on merge requests
The ability to require an associated Jira issue on merge requests is under development
and not ready for production use. It is deployed behind a feature flag that is
**disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) can enable it.
To enable it:
```ruby
Feature.enable(:jira_issue_association_on_merge_request)
```
To disable it:
```ruby
Feature.disable(:jira_issue_association_on_merge_request)
```

View File

@ -41,7 +41,8 @@ The following Rake tasks are available for use with GitLab:
| [Praefect Rake tasks](../administration/raketasks/praefect.md) | [Praefect](../administration/gitaly/praefect.md)-related tasks. |
| [Project import/export](../administration/raketasks/project_import_export.md) | Prepare for [project exports and imports](../user/project/settings/import_export.md). |
| [Sample Prometheus data](generate_sample_prometheus_data.md) | Generate sample Prometheus data. |
| [SPDX license list import](spdx.md) | Import a local copy of the [SPDX license list](https://spdx.org/licenses/) for matching [License Compliance policies](../user/compliance/license_compliance/index.md). | |
| [Sidekiq job migration](sidekiq_job_migration.md) | Migrate Sidekiq jobs scheduled for future dates to a new queue. |
| [SPDX license list import](spdx.md) | Import a local copy of the [SPDX license list](https://spdx.org/licenses/) for matching [License Compliance policies](../user/compliance/license_compliance/index.md). |
| [Repository storage](../administration/raketasks/storage.md) | List and migrate existing projects and attachments from legacy storage to hashed storage. |
| [Uploads migrate](../administration/raketasks/uploads/migrate.md) | Migrate uploads between local storage and object storage. |
| [Uploads sanitize](../administration/raketasks/uploads/sanitize.md) | Remove EXIF data from images uploaded to earlier versions of GitLab. |

View File

@ -0,0 +1,40 @@
---
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
---
# Sidekiq job migration **(FREE SELF)**
WARNING:
This operation should be very uncommon. We do not recommend it for the vast majority of GitLab instances.
Sidekiq routing rules allow administrators to re-route certain background jobs from their regular queue to an alternative queue. By default, GitLab uses one queue per background job type. GitLab has over 400 background job types, and so correspondingly it has over 400 queues.
Most administrators will not need to change this setting. In some cases with particularly large background job processing workloads, Redis performance may suffer due to the number of queues that GitLab listens to.
If the Sidekiq routing rules are changed, administrators need to take care with the migration to avoid losing jobs entirely. The basic migration steps are:
1. Listen to both the old and new queues.
1. Update the routing rules.
1. Wait until there are no publishers dispatching jobs to the old queues.
1. Run the [Rake tasks for future jobs](#future-jobs).
1. Wait for the old queues to be empty.
1. Stop listening to the old queues.
## Future jobs
Step 4 involves rewriting some Sidekiq job data for jobs that are already stored in Redis, but due to run in future. There are two sets of jobs to run in future: scheduled jobs and jobs to be retried. We provide a separate Rake task to migrate each set:
- `gitlab:sidekiq:migrate_jobs:retry` for jobs to be retried.
- `gitlab:sidekiq:migrate_jobs:scheduled` for scheduled jobs.
Most of the time, running both at the same time is the correct choice. There are two separate tasks to allow for more fine-grained control where needed. To run both at once:
```shell
# omnibus-gitlab
sudo gitlab-rake gitlab:sidekiq:migrate_jobs:retry gitlab:sidekiq:migrate_jobs:schedule
# source installations
bundle exec rake gitlab:sidekiq:migrate_jobs:retry gitlab:sidekiq:migrate_jobs:schedule RAILS_ENV=production
```

View File

@ -204,6 +204,13 @@ To make an epic confidential:
This section collects instructions for all the things you can do with [issues](../../project/issues/index.md)
in relation to epics.
### View count of issues in an epic
On the **Epics and Issues** tab, under each epic name, hover over the total counts.
The number indicates all epics associated with the project, including issues
you might not have permission to.
### Add a new issue to an epic
You can add an existing issue to an epic, or create a new issue that's
@ -231,13 +238,6 @@ To add a new issue to an epic:
If there are multiple issues to be added, press <kbd>Space</kbd> and repeat this step.
1. Select **Add**.
#### View count of issues in an epic
On the **Epics and Issues** tab, under each epic name, hover over the total counts.
The number indicates all epics associated with the project, including issues
you might not have permission to.
#### Create an issue from an epic
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5419) in GitLab 12.7.

View File

@ -208,7 +208,7 @@ The repository graph displays the history of the repository network visually, in
Find it under your project's **Repository > Graph**.
## Repository Languages
## Repository languages
For the default branch of each repository, GitLab determines what programming languages
were used and displays this on the project's pages. If this information is missing, it's
@ -268,7 +268,7 @@ All projects can be cloned into Visual Studio Code. To do that:
When VS Code has successfully cloned your project, it opens the folder.
## Download Source Code
## Download source code
> - Support for directory download was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/24704) in GitLab 11.11.
> - Support for [including Git LFS blobs](../../../topics/git/lfs#lfs-objects-in-project-archives) was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15079) in GitLab 13.5.

View File

@ -47,6 +47,8 @@ Note the following:
- Imported users can be mapped by their primary email on self-managed instances, if an administrative user (not an owner) does the import.
Otherwise, a supplementary comment is left to mention that the original author and
the MRs, notes, or issues are owned by the importer.
- For project migration imports performed over GitLab.com Groups, preserving author information is
possible through a [professional services engagement](https://about.gitlab.com/services/migration/).
- If an imported project contains merge requests originating from forks,
then new branches associated with such merge requests are created
within a project during the import/export. Thus, the number of branches

View File

@ -40,7 +40,7 @@ The project description also partially supports [standard Markdown](../../markdo
You can create a framework label to identify that your project has certain compliance requirements or needs additional oversight.
Group owners can create, edit and delete compliance frameworks by going to **Settings** > **General** and expanding the **Compliance frameworks** section.
Compliance frameworks created can then be assigned to any number of projects via the project settings page inside the group or subgroups.
Compliance frameworks created can then be assigned to any number of projects via the project settings page inside the group or subgroups.
NOTE:
Attempting to create compliance frameworks on subgroups via GraphQL will cause the framework to be created on the root ancestor if the user has the correct permissions.
@ -193,8 +193,9 @@ Set up your project's merge request settings:
- Enable [merge request approvals](../merge_requests/approvals/index.md).
- Enable [merge only if pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md).
- Enable [merge only when all threads are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved).
- Enable [`delete source branch after merge` option by default](../merge_requests/getting_started.md#deleting-the-source-branch)
- Configure [suggested changes commit messages](../merge_requests/reviews/suggestions.md#configure-the-commit-message-for-applied-suggestions)
- Enable [require an associated issue from Jira](../../../integration/jira/issues.md#require-associated-jira-issue-for-merge-requests-to-be-merged).
- Enable [`delete source branch after merge` option by default](../merge_requests/getting_started.md#deleting-the-source-branch).
- Configure [suggested changes commit messages](../merge_requests/reviews/suggestions.md#configure-the-commit-message-for-applied-suggestions).
- Configure [the default target project](../merge_requests/creating_merge_requests.md#set-the-default-target-project) for merge requests coming from forks.
### Service Desk

View File

@ -3,6 +3,8 @@
module Banzai
module ReferenceParser
class MergeRequestParser < IssuableParser
include Gitlab::Utils::StrongMemoize
self.reference_type = :merge_request
def records_for_nodes(nodes)
@ -27,6 +29,16 @@ module Banzai
self.class.data_attribute
)
end
def can_read_reference?(user, merge_request)
memo = strong_memoize(:can_read_reference) { {} }
project_id = merge_request.project_id
return memo[project_id] if memo.key?(project_id)
memo[project_id] = can?(user, :read_merge_request_iid, merge_request.project)
end
end
end
end

View File

@ -18,6 +18,10 @@ module Gitlab
Feature.enabled?(:ci_pipeline_status_omit_commit_sha_in_cache_key, project, default_enabled: true)
end
def self.merge_base_pipeline_for_metrics_comparison?(project)
Feature.enabled?(:merge_base_pipeline_for_metrics_comparison, project, default_enabled: :yaml)
end
# Remove in https://gitlab.com/gitlab-org/gitlab/-/issues/224199
def self.store_pipeline_messages?(project)
::Feature.enabled?(:ci_store_pipeline_messages, project, default_enabled: true)
@ -54,6 +58,10 @@ module Gitlab
def self.gldropdown_tags_enabled?
::Feature.enabled?(:gldropdown_tags, default_enabled: :yaml)
end
def self.background_pipeline_retry_endpoint?(project)
::Feature.enabled?(:background_pipeline_retry_endpoint, project)
end
end
end
end

View File

@ -20,6 +20,7 @@ module Gitlab
scheduler_failure: 'scheduler failure',
data_integrity_failure: 'data integrity failure',
forward_deployment_failure: 'forward deployment failure',
pipeline_loop_detected: 'job would create infinitely looping pipelines',
invalid_bridge_trigger: 'downstream pipeline trigger definition is invalid',
downstream_bridge_project_not_found: 'downstream project could not be found',
insufficient_bridge_permissions: 'no permissions to trigger downstream pipeline',

View File

@ -15,6 +15,7 @@ module Gitlab
NoSpaceLeft = Class.new(StandardError)
InvalidPosition = Class.new(StandardError)
IllegalRange = Class.new(ArgumentError)
IssuePositioningDisabled = Class.new(StandardError)
def self.range(lhs, rhs)
if lhs && rhs

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Gitlab
class SidekiqMigrateJobs
LOG_FREQUENCY = 1_000
attr_reader :sidekiq_set, :logger
def initialize(sidekiq_set, logger: nil)
@sidekiq_set = sidekiq_set
@logger = logger
end
# mappings is a hash of WorkerClassName => target_queue_name
def execute(mappings)
source_queues_regex = Regexp.union(mappings.keys)
cursor = 0
scanned = 0
migrated = 0
estimated_size = Sidekiq.redis { |c| c.zcard(sidekiq_set) }
logger&.info("Processing #{sidekiq_set} set. Estimated size: #{estimated_size}.")
begin
cursor, jobs = Sidekiq.redis { |c| c.zscan(sidekiq_set, cursor) }
jobs.each do |(job, score)|
if scanned > 0 && scanned % LOG_FREQUENCY == 0
logger&.info("In progress. Scanned records: #{scanned}. Migrated records: #{migrated}.")
end
scanned += 1
next unless job.match?(source_queues_regex)
job_hash = Sidekiq.load_json(job)
destination_queue = mappings[job_hash['class']]
next unless mappings.has_key?(job_hash['class'])
next if job_hash['queue'] == destination_queue
job_hash['queue'] = destination_queue
migrated += migrate_job(job, score, job_hash)
end
end while cursor.to_i != 0
logger&.info("Done. Scanned records: #{scanned}. Migrated records: #{migrated}.")
{
scanned: scanned,
migrated: migrated
}
end
private
def migrate_job(job, score, job_hash)
Sidekiq.redis do |connection|
removed = connection.zrem(sidekiq_set, job)
if removed
connection.zadd(sidekiq_set, score, Sidekiq.dump_json(job_hash))
1
else
0
end
end
end
end
end

View File

@ -9,7 +9,7 @@ module Gitlab
instrumentation_class = definition.attributes[:instrumentation_class]
if instrumentation_class.present?
metric_value = instrumentation_class.constantize.new(time_frame: definition.attributes[:time_frame]).value
metric_value = "Gitlab::Usage::Metrics::Instrumentations::#{instrumentation_class}".constantize.new(time_frame: definition.attributes[:time_frame]).value
metric_payload(definition.key_path, metric_value)
else

View File

@ -8,6 +8,29 @@ namespace :gitlab do
File.write(path, banner + YAML.dump(object).gsub(/ *$/m, ''))
end
namespace :migrate_jobs do
def mappings
::Gitlab::SidekiqConfig
.workers
.reject { |worker| worker.klass.is_a?(Gitlab::SidekiqConfig::DummyWorker) }
.to_h { |worker| [worker.klass.to_s, ::Gitlab::SidekiqConfig::WorkerRouter.global.route(worker.klass)] }
end
desc 'GitLab | Sidekiq | Migrate jobs in the scheduled set to new queue names'
task schedule: :environment do
::Gitlab::SidekiqMigrateJobs
.new('schedule', logger: Logger.new($stdout))
.execute(mappings)
end
desc 'GitLab | Sidekiq | Migrate jobs in the retry set to new queue names'
task retry: :environment do
::Gitlab::SidekiqMigrateJobs
.new('retry', logger: Logger.new($stdout))
.execute(mappings)
end
end
namespace :all_queues_yml do
desc 'GitLab | Sidekiq | Generate all_queues.yml based on worker definitions'
task generate: :environment do

View File

@ -12,10 +12,12 @@ class VersionCheck
def self.url
encoded_data = Base64.urlsafe_encode64(data.to_json)
"#{host}?gitlab_info=#{encoded_data}"
"#{host}/check.svg?gitlab_info=#{encoded_data}"
end
def self.host
'https://version.gitlab.com/check.svg'
'https://version.gitlab.com'
end
end
VersionCheck.prepend_mod

View File

@ -11342,9 +11342,6 @@ msgstr ""
msgid "DevopsAdoption|Adopted"
msgstr ""
msgid "DevopsAdoption|Adoption"
msgstr ""
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgstr ""
@ -11381,6 +11378,9 @@ msgstr ""
msgid "DevopsAdoption|Deploys"
msgstr ""
msgid "DevopsAdoption|Dev"
msgstr ""
msgid "DevopsAdoption|DevOps adoption tracks the use of key features across your favorite groups. Add a group to the table to begin."
msgstr ""
@ -11408,6 +11408,9 @@ msgstr ""
msgid "DevopsAdoption|Not adopted"
msgstr ""
msgid "DevopsAdoption|Ops"
msgstr ""
msgid "DevopsAdoption|Pipelines"
msgstr ""
@ -11429,6 +11432,9 @@ msgstr ""
msgid "DevopsAdoption|Scanning"
msgstr ""
msgid "DevopsAdoption|Sec"
msgstr ""
msgid "DevopsAdoption|There was an error enabling the current group. Please refresh the page."
msgstr ""
@ -11441,9 +11447,6 @@ msgstr ""
msgid "DevopsAdoption|You cannot remove the group you are currently in."
msgstr ""
msgid "DevopsReport|Adoption"
msgstr ""
msgid "DevopsReport|DevOps Score"
msgstr ""
@ -18359,6 +18362,9 @@ msgstr ""
msgid "Issues closed"
msgstr ""
msgid "Issues manual ordering is temporarily disabled for technical reasons."
msgstr ""
msgid "Issues must match this scope to appear in this list."
msgstr ""
@ -34130,6 +34136,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr ""
msgid "TopNav|Switch to..."
msgstr ""
msgid "Topics (optional)"
msgstr ""

View File

@ -108,10 +108,6 @@ module QA
element :file_to_commit_content
end
view 'app/assets/javascripts/editor/extensions/editor_lite_extension_base.js' do
element :line_link
end
def has_file?(file_name)
within_element(:file_list) do
has_element?(:file_name_content, file_name: file_name)
@ -319,11 +315,15 @@ module QA
end
def link_line(line_number)
previous_url = page.current_url
wait_for_animated_element(:editor_container)
within_element(:editor_container) do
find('.line-numbers', text: line_number).hover
find_element(:line_link, number: "#L#{line_number}")['href'].to_s
find('.line-numbers', text: line_number).hover.click
end
wait_until(max_duration: 5, reload: false) do
page.current_url != previous_url
end
page.current_url.to_s
end
end
end

View File

@ -2,7 +2,7 @@
module QA
RSpec.describe 'Package' do
describe 'Container Registry', only: { subdomain: %i[staging pre] } do
describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] } do
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-registry'

View File

@ -9,12 +9,6 @@ RSpec.describe Admin::DevOpsReportController do
end
end
describe 'should_track_devops_score?' do
it 'is always true' do
expect(controller.should_track_devops_score?).to be_truthy
end
end
describe 'GET #show' do
context 'as admin' do
let(:user) { create(:admin) }
@ -31,6 +25,8 @@ RSpec.describe Admin::DevOpsReportController do
it_behaves_like 'tracking unique visits', :show do
let(:target_id) { 'i_analytics_dev_ops_score' }
let(:request_params) { { tab: 'devops-score' } }
end
end
end

View File

@ -49,6 +49,7 @@ RSpec.describe Boards::IssuesController do
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project, labels: [development], assignees: [johndoe])
issue.subscribe(johndoe, project)
expect(Issue).to receive(:move_nulls_to_end)
list_issues user: user, board: board, list: list2
@ -119,6 +120,18 @@ RSpec.describe Boards::IssuesController do
expect(query_count).to eq(1)
end
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: true)
end
it 'does not reposition issues with null position' do
expect(Issue).not_to receive(:move_nulls_to_end)
list_issues(user: user, board: group_board, list: list3)
end
end
end
context 'with invalid list id' do

View File

@ -853,10 +853,7 @@ RSpec.describe Projects::PipelinesController do
end
describe 'POST retry.json' do
let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
before do
subject(:post_retry) do
post :retry, params: {
namespace_id: project.namespace,
project_id: project,
@ -865,15 +862,41 @@ RSpec.describe Projects::PipelinesController do
format: :json
end
it 'retries a pipeline without returning any content' do
let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
let(:worker_spy) { class_spy(::Ci::RetryPipelineWorker) }
before do
stub_const('::Ci::RetryPipelineWorker', worker_spy)
end
it 'retries a pipeline in the background without returning any content' do
post_retry
expect(response).to have_gitlab_http_status(:no_content)
expect(build.reload).to be_retried
expect(::Ci::RetryPipelineWorker).to have_received(:perform_async).with(pipeline.id, user.id)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(background_pipeline_retry_endpoint: false)
end
it 'retries the pipeline without returning any content' do
post_retry
expect(response).to have_gitlab_http_status(:no_content)
expect(build.reload).to be_retried
end
end
context 'when builds are disabled' do
let(:feature) { ProjectFeature::DISABLED }
it 'fails to retry pipeline' do
post_retry
expect(response).to have_gitlab_http_status(:not_found)
end
end

View File

@ -20,8 +20,6 @@ RSpec.describe 'Admin mode' do
context 'when not in admin mode' do
it 'has no leave admin mode button' do
pending_on_combined_menu_flag
visit new_admin_session_path
page.within('.navbar-sub-nav') do
@ -180,8 +178,6 @@ RSpec.describe 'Admin mode' do
end
it 'shows no admin mode buttons in navbar' do
pending_on_combined_menu_flag
visit admin_root_path
page.within('.navbar-sub-nav') do

View File

@ -0,0 +1,68 @@
import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import TopNavApp from '~/nav/components/top_nav_app.vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
import { TEST_NAV_DATA } from '../mock_data';
describe('~/nav/components/top_nav_app.vue', () => {
let wrapper;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TopNavApp, {
propsData: {
navData: TEST_NAV_DATA,
},
});
};
const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown);
const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
const findTooltip = () => wrapper.findComponent(GlTooltip);
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders nav item dropdown', () => {
expect(findNavItemDropdown().attributes('href')).toBeUndefined();
expect(findNavItemDropdown().attributes()).toMatchObject({
icon: 'dot-grid',
text: TEST_NAV_DATA.activeTitle,
'no-flip': '',
});
});
it('renders top nav dropdown menu', () => {
expect(findMenu().props()).toStrictEqual({
primary: TEST_NAV_DATA.primary,
secondary: TEST_NAV_DATA.secondary,
views: TEST_NAV_DATA.views,
});
});
it('renders tooltip', () => {
expect(findTooltip().attributes()).toMatchObject({
'boundary-padding': '0',
placement: 'right',
title: TopNavApp.TOOLTIP,
});
});
});
describe('when full mounted', () => {
beforeEach(() => {
createComponent(mount);
});
it('has dropdown toggle as tooltip target', () => {
const targetFn = findTooltip().props('target');
expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element);
});
});
});

View File

@ -0,0 +1,114 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import FrequentItemsApp from '~/frequent_items/components/app.vue';
import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants';
import eventHub from '~/frequent_items/event_hub';
import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import { TEST_NAV_DATA } from '../mock_data';
const DEFAULT_PROPS = {
frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace,
frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
linksPrimary: TEST_NAV_DATA.primary,
linksSecondary: TEST_NAV_DATA.secondary,
};
const TEST_OTHER_PROPS = {
namespace: 'projects',
currentUserName: '',
currentItem: {},
};
describe('~/nav/components/top_nav_container_view.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavContainerView, {
propsData: {
...DEFAULT_PROPS,
...TEST_OTHER_PROPS,
...props,
},
});
};
const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem);
const findMenuItemsModel = (parent = wrapper) =>
findMenuItems(parent).wrappers.map((x) => x.props());
const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel);
const findFrequentItemsApp = () => {
const parent = wrapper.findComponent(VuexModuleProvider);
return {
vuexModule: parent.props('vuexModule'),
props: parent.findComponent(FrequentItemsApp).props(),
};
};
afterEach(() => {
wrapper.destroy();
});
it.each(['projects', 'groups'])(
'emits frequent items event to event hub (%s)',
async (frequentItemsDropdownType) => {
const listener = jest.fn();
eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener);
createComponent({ frequentItemsDropdownType });
expect(listener).not.toHaveBeenCalled();
await nextTick();
expect(listener).toHaveBeenCalled();
},
);
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders frequent items app', () => {
expect(findFrequentItemsApp()).toEqual({
vuexModule: DEFAULT_PROPS.frequentItemsVuexModule,
props: TEST_OTHER_PROPS,
});
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })),
]);
});
it('only the first group does not have margin top', () => {
expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]);
});
it('only the first menu item does not have margin top', () => {
const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) =>
x.classes('gl-mt-1'),
);
expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]);
});
});
describe('without secondary links', () => {
beforeEach(() => {
createComponent({
linksSecondary: [],
});
});
it('renders one menu item group', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
]);
});
});
});

View File

@ -0,0 +1,157 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import { TEST_NAV_DATA } from '../mock_data';
const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' ');
describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavDropdownMenu, {
propsData: {
primary: TEST_NAV_DATA.primary,
secondary: TEST_NAV_DATA.secondary,
views: TEST_NAV_DATA.views,
...props,
},
});
};
const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]');
const findMenuItemsModel = (parent = wrapper) =>
findMenuItems(parent).wrappers.map((x) => ({
menuItem: x.props('menuItem'),
isActive: x.classes('active'),
}));
const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
const findMenuItemGroupsModel = () =>
findMenuItemGroups().wrappers.map((x) => ({
classes: x.classes(),
items: findMenuItemsModel(x),
}));
const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]');
const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots);
const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full');
const createItemsGroupModelExpectation = ({
primary = TEST_NAV_DATA.primary,
secondary = TEST_NAV_DATA.secondary,
activeIndex = -1,
} = {}) => [
{
classes: [],
items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })),
},
{
classes: SECONDARY_GROUP_CLASSES,
items: secondary.map((menuItem) => ({ isActive: false, menuItem })),
},
];
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation());
});
it('has full width menu sidebar', () => {
expect(hasFullWidthMenuSidebar()).toBe(true);
});
it('renders hidden subview with no slot key', () => {
const subview = findMenuSubview();
expect(subview.isVisible()).toBe(false);
expect(subview.props()).toEqual({ slotKey: '' });
});
it('the first menu item in a group does not render margin top', () => {
const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) =>
x.classes('gl-mt-1'),
);
expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]);
});
});
describe('with pre-initialized active view', () => {
const primaryWithActive = [
TEST_NAV_DATA.primary[0],
{
...TEST_NAV_DATA.primary[1],
active: true,
},
...TEST_NAV_DATA.primary.slice(2),
];
beforeEach(() => {
createComponent({
primary: primaryWithActive,
});
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }),
);
});
it('does not have full width menu sidebar', () => {
expect(hasFullWidthMenuSidebar()).toBe(false);
});
it('renders visible subview with slot key', () => {
const subview = findMenuSubview();
expect(subview.isVisible()).toBe(true);
expect(subview.props('slotKey')).toBe(primaryWithActive[1].view);
});
it('does not change view if non-view menu item is clicked', async () => {
const secondaryLink = findMenuItems().at(primaryWithActive.length);
// Ensure this doesn't have a view
expect(secondaryLink.props('menuItem').view).toBeUndefined();
secondaryLink.vm.$emit('click');
await nextTick();
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view);
});
describe('when other view menu item is clicked', () => {
let primaryLink;
beforeEach(async () => {
primaryLink = findMenuItems().at(0);
primaryLink.vm.$emit('click');
await nextTick();
});
it('clicked on link with view', () => {
expect(primaryLink.props('menuItem').view).toBeTruthy();
});
it('changes active view', () => {
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view);
});
it('changes active status on menu item', () => {
expect(findMenuItemGroupsModel()).toStrictEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }),
);
});
});
});
});

View File

@ -0,0 +1,74 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
const TEST_MENU_ITEM = {
title: 'Cheeseburger',
icon: 'search',
href: '/pretty/good/burger',
view: 'burger-view',
};
describe('~/nav/components/top_nav_menu_item.vue', () => {
let listener;
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavMenuItem, {
propsData: {
menuItem: TEST_MENU_ITEM,
...props,
},
listeners: {
click: listener,
},
});
};
const findButton = () => wrapper.find(GlButton);
const findButtonIcons = () =>
findButton()
.findAllComponents(GlIcon)
.wrappers.map((x) => x.props('name'));
beforeEach(() => {
listener = jest.fn();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders button href and text', () => {
const button = findButton();
expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href);
expect(button.text()).toBe(TEST_MENU_ITEM.title);
});
it('passes listeners to button', () => {
expect(listener).not.toHaveBeenCalled();
findButton().vm.$emit('click', 'TEST');
expect(listener).toHaveBeenCalledWith('TEST');
});
});
describe.each`
desc | menuItem | expectedIcons
${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']}
${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']}
${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]}
${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]}
`('$desc', ({ menuItem, expectedIcons }) => {
beforeEach(() => {
createComponent({ menuItem });
});
it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => {
expect(findButtonIcons()).toEqual(expectedIcons);
});
});
});

View File

@ -0,0 +1,35 @@
import { range } from 'lodash';
export const TEST_NAV_DATA = {
activeTitle: 'Test Active Title',
primary: [
...['projects', 'groups'].map((view) => ({
id: view,
href: null,
title: view,
view,
})),
...range(0, 2).map((idx) => ({
id: `primary-link-${idx}`,
href: `/path/to/primary/${idx}`,
title: `Title ${idx}`,
})),
],
secondary: range(0, 2).map((idx) => ({
id: `secondary-link-${idx}`,
href: `/path/to/secondary/${idx}`,
title: `SecTitle ${idx}`,
})),
views: {
projects: {
namespace: 'projects',
currentUserName: '',
currentItem: {},
},
groups: {
namespace: 'groups',
currentUserName: '',
currentItem: {},
},
},
};

View File

@ -341,4 +341,65 @@ RSpec.describe IssuesHelper do
end
end
end
describe '#issue_manual_ordering_class' do
context 'when sorting by relative position' do
before do
assign(:sort, 'relative_position')
end
it 'returns manual ordering class' do
expect(helper.issue_manual_ordering_class).to eq("manual-ordering")
end
context 'when manual sorting disabled' do
before do
allow(helper).to receive(:issue_repositioning_disabled?).and_return(true)
end
it 'returns nil' do
expect(helper.issue_manual_ordering_class).to eq(nil)
end
end
end
end
describe '#issue_repositioning_disabled?' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
subject { helper.issue_repositioning_disabled? }
context 'for project' do
before do
assign(:project, project)
end
it { is_expected.to eq(false) }
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it { is_expected.to eq(true) }
end
end
context 'for group' do
before do
assign(:group, group)
end
it { is_expected.to eq(false) }
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it { is_expected.to eq(true) }
end
end
end
end

View File

@ -8,7 +8,7 @@ RSpec.describe Banzai::ReferenceParser::MergeRequestParser do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, source_project: project) }
subject { described_class.new(Banzai::RenderContext.new(merge_request.target_project, user)) }
subject(:parser) { described_class.new(Banzai::RenderContext.new(merge_request.target_project, user)) }
let(:link) { empty_html_link }
@ -65,4 +65,49 @@ RSpec.describe Banzai::ReferenceParser::MergeRequestParser do
it_behaves_like 'no N+1 queries'
end
describe '#can_read_reference?' do
subject { parser.can_read_reference?(user, merge_request) }
it { is_expected.to be_truthy }
context 'when merge request belongs to the private project' do
let(:project) { create(:project, :private) }
it 'prevents user from reading merge request references' do
is_expected.to be_falsey
end
context 'when user has access to the project' do
before do
project.add_developer(user)
end
it { is_expected.to be_truthy }
end
end
context 'with memoization' do
context 'when project is the same' do
it 'calls #can? only once' do
expect(parser).to receive(:can?).once
2.times { parser.can_read_reference?(user, merge_request) }
end
end
context 'when merge requests belong to different projects' do
it 'calls #can? for each project' do
expect(parser).to receive(:can?).twice
another_merge_request = create(:merge_request)
2.times do
parser.can_read_reference?(user, merge_request)
parser.can_read_reference?(user, another_merge_request)
end
end
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More