Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-04-05 00:13:16 +00:00
parent a3b88e15d8
commit 20ec39e5bf
31 changed files with 1381 additions and 370 deletions

4
.gitignore vendored
View File

@ -118,8 +118,10 @@ jest-snapshot-test-report.json
*.local
/config/vite.gdk.json
# CSS compilation for cssbundling
# CSS compilation for cssbundling and tailwind
app/assets/builds/
config/helpers/tailwind/all_utilities.haml
config/helpers/tailwind/css_in_js.js
# ruby-lsp
.index.yml

View File

@ -66,6 +66,7 @@ compile-test-assets:
expire_in: 7d
paths:
- public/assets/
- config/helpers/tailwind/ # Assets created during tailwind compilation
- node_modules/@gitlab/svgs/dist/icons.json # app/helpers/icons_helper.rb uses this file
- node_modules/@gitlab/svgs/dist/file_icons/file_icons.json # app/helpers/icons_helper.rb uses this file
- "${WEBPACK_COMPILE_LOG_PATH}"

View File

@ -26,6 +26,7 @@ const CREATE_BRANCH = 'create-branch';
const VALIDATION_TYPE_BRANCH_UNAVAILABLE = 'branch_unavailable';
const VALIDATION_TYPE_INVALID_CHARS = 'invalid_chars';
const INPUT_TARGET_PROJECT = 'project';
const INPUT_TARGET_BRANCH = 'branch';
const INPUT_TARGET_REF = 'ref';
@ -61,36 +62,32 @@ export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
this.availableButton = this.wrapperEl.querySelector('.available');
this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchNameInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.targetProjectInput = this.wrapperEl.querySelector('.js-target-project');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
this.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner');
this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.sourceRefInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonSpinner = this.unavailableButton.querySelector('.js-create-mr-spinner');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
this.isBranchCreationMode = false;
this.branchCreated = false;
this.branchIsValid = true;
this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false;
this.isCreatingBranch = false;
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.refCancelToken = null;
this.mergeRequestCreated = false;
this.refDebounce = debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
this.projectPath = this.wrapperEl.dataset.projectPath;
this.projectId = this.wrapperEl.dataset.projectId;
this.suggestedRef = this.refName;
this.cancelSources = new Set();
// These regexps are used to replace
// a backend generated new branch name and its source (ref)
@ -114,6 +111,53 @@ export default class CreateMergeRequestDropdown {
}
}
get selectedProjectOption() {
if (this.targetProjectInput) {
return this.targetProjectInput.options[this.targetProjectInput.options.selectedIndex];
}
return null;
}
get sourceProject() {
return {
refsPath: this.wrapperEl.dataset.refsPath,
projectPath: this.wrapperEl.dataset.projectPath,
projectId: this.wrapperEl.dataset.projectId,
};
}
get targetProject() {
if (this.targetProjectInput) {
const option = this.selectedProjectOption;
return {
refsPath: this.targetProjectInput.value,
projectId: option.dataset.id,
projectPath: option.dataset.fullPath,
};
}
return this.sourceProject;
}
get branchName() {
return this.branchNameInput.value || this.branchNameInput.placeholder;
}
get refName() {
return this.sourceRefInput.value;
}
get canCreatePath() {
return this.wrapperEl.dataset.canCreatePath;
}
get createBranchPath() {
return this.wrapperEl.dataset.createBranchPath;
}
get createMrPath() {
return this.selectedProjectOption?.dataset?.createMrPath || this.wrapperEl.dataset.createMrPath;
}
available() {
this.availableButton.classList.remove('hidden');
this.unavailableButton.classList.add('hidden');
@ -128,17 +172,36 @@ export default class CreateMergeRequestDropdown {
'click',
this.onClickCreateMergeRequestButton.bind(this),
);
this.branchInput.addEventListener('input', this.onChangeInput.bind(this));
this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.branchNameInput.addEventListener('input', this.onBranchChange.bind(this));
this.branchNameInput.addEventListener('keyup', this.onBranchChange.bind(this));
if (this.targetProjectInput) {
this.targetProjectInput.addEventListener('change', this.onTargetProjectChange.bind(this));
}
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
// Detect for example when user pastes ref using the mouse
this.refInput.addEventListener('input', this.onChangeInput.bind(this));
this.sourceRefInput.addEventListener('input', this.onRefChange.bind(this));
// Detect for example when user presses right arrow to apply the suggested ref
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.sourceRefInput.addEventListener('keyup', this.onRefChange.bind(this));
// Detect when user clicks inside the input to apply the suggested ref
this.refInput.addEventListener('click', this.onChangeInput.bind(this));
this.sourceRefInput.addEventListener('click', this.onRefChange.bind(this));
// Detect when user presses tab to apply the suggested ref
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
this.sourceRefInput.addEventListener(
'keydown',
CreateMergeRequestDropdown.processTab.bind(this),
);
Array.from(this.wrapperEl.querySelectorAll('.js-type-toggle')).forEach((el) => {
el.addEventListener('click', this.toggleBranchCreationMode.bind(this));
});
}
toggleBranchCreationMode(event) {
if (event.currentTarget.dataset.value === 'create-branch') {
this.disableProjectSelect();
this.isBranchCreationMode = true;
} else {
this.enableProjectSelect();
this.isBranchCreationMode = false;
}
}
checkAbilityToCreateBranch() {
@ -165,8 +228,6 @@ export default class CreateMergeRequestDropdown {
}
})
.catch(() => {
this.unavailable();
this.disable();
createAlert({
message: __('Failed to check related branches.'),
});
@ -174,19 +235,21 @@ export default class CreateMergeRequestDropdown {
}
createBranch(navigateToBranch = true) {
// eslint-disable-next-line @gitlab/require-i18n-strings
if (!this.refName) return Promise.reject(new Error('Missing ref name'));
this.isCreatingBranch = true;
const endpoint = createEndpoint(
this.projectPath,
mergeUrlParams(
{ ref: this.refInput.value, branch_name: this.branchInput.value },
this.createBranchPath,
),
this.sourceProject.projectPath,
mergeUrlParams({ ref: this.refName, branch_name: this.branchName }, this.createBranchPath),
);
return axios
.post(endpoint, {
confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null,
confidential_issue_project_id: canCreateConfidentialMergeRequest()
? this.sourceProject.projectId
: null,
})
.then(({ data }) => {
this.branchCreated = true;
@ -210,14 +273,14 @@ export default class CreateMergeRequestDropdown {
.then(() => {
let path = canCreateConfidentialMergeRequest()
? this.createMrPath.replace(
this.projectPath,
this.targetProject.projectPath,
confidentialMergeRequestState.selectedProject.pathWithNamespace,
)
: this.createMrPath;
path = mergeUrlParams(
{
'merge_request[target_branch]': this.refInput.value,
'merge_request[source_branch]': this.branchInput.value,
'merge_request[target_branch]': this.refName,
'merge_request[source_branch]': this.branchName,
},
path,
);
@ -252,6 +315,18 @@ export default class CreateMergeRequestDropdown {
this.createTargetButton.removeAttribute('disabled');
}
disableProjectSelect() {
if (!this.targetProjectInput) return;
this.targetProjectInput.classList.add('disabled');
this.targetProjectInput.setAttribute('disabled', 'disabled');
}
enableProjectSelect() {
if (!this.targetProjectInput) return;
this.targetProjectInput.classList.remove('disabled');
this.targetProjectInput.removeAttribute('disabled');
}
static findByValue(objects, ref, returnFirstMatch = false) {
if (!objects || !objects.length) return false;
if (objects.indexOf(ref) > -1) return ref;
@ -295,13 +370,17 @@ export default class CreateMergeRequestDropdown {
}
getRef(ref, target = 'all') {
if (!ref) return false;
const source = axios.CancelToken.source();
this.cancelSources.add(source);
this.refCancelToken = axios.CancelToken.source();
const project =
target === INPUT_TARGET_REF && !this.isBranchCreationMode
? this.targetProject
: this.sourceProject;
return axios
.get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, {
cancelToken: this.refCancelToken.token,
.get(`${createEndpoint(project.projectPath, project.refsPath)}${encodeURIComponent(ref)}`, {
cancelToken: source.token,
})
.then(({ data }) => {
const branches = data[Object.keys(data)[0]];
@ -317,33 +396,22 @@ export default class CreateMergeRequestDropdown {
this.suggestedRef = result;
}
this.isGettingRef = false;
return this.updateInputState(target, ref, result);
this.updateInputState(target, ref, result);
})
.catch((thrown) => {
if (axios.isCancel(thrown)) {
return false;
return;
}
this.unavailable();
this.disable();
createAlert({
message: __('Failed to get ref.'),
});
this.isGettingRef = false;
return false;
})
.finally(() => {
this.cancelSources.delete(source);
});
}
getTargetData(target) {
return {
input: this[`${target}Input`],
message: this[`${target}Message`],
};
}
hide() {
this.wrapperEl.classList.add('hidden');
}
@ -372,43 +440,53 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
this.branchCreated ||
this.isGettingRef
this.branchCreated
);
}
onChangeInput(event) {
this.disable();
let target;
let value;
beforeChange() {
// User changed input, cancel to prevent previous request from interfering
if (this.refCancelToken !== null) {
this.refCancelToken.cancel();
if (this.cancelSources.size !== 0) {
this.cancelSources.forEach((token) => {
token.cancel();
this.cancelSources.delete(token);
});
}
}
onTargetProjectChange() {
this.beforeChange();
this.getRef(this.refName, INPUT_TARGET_REF);
}
onBranchChange(event) {
this.beforeChange();
this.handleChange(event, INPUT_TARGET_BRANCH, this.branchName);
}
onRefChange(event) {
this.beforeChange();
const target = INPUT_TARGET_REF;
if (event.target !== document.activeElement) {
this.handleChange(event, target, event.target.value);
return;
}
if (event.target === this.branchInput) {
target = INPUT_TARGET_BRANCH;
({ value } = this.branchInput);
} else if (event.target === this.refInput) {
target = INPUT_TARGET_REF;
if (event.target === document.activeElement) {
value =
event.target.value.slice(0, event.target.selectionStart) +
event.target.value.slice(event.target.selectionEnd);
} else {
value = event.target.value;
}
} else {
return false;
}
const value =
event.target.value.slice(0, event.target.selectionStart) +
event.target.value.slice(event.target.selectionEnd);
if (this.isGettingRef) return false;
this.handleChange(event, target, value);
}
handleChange(event, target, value) {
// `ENTER` key submits the data.
if (event.keyCode === 13 && this.inputsAreValid()) {
event.preventDefault();
return this.createMergeRequestButton.click();
this.createMergeRequestButton.click();
return;
}
// If the input is empty, use the original value generated by the backend.
@ -422,19 +500,18 @@ export default class CreateMergeRequestDropdown {
this.enable();
this.showAvailableMessage(target);
this.refDebounce(value, target);
return true;
return;
}
this.showCheckingMessage(target);
if (target !== INPUT_TARGET_PROJECT) this.showCheckingMessage(target);
this.refDebounce(value, target);
return true;
}
onClickCreateMergeRequestButton(event) {
let xhr = null;
event.preventDefault();
if (this.cancelSources.size !== 0) return;
if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) {
this.droplab.hooks.forEach((hook) => hook.list.toggle());
@ -445,6 +522,7 @@ export default class CreateMergeRequestDropdown {
return;
}
let xhr = null;
if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
} else if (event.currentTarget.dataset.action === CREATE_BRANCH) {
@ -464,12 +542,12 @@ export default class CreateMergeRequestDropdown {
}
onClickSetFocusOnBranchNameInput() {
this.branchInput.focus();
this.branchNameInput.focus();
}
// `TAB` autocompletes the source.
static processTab(event) {
if (event.keyCode !== 9 || this.isGettingRef) return;
if (event.keyCode !== 9) return;
const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
@ -482,6 +560,22 @@ export default class CreateMergeRequestDropdown {
this.refInput.setSelectionRange(caretPositionEnd, caretPositionEnd);
}
getTargetData(target) {
if (target === INPUT_TARGET_BRANCH) {
return {
input: this.branchNameInput,
message: this.branchMessage,
};
}
if (target === INPUT_TARGET_REF) {
return {
input: this.sourceRefInput,
message: this.refMessage,
};
}
return null;
}
removeMessage(target) {
const { input, message } = this.getTargetData(target);
const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
@ -534,13 +628,8 @@ export default class CreateMergeRequestDropdown {
message.style.display = 'inline-block';
}
unavailable() {
this.availableButton.classList.add('hidden');
this.unavailableButton.classList.remove('hidden');
}
updateBranchName(suggestedBranchName) {
this.branchInput.value = suggestedBranchName;
this.branchNameInput.value = suggestedBranchName;
this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, '');
}
@ -559,20 +648,20 @@ export default class CreateMergeRequestDropdown {
}
updateRefInput(ref, result) {
this.refInput.dataset.value = ref;
this.sourceRefInput.dataset.value = ref;
if (ref === result) {
this.refIsValid = true;
this.showAvailableMessage(INPUT_TARGET_REF);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
this.sourceRefInput.dataset.value = ref;
this.disableCreateAction();
this.showNotAvailableMessage(INPUT_TARGET_REF);
// Show ref hint.
if (result) {
this.refInput.value = result;
this.refInput.setSelectionRange(ref.length, result.length);
this.sourceRefInput.value = result;
this.sourceRefInput.setSelectionRange(ref.length, result.length);
}
}
}

View File

@ -1,3 +1,58 @@
/* stylelint-disable scss/at-rule-no-unknown */
.gl-border {
@apply gl-border-gray-100;
@apply gl-border-solid;
}
.gl-border\! {
@apply gl-border !important;
@apply gl-border-gray-100 !important;
@apply gl-border-solid !important;
}
.gl-border-b {
@apply gl-border-b-gray-100;
@apply gl-border-b-solid;
}
.gl-border-b\! {
@apply gl-border-b !important;
@apply gl-border-b-gray-100 !important;
@apply gl-border-b-solid !important;
}
.gl-border-l {
@apply gl-border-l-gray-100;
@apply gl-border-l-solid;
}
.gl-border-l\! {
@apply gl-border-l !important;
@apply gl-border-l-gray-100 !important;
@apply gl-border-l-solid !important;
}
.gl-border-r {
@apply gl-border-r-gray-100;
@apply gl-border-r-solid;
}
.gl-border-r\! {
@apply gl-border-r !important;
@apply gl-border-r-gray-100 !important;
@apply gl-border-r-solid !important;
}
.gl-border-t {
@apply gl-border-t-gray-100;
@apply gl-border-t-solid;
}
.gl-border-t\! {
@apply gl-border-t !important;
@apply gl-border-t-gray-100 !important;
@apply gl-border-t-solid !important;
}
@tailwind utilities;

View File

@ -68,6 +68,14 @@
}
}
}
.gl-dark-invert-keep-hue {
filter: invert(0.8) hue-rotate(180deg);
}
.gl-dark-invert-keep-hue\! {
filter: invert(0.8) hue-rotate(180deg) !important;
}
}
// Some hacks and overrides for things that don't properly support dark mode

View File

@ -2,6 +2,8 @@
module Projects
module IssuesHelper
MAX_FORK_CHAIN_LENGTH = 20
def create_mr_tracking_data(can_create_mr, can_create_confidential_mr)
if can_create_confidential_mr
{ event_tracking: 'click_create_confidential_mr_issues_list' }
@ -11,5 +13,52 @@ module Projects
{}
end
end
def default_target(project)
target = project.default_merge_request_target
target = project unless target.present? && can?(current_user, :create_merge_request_in, target)
target
end
def target_projects(project)
return [] unless project.forked?
target = default_target(project)
MergeRequestTargetProjectFinder
.new(current_user: current_user, source_project: project)
.execute(include_routes: true)
.limit(MAX_FORK_CHAIN_LENGTH)
.filter { |target_project| can?(current_user, :create_merge_request_in, target_project) }
.sort { |target_project, _| target_project.id == target.id ? 0 : 1 }
end
def merge_request_target_projects_options(issue, target, default_create_mr_path)
default_project = default_target(target)
target_projects(target).map do |project|
value = refs_project_path(project, search: '')
label = project.full_name
project_create_mr_path = default_create_mr_path
unless default_project.id == project.id
project_create_mr_path = create_mr_path(
from: issue.to_branch_name,
source_project: target,
target_project: project,
to: project.default_branch,
mr_params: { issue_iid: issue.iid }
)
end
[
nil,
value,
{
label: label,
data: { id: project.id, full_path: project.full_path, create_mr_path: project_create_mr_path }
}
]
end
end
end
end

View File

@ -551,7 +551,7 @@ class Issue < ApplicationRecord
end
def can_be_worked_on?
!self.closed? && !self.project.forked?
!self.closed?
end
# Returns `true` if the current issue can be viewed by either a logged in User

View File

@ -256,7 +256,7 @@ module MergeRequests
def append_closes_description
return unless issue&.to_reference.present?
closes_issue = "#{target_project.autoclose_referenced_issues ? 'Closes' : 'Related to'} #{issue.to_reference}"
closes_issue = "#{target_project.autoclose_referenced_issues ? 'Closes' : 'Related to'} #{issue.to_reference(target_project)}"
if description.present?
descr_parts = [merge_request.description, closes_issue]
@ -348,7 +348,8 @@ module MergeRequests
def issue
strong_memoize(:issue) do
target_project.get_issue(issue_iid, current_user)
issue_project = same_source_and_target_project? ? target_project : source_project
issue_project.get_issue(issue_iid, current_user)
end
end
end

View File

@ -31,12 +31,12 @@
= stylesheet_link_tag_defer "application_dark"
= yield :page_specific_styles
= stylesheet_link_tag_defer "application_utilities_dark"
= stylesheet_link_tag_defer "application_utilities_to_be_replaced_dark"
= stylesheet_link_tag_defer "application_utilities_to_be_replaced_dark" unless tailwind_all_the_way
- else
= stylesheet_link_tag_defer "application"
= yield :page_specific_styles
= stylesheet_link_tag_defer 'application_utilities'
= stylesheet_link_tag_defer "application_utilities_to_be_replaced"
= stylesheet_link_tag_defer "application_utilities_to_be_replaced" unless tailwind_all_the_way
- if tailwind_all_the_way
= stylesheet_link_tag_defer 'tailwind_all_the_way'
- else

View File

@ -5,12 +5,14 @@
- value = can_create_confidential_merge_request? ? _('Create confidential merge request') : value
- create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request')
- default_project = default_target(@project)
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid, format: :json)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
- refs_path = refs_namespace_project_path(default_project.namespace, default_project, search: '')
- tracking_data = create_mr_tracking_data(can_create_merge_request, can_create_confidential_merge_request?)
- default_create_mr_path = create_mr_path(from: @issue.to_branch_name, source_project: @project, to: default_project.default_branch, mr_params: { issue_iid: @issue.iid })
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: default_create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
= render Pajamas::ButtonComponent.new(button_options: { disabled: 'disabled' }) do
= gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
@ -26,10 +28,10 @@
= render Pajamas::ButtonComponent.new(variant: :confirm, icon: 'chevron-down', button_options: { class: 'js-dropdown-toggle dropdown-toggle create-merge-request-dropdown-toggle', data: { 'dropdown-trigger': '#create-merge-request-dropdown', display: 'static' } })
.droplab-dropdown
%form.droplab-dropdown
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
%li.js-type-toggle.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
.menu-item.text-nowrap
= sprite_icon('check', css_class: 'icon')
- if can_create_confidential_merge_request?
@ -37,7 +39,7 @@
- else
= _('Create merge request and branch')
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
%li.js-type-toggle{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
.menu-item
= sprite_icon('check', css_class: 'icon')
= _('Create branch')
@ -46,6 +48,13 @@
%li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5
- if can_create_confidential_merge_request?
#js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index') } }
- if @project.forked? && can_create_merge_request
.form-group
%label{ for: 'target-project' }
= _('Target project')
= select nil, nil, options_for_select(merge_request_target_projects_options(@issue, @project, default_create_mr_path), refs_path), {}, id: 'target-project', class: 'js-target-project form-control gl-form-select custom-select'
.form-group
%label{ for: 'new-branch-name' }
= _('Branch name')

View File

@ -1,12 +1,92 @@
// const plugin = require('tailwindcss/plugin');
const path = require('path');
const plugin = require('tailwindcss/plugin');
const tailwindGitLabDefaults = require('./tailwind.config');
// const utilities = require('./helpers/tailwind/css_in_js');
// Try loading the tailwind css_in_js, in case they exist
let utilities = {};
try {
// eslint-disable-next-line global-require, import/extensions
utilities = require('./helpers/tailwind/css_in_js.js');
} catch (e) {
console.log(
'config/helpers/tailwind/css_in_js do not exist yet. Please run `scripts/frontend/tailwind_all_the_way.mjs`',
);
/*
We need to remove the module itself from the cache, because node caches resolved modules.
So if we:
1. Require this file while helpers/tailwind/css_in_js.js does NOT exist
2. Require this file again, when it exists, we would get the version from (1.) leading
to errors.
If we bust the cache in case css_in_js.js doesn't exist, we will get the proper version
on a reload.
*/
delete require.cache[path.resolve(__filename)];
}
const { content, ...remainingConfig } = tailwindGitLabDefaults;
/** @type {import('tailwindcss').Config} */
module.exports = {
content,
...remainingConfig,
// This will be filled with life in a follow-up MR
content: [
process.argv.includes('--only-used') ? 'false' : './config/helpers/tailwind/all_utilities.haml',
...content,
],
corePlugins: {
/*
We set background: none, Tailwind background-image: none...
Probably compatible enough?
We could also extend the theme, so that we use background: none in tailwind
*/
backgroundImage: false,
/*
Our lineClamp also sets white-space: normal, which tailwind doesn't do, maybe we are okay?
*/
lineClamp: false,
/*
Our opacity scale is 0 to 10, tailwind is 0, 100
So:
opacity-5 => opacity-50
opacity-10 => opacity-100
*/
opacity: false,
/*
outline-none in tailwind is 2px solid transparent, we have outline: none
I assume that tailwind has it's reasons, and we probably could enable it
after a UX check
*/
outlineStyle: false,
/*
Our outline-0 removes the complete outline, while tailwind just sets the width to 0.
Maybe compatible?
*/
outlineWidth: false,
},
theme: {
// These extends probably should be moved to GitLab UI:
extend: {
borderWidth: {
// We have a border-1 class, while tailwind was missing it
1: '1px',
},
borderRadius: {
// Tailwind gl-rounded-full is 9999px
full: '50%',
},
boxShadow: {
none: 'none',
// TODO: I don't think we have a --t-gray matching class... --t-gray-a-24 seems close
DEFAULT: '0 1px 4px 0 rgba(#000, 0.3)',
sm: '0 1px 2px var(--t-gray-a-08, #1f1e2414)',
md: '0 2px 8px var(--t-gray-a-16, #1f1e2429), 0 0 2px var(--t-gray-a-16, #1f1e2429)',
lg: '0 4px 12px var(--t-gray-a-16, #1f1e2429), 0 0 4px var(--t-gray-a-16, #1f1e2429)',
},
},
},
plugins: [
plugin(({ addUtilities }) => {
addUtilities(utilities);
}),
],
};

View File

@ -308,6 +308,11 @@ module.exports = {
include: /node_modules/,
loader: 'babel-loader',
},
{
test: /jsonc-parser\/.*\.js$/,
include: /node_modules/,
loader: 'babel-loader',
},
{
test: /_worker\.js$/,
resourceQuery: /worker/,

View File

@ -48,13 +48,18 @@ The following video gives you an overview of GitLab merge request approval polic
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/379108) in GitLab 16.2 [with a flag](../../../administration/feature_flags.md) named `multi_pipeline_scan_result_policies`. Disabled by default.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/409482) in GitLab 16.3. Feature flag `multi_pipeline_scan_result_policies` removed.
> - Support for parent-child pipelines [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/428591) in GitLab 16.11 [with a flag](../../../administration/feature_flags.md) named `approval_policy_parent_child_pipeline`. Disabled by default.
FLAG:
On self-managed GitLab, by default support for parent-child pipelines is not available. To make it available, an administrator can [enable the feature flag](../../../administration/feature_flags.md) named `approval_policy_parent_child_pipeline`.
On GitLab.com and GitLab Dedicated, this feature is not available.
A project can have multiple pipeline types configured. A single commit can initiate multiple
pipelines, each of which may contain a security scan.
- In GitLab 16.3 and later, the results of all completed pipelines for the latest commit in
the merge request's source and target branch are evaluated and used to enforce the merge request approval policy.
Parent-child pipelines and on-demand DAST pipelines are not considered.
On-demand DAST pipelines are not considered.
- In GitLab 16.2 and earlier, only the results of the latest completed pipeline were evaluated
when enforcing merge request approval policies.
@ -317,7 +322,8 @@ actions:
- To determine when approval is required on a merge request, we compare completed pipelines for each supported pipeline source for the source and target branch (for example, `feature`/`main`). This ensures the most comprehensive evaluation of scan results.
- For the source branch, the comparison pipelines are all completed pipelines for each supported pipeline source for the latest commit in the source branch.
- For the target branch, we compare to all common ancestor's completed pipelines for each supported pipeline source.
- Merge request approval policies considers all supported pipeline sources (based on the [`CI_PIPELINE_SOURCE` variable](../../../ci/variables/predefined_variables.md)) when comparing results from both the source and target branches when determining if a merge request requires approval. Pipeline sources `webide` and `parent_pipeline` are not supported.
- Merge request approval policies considers all supported pipeline sources (based on the [`CI_PIPELINE_SOURCE` variable](../../../ci/variables/predefined_variables.md)) when comparing results from both the source and target branches when determining if a merge request requires approval. Pipelines with source `webide` are not supported.
- In GitLab 16.11 and later, the child pipelines of each of the selected pipelines are also considered for comparison. This is available [with a flag](../../../administration/feature_flags.md) named `approval_policy_parent_child_pipeline`.
### Accepting risk and ignoring vulnerabilities in future merge requests

View File

@ -3,7 +3,7 @@
module Gitlab
module Tracking
class StandardContext
GITLAB_STANDARD_SCHEMA_URL = 'iglu:com.gitlab/gitlab_standard/jsonschema/1-0-9'
GITLAB_STANDARD_SCHEMA_URL = 'iglu:com.gitlab/gitlab_standard/jsonschema/1-0-10'
GITLAB_RAILS_SOURCE = 'gitlab-rails'
def initialize(

View File

@ -50537,6 +50537,9 @@ msgstr ""
msgid "Target branch: %{target_branch}"
msgstr ""
msgid "Target project"
msgstr ""
msgid "Target project cannot be equal to source project"
msgstr ""

View File

@ -12,7 +12,7 @@
"prejest": "yarn check-dependencies",
"build:css": "node scripts/frontend/build_css.mjs",
"tailwindcss:build": "node scripts/frontend/tailwindcss.mjs",
"pretailwindcss:build": "TAILWIND_ALL_THE_WAY=1 node scripts/frontend/tailwindcss.mjs",
"pretailwindcss:build": "node scripts/frontend/tailwind_all_the_way.mjs",
"jest": "jest --config jest.config.js",
"jest-debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
"jest:ci": "jest --config jest.config.js --ci --coverage --testSequencer ./scripts/frontend/parallel_ci_sequencer.js",
@ -37,6 +37,7 @@
"lint:stylelint:fix": "yarn run lint:stylelint --fix",
"lint:stylelint:staged": "scripts/frontend/execute-on-staged-files.sh stylelint '(css|scss)' -q",
"lint:stylelint:staged:fix": "yarn run lint:stylelint:staged --fix",
"lint:tailwind-utils": "REDIRECT_TO_STDOUT=true node scripts/frontend/compare_css_util_classes.mjs",
"markdownlint": "markdownlint-cli2",
"preinstall": "node ./scripts/frontend/preinstall.mjs",
"postinstall": "node ./scripts/frontend/postinstall.js",
@ -61,7 +62,7 @@
"@gitlab/cluster-client": "^2.1.0",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/svgs": "3.93.0",
"@gitlab/svgs": "3.94.0",
"@gitlab/ui": "78.6.1",
"@gitlab/visual-review-tools": "1.7.3",
"@gitlab/web-ide": "^0.0.1-dev-20240226152102",
@ -192,6 +193,7 @@
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.2",
"remark-rehype": "^10.1.0",
"rgb-hex": "^4.1.0",
"sass": "^1.69.7",
"scrollparent": "^2.0.1",
"semver": "^7.3.4",
@ -294,7 +296,7 @@
"vue-loader-vue3": "npm:vue-loader@17",
"vue-test-utils-compat": "0.0.14",
"vuex-mock-store": "^0.1.0",
"webpack-dev-server": "4.15.1",
"webpack-dev-server": "4.15.2",
"xhr-mock": "^2.5.1",
"yarn-check-webpack-plugin": "^1.2.0",
"yarn-deduplicate": "^6.0.2"

View File

@ -0,0 +1,79 @@
#!/usr/bin/env node
/* eslint-disable import/extensions */
import { deepEqual } from 'node:assert';
import {
extractRules,
loadCSSFromFile,
normalizeCssInJSDefinition,
darkModeTokenToHex,
mismatchAllowList,
} from './lib/tailwind_migration.mjs';
import { convertUtilsToCSSInJS } from './tailwind_all_the_way.mjs';
function darkModeResolver(str) {
return str.replace(
/var\(--([^,]+?), #([a-f\d]{8}|[a-f\d]{6}|[a-f\d]{4}|[a-f\d]{3})\)/g,
(_all, tokenName) => {
if (darkModeTokenToHex[tokenName]) {
return darkModeTokenToHex[tokenName];
}
return _all;
},
);
}
function compareApplicationUtilsToTailwind(appUtils, tailWind, colorResolver) {
let fail = 0;
const tailwind = extractRules(tailWind);
Object.keys(appUtils).forEach((selector) => {
if (mismatchAllowList.includes(selector)) {
return;
}
try {
deepEqual(
normalizeCssInJSDefinition(appUtils[selector], colorResolver),
normalizeCssInJSDefinition(tailwind[selector], colorResolver),
);
} catch (e) {
fail += 1;
console.warn(`Not equal ${selector}`);
console.warn(e.message.replace(/\n/g, '\n\t'));
}
});
if (fail) {
console.log(`${fail} selectors failed`);
process.exitCode = 1;
}
}
console.log('# Converting legacy styles to CSS-in-JS definitions');
const stats = await convertUtilsToCSSInJS();
if (stats.hardcodedColors || stats.potentialMismatches) {
console.warn(`Some utils are not properly mapped`);
process.exitCode = 1;
}
console.log('# Comparing tailwind to legacy utils');
const applicationUtilsLight = extractRules(
loadCSSFromFile('app/assets/builds/application_utilities_to_be_replaced.css'),
{ convertColors: true },
);
const applicationUtilsDark = extractRules(
loadCSSFromFile('app/assets/builds/application_utilities_to_be_replaced_dark.css'),
{ convertColors: true },
);
const tailwind = loadCSSFromFile('app/assets/builds/tailwind_all_the_way.css');
compareApplicationUtilsToTailwind(applicationUtilsLight, tailwind);
compareApplicationUtilsToTailwind(applicationUtilsDark, tailwind, darkModeResolver);

View File

@ -86,6 +86,10 @@ function findSourceFiles(globPath, options = {}) {
});
}
function alwaysTrue() {
return true;
}
/**
* This function returns a Map<inputPath, outputPath> of absolute paths
* which map from a SCSS source file to a CSS output file.
@ -98,7 +102,7 @@ function findSourceFiles(globPath, options = {}) {
* but theoretically they could be completely separate files.
*
*/
function resolveCompilationTargets() {
function resolveCompilationTargets(filter) {
const inputGlobs = [
[
'app/assets/stylesheets/*.scss',
@ -156,7 +160,9 @@ function resolveCompilationTargets() {
const sources = findSourceFiles(sourcePath, options);
console.log(`${sourcePath} resolved to:`, sources);
for (const { source, dest } of sources) {
result.set(dest, source);
if (filter(source, dest)) {
result.set(dest, source);
}
}
}
@ -174,10 +180,14 @@ function createPostCSSProcessors() {
};
}
export async function compileAllStyles({ shouldWatch = false }) {
export async function compileAllStyles({
shouldWatch = false,
style = null,
filter = alwaysTrue,
} = {}) {
const reverseDependencies = {};
const compilationTargets = resolveCompilationTargets();
const compilationTargets = resolveCompilationTargets(filter);
const processors = createPostCSSProcessors();
@ -188,7 +198,7 @@ export async function compileAllStyles({ shouldWatch = false }) {
// We probably want to change this later if there are more
// post-processing steps, because we would compress
// _after_ things like auto-prefixer, etc. happened
style: shouldWatch ? 'expanded' : 'compressed',
style: style ?? (shouldWatch ? 'expanded' : 'compressed'),
sourceMap: shouldWatch,
sourceMapIncludeSources: shouldWatch,
};

View File

@ -0,0 +1,314 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import rgbHex from 'rgb-hex';
import postcss from 'postcss';
import _ from 'lodash';
const ROOT_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../');
const GITLAB_UI_DIR = path.join(ROOT_PATH, 'node_modules/@gitlab/ui');
// This is a list of classes where the tailwind and gitlab-ui output have a mismatch
// This might be due to e.g. the usage of custom properties on tailwinds side,
// or the usage of background vs background-image
export const mismatchAllowList = [
// Shadows use some `--tw` attributes, but the output should be the same
'.shadow-none',
'.shadow',
'.shadow-sm',
'.shadow-md',
'.shadow-lg',
// Difference between tailwind and gitlab ui: border-width: 0 vs border: 0
'.sr-only',
// tailwind uses --tw-rotate and --tw-translate custom properties
// the reason for this: To make translate / rotate composable
// Our utilities would overwrite each other
'.translate-x-0',
'.translate-y-0',
'.rotate-90',
'.rotate-180',
// Our border shorthand classes are slightly different,
// we migrated them by prepending them to the tailwind.css
'.border',
'.border-b',
'.border-l',
'.border-r',
'.border-t',
'.border\\!',
'.border-b\\!',
'.border-l\\!',
'.border-r\\!',
'.border-t\\!',
];
export function loadCSSFromFile(filePath) {
return fs.readFileSync(path.join(ROOT_PATH, filePath), 'utf-8');
}
/**
* A map of hex color codes to CSS variables replacements for utils where we can't
* confidently automated the substitutions.
* The keys correspond to a given util's base name obtained with the `selectorToBaseUtilName` helper.
* Values are a map of hex color codes to CSS variable names.
* If no replacement is necessary for a given util, the value should be an empty object.
*/
const hardcodedColorsToCSSVarsMap = {
'animate-skeleton-loader': {
'#dcdcde': '--gray-100',
'#ececef': '--gray-50',
},
'inset-border-b-2-theme-accent': {
'#6666c4': '--theme-indigo-500', // This gives us `var(--gl-theme-accent, var(--theme-indigo-500, #6666c4))` which I think is good
},
shadow: {}, // This util already uses hardcoded colors in its legacy version
'shadow-x0-y2-b4-s0': {}, // This util already uses hardcoded colors in its legacy version
'shadow-sm': {
'#1f1e2414': '--t-gray-a-08', // The dark theme override does not yet exist
},
'shadow-md': {
'#1f1e2429': '--t-gray-a-16', // The dark theme override does not yet exist
},
'shadow-lg': {
'#1f1e2429': '--t-gray-a-16', // The dark theme override does not yet exist
},
'text-contrast-light': {}, // The legacy util references the $white-contrast variable for which we have no dark theme override
'text-black-normal': {
'#333': '--gray-900',
},
'text-body': {
'#333238': '--gl-text-primary',
},
'text-secondary': {
'#737278': '--gl-text-secondary',
},
'border-gray-a-08': {
'#1f1e2414': '--t-gray-a-08', // The dark theme override does not yet exist
},
'inset-border-1-gray-a-08': {
'#1f1e2414': '--t-gray-a-08', // The dark theme override does not yet exist
},
'border-gray-a-24': {
'#1f1e243d': '--t-gray-a-24', // The dark theme override does not yet exist
},
border: {
'#dcdcde': '--gray-100',
},
'border-t': {
'#dcdcde': '--gray-100',
},
'border-r': {
'#dcdcde': '--gray-100',
},
'border-b': {
'#dcdcde': '--gray-100',
},
'border-l': {
'#dcdcde': '--gray-100',
},
'-focus': {
'#fff': '--white',
'#428fdc': '--blue-400',
},
'focus--focus': {
'#fff': '--white',
'#428fdc': '--blue-400',
},
};
/**
* Returns a flat array of token entries in the form:
*
* [['#123456','gray-500'],...]
* @param tokens
*/
export function getColorTokens(tokens) {
// Get
if (tokens.$type === 'color') {
return [
[
// Normalize rgb(a) values to hex values.
tokens.value.startsWith('rgb') ? `#${rgbHex(tokens.value)}` : tokens.value,
tokens.path.join('-'),
],
];
}
if (tokens.$type) {
return [];
}
return Object.values(tokens).flatMap((t) => getColorTokens(t));
}
/**
* We get all tokens, but ignore the `text` tokens,
* because the text tokens are correct, semantic tokens, but the values are
* from our gray scale
*/
const { text, ...lightModeTokensRaw } = JSON.parse(
fs.readFileSync(path.join(GITLAB_UI_DIR, 'dist/tokens/json/tokens.json'), 'utf-8'),
);
const lightModeHexToToken = Object.fromEntries(getColorTokens(lightModeTokensRaw));
export const darkModeTokenToHex = Object.fromEntries(
getColorTokens(
JSON.parse(
fs.readFileSync(path.join(GITLAB_UI_DIR, 'dist/tokens/json/tokens.dark.json'), 'utf-8'),
),
).map(([color, key]) => [key.startsWith('text-') ? `gl-${key}` : key, color]),
);
// We overwrite the following classes in
// app/assets/stylesheets/themes/_dark.scss
darkModeTokenToHex['t-gray-a-08'] = '#fbfafd14'; // rgba($gray-950, 0.08);
darkModeTokenToHex['gl-text-secondary'] = '#bfbfc3'; // $gray-700
function isImportant(selector) {
return selector.includes('!');
}
function getPseudoClass(selector) {
const [, ...state] = selector.split(':');
return state.length ? `&:${state.join(':')}` : '';
}
function getCleanSelector(selector) {
return selector.replace('gl-', '').replace(/:.*/, '');
}
/**
* Returns the plain util name from a given selector.
* Essentially removes the leading dot, breakpoint prefix and important suffix if any.
*
* @param {string} cleanSelector The selector from which to extract the util name (should have been cleaned with getCleanSelector first)
*/
function selectorToBaseUtilName(cleanSelector) {
return cleanSelector.replace(/^\.(sm-|md-|lg-)?/, '').replace(/\\!$/, '');
}
export const classesWithRawColors = [];
function normalizeColors(value, cleanSelector) {
return (
value
// Replace rgb and rgba functions with hex syntax
.replace(/rgba?\([\d ,./]+?\)/g, (rgbaColor) => `#${rgbHex(rgbaColor)}`)
// Find corresponding token for color
.replace(/#(?:[a-f\d]{8}|[a-f\d]{6}|[a-f\d]{4}|[a-f\d]{3})/gi, (hexColor) => {
// transparent rgba hexex
if (hexColor === '#0000' || hexColor === '#00000000') {
return 'transparent';
}
// We only want to match a color, if the selector contains the color name
if (
lightModeHexToToken[hexColor] &&
cleanSelector.includes(lightModeHexToToken[hexColor])
) {
return `var(--${lightModeHexToToken[hexColor]}, ${hexColor})`;
}
const utilName = selectorToBaseUtilName(cleanSelector);
const cssVar = hardcodedColorsToCSSVarsMap[utilName]?.[hexColor];
if (cssVar) {
return `var(${cssVar}, ${hexColor})`;
}
// Only add this util to the list of hardcoded colors if it was not defined in the
// `hardcodedColorsToCSSVarsMap` map.
if (!hardcodedColorsToCSSVarsMap[utilName]) {
classesWithRawColors.push(cleanSelector);
}
return hexColor;
})
);
}
export function extractRules(css, { convertColors = false } = {}) {
const definitions = {};
postcss.parse(css).walkRules((rule) => {
// We skip all atrule, e.g. @keyframe, except @media queries
if (rule.parent?.type === 'atrule' && rule.parent?.name !== 'media') {
console.log(`Skipping atrule of type ${rule.parent?.name}`);
return;
}
// This is an odd dark-mode only util. We have added it to the dark mode overrides
// and remove it from our utility classes
if (rule.selector.startsWith('.gl-dark .gl-dark-invert-keep-hue')) {
console.log(`Skipping composite selector ${rule.selector} which will be migrated manually`);
return;
}
// iterate over each class definition
rule.selectors.forEach((selector) => {
let styles = {};
const cleanSelector = getCleanSelector(selector);
// iterate over the properties of each class definition
rule.nodes.forEach((node) => {
styles[node.prop] = convertColors ? normalizeColors(node.value, cleanSelector) : node.value;
if (isImportant(selector)) {
styles[node.prop] += ' !important';
}
});
const pseudoClass = getPseudoClass(selector);
styles = pseudoClass
? {
[pseudoClass]: styles,
}
: styles;
if (rule.parent?.name === 'media') {
styles = {
[`@media ${rule.parent.params}`]: styles,
};
}
/* merge existing definitions, because e.g.
.class {
width: 0;
}
@media(...) {
.class {
height: 0;
}
}
needs to merged into:
{ '.class': {
'width': 0;
'@media(...)': {
height: 0;
}
}}
*/
definitions[cleanSelector] = { ...definitions[cleanSelector], ...styles };
});
});
return definitions;
}
export function normalizeCssInJSDefinition(tailwindDefinition, colorResolver = false) {
if (!tailwindDefinition) {
return null;
}
// Order property definitions by name.
const ordered = _.pick(tailwindDefinition, Object.keys(tailwindDefinition).sort());
return JSON.stringify(ordered, (key, value) => {
if (typeof value === 'string') {
// Normalize decimal values without leading zeroes
// e.g. 0.5px and .5px
if (value.startsWith('0.')) {
return value.substring(1);
}
// Normalize 0px and 0
if (value === '0px') {
return '0';
}
if (colorResolver) {
return colorResolver(value);
}
}
return value;
});
}

View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
/* eslint-disable import/extensions */
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import _ from 'lodash';
import postcss from 'postcss';
import prettier from 'prettier';
import tailwindcss from 'tailwindcss/lib/plugin.js';
import {
classesWithRawColors,
extractRules,
loadCSSFromFile,
mismatchAllowList,
normalizeCssInJSDefinition,
} from './lib/tailwind_migration.mjs';
import { compileAllStyles } from './lib/compile_css.mjs';
import { build as buildTailwind } from './tailwindcss.mjs';
const PATH_TO_FILE = path.resolve(fileURLToPath(import.meta.url));
const ROOT_PATH = path.resolve(path.dirname(PATH_TO_FILE), '../../');
const tempDir = path.join(ROOT_PATH, 'config', 'helpers', 'tailwind');
const allUtilitiesFile = path.join(tempDir, './all_utilities.haml');
export async function convertUtilsToCSSInJS() {
console.log('# Compiling legacy styles');
await compileAllStyles({
style: 'expanded',
filter: (source) => source.includes('application_utilities_to_be_replaced'),
});
fs.mkdirSync(tempDir, { recursive: true });
const oldUtilityDefinitions = extractRules(
loadCSSFromFile('app/assets/builds/application_utilities_to_be_replaced.css'),
{ convertColors: true },
);
// Write out all found css classes in order to run tailwind on it.
fs.writeFileSync(
allUtilitiesFile,
Object.keys(oldUtilityDefinitions)
.map((clazz) => {
return (
// Add `gl-` prefix to all classes
`.gl-${clazz.substring(1)}`
// replace the escaped `\!` with !
.replace(/\\!/g, '!')
);
})
.join('\n'),
);
// Lazily require
const { default: tailwindConfig } = await import('../../config/tailwind.all_the_way.config.js');
const { css: tailwindClasses } = await postcss([
tailwindcss({
...tailwindConfig,
// We only want to generate the utils based on the fresh
// allUtilitiesFile
content: [allUtilitiesFile],
// We are disabling all plugins, so that the css-to-js
// import doesn't cause trouble.
plugins: [],
}),
]).process('@tailwind utilities;', { map: false, from: undefined });
const tailwindDefinitions = extractRules(tailwindClasses);
const deleted = [];
const mismatches = [];
for (const definition of Object.keys(tailwindDefinitions)) {
if (
mismatchAllowList.includes(definition) ||
normalizeCssInJSDefinition(oldUtilityDefinitions[definition]) ===
normalizeCssInJSDefinition(tailwindDefinitions[definition])
) {
delete oldUtilityDefinitions[definition];
deleted.push(definition);
} else if (oldUtilityDefinitions[definition]) {
console.log(`Found ${definition} in both, but they don't match:`);
console.log(`\tOld: ${JSON.stringify(oldUtilityDefinitions[definition])}`);
console.log(`\tNew: ${JSON.stringify(tailwindDefinitions[definition])}`);
mismatches.push([
definition,
oldUtilityDefinitions[definition],
tailwindDefinitions[definition],
]);
delete oldUtilityDefinitions[definition];
}
}
console.log(
`Deleted exact matches:\n\t${_.chunk(deleted, 4)
.map((n) => n.join(' '))
.join('\n\t')}`,
);
const hardcodedColors = _.pick(oldUtilityDefinitions, classesWithRawColors);
const safeToUse = _.omit(oldUtilityDefinitions, classesWithRawColors);
const stats = {
exactMatches: deleted.length,
potentialMismatches: Object.keys(mismatches).length,
hardcodedColors: Object.keys(hardcodedColors).length,
safeToUseLegacyUtils: Object.keys(safeToUse).length,
};
console.log(stats);
const output = await prettier.format(
[
stats.potentialMismatches &&
`
/* eslint-disable no-unused-vars */
// The following rules are mismatches between our utility classes and
// tailwinds. So there are two rules in the old system and the new system
// with the same name, but their definitions mismatch.
// The mismatch might be minor, or major and needs to be dealt with manually
// the array below contains:
// [rule name, GitLab UI utility, tailwind utility]
const potentialMismatches = Object.fromEntries(
${JSON.stringify(mismatches, null, 2)}
);`,
stats.hardcodedColors &&
`
// The following definitions have hard-coded colors and do not use
// their var(...) counterparts. We should double-check them and fix them
// manually (e.g. the text- classes should use the text variables and not
// gray-)
const hardCodedColors = ${JSON.stringify(hardcodedColors, null, 2)};
`,
`module.exports = {`,
stats.hardcodedColors && '...hardCodedColors,',
`...${JSON.stringify(safeToUse, null, 2)}`,
'}',
]
.filter(Boolean)
.join(''),
{
printWidth: 100,
singleQuote: true,
arrowParens: 'always',
trailingComma: 'all',
parser: 'babel',
},
);
fs.writeFileSync(path.join(tempDir, './css_in_js.js'), output);
console.log('# Rebuilding tailwind-all-the-way');
await buildTailwind({ tailWindAllTheWay: true });
return stats;
}
if (PATH_TO_FILE.includes(path.resolve(process.argv[1]))) {
console.log('Script called directly.');
convertUtilsToCSSInJS().catch((e) => {
console.warn(e);
process.exitCode = 1;
});
}

View File

@ -6,10 +6,13 @@ import { createProcessor } from 'tailwindcss/lib/cli/build/plugin.js';
// Note, in node > 21.2 we could replace the below with import.meta.dirname
const ROOT_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../');
async function build({ shouldWatch = false } = {}) {
export async function build({
shouldWatch = false,
tailWindAllTheWay = Boolean(process.env.TAILWIND_ALL_THE_WAY),
} = {}) {
let config = path.join(ROOT_PATH, 'config/tailwind.config.js');
let fileName = 'tailwind.css';
if (process.env.TAILWIND_ALL_THE_WAY) {
if (tailWindAllTheWay) {
config = path.join(ROOT_PATH, 'config/tailwind.all_the_way.config.js');
fileName = 'tailwind_all_the_way.css';
}
@ -24,10 +27,18 @@ async function build({ shouldWatch = false } = {}) {
);
if (shouldWatch) {
processor.watch();
} else {
processor.build();
return processor.watch();
}
if (!process.env.REDIRECT_TO_STDOUT) {
return processor.build();
}
// tailwind directly prints to stderr,
// which we want to prevent in our static-analysis script
const origError = console.error;
console.error = console.log;
await processor.build();
console.error = origError;
return null;
}
function wasScriptCalledDirectly() {

View File

@ -267,18 +267,15 @@ function rspec_parallelized_job() {
export KNAPSACK_TEST_FILE_PATTERN=$(ruby -r./tooling/quality/test_level.rb -e "puts Quality::TestLevel.new(${spec_folder_prefixes}).pattern(:${test_level})")
export FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}all_${report_name}_report.json"
export NEW_FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}new_${report_name}_report.json"
export KNAPSACK_GENERATE_REPORT="true"
export FLAKY_RSPEC_GENERATE_REPORT="true"
if [[ -d "ee/" ]]; then
export KNAPSACK_GENERATE_REPORT="true"
export FLAKY_RSPEC_GENERATE_REPORT="true"
if [[ ! -f $FLAKY_RSPEC_REPORT_PATH ]]; then
echo "{}" > "${FLAKY_RSPEC_REPORT_PATH}"
fi
if [[ ! -f $FLAKY_RSPEC_REPORT_PATH ]]; then
echo "{}" > "${FLAKY_RSPEC_REPORT_PATH}"
fi
if [[ ! -f $NEW_FLAKY_RSPEC_REPORT_PATH ]]; then
echo "{}" > "${NEW_FLAKY_RSPEC_REPORT_PATH}"
fi
if [[ ! -f $NEW_FLAKY_RSPEC_REPORT_PATH ]]; then
echo "{}" > "${NEW_FLAKY_RSPEC_REPORT_PATH}"
fi
debug_rspec_variables

View File

@ -43,6 +43,7 @@ class StaticAnalysis
Task.new(%W[scripts/license-check.sh #{project_path}], 200),
(Gitlab.ee? ? Task.new(%w[bin/rake gettext:updated_check], 40) : nil),
Task.new(%w[bin/rake lint:static_verification], 40),
Task.new(%w[yarn run lint:tailwind-utils], 20),
Task.new(%w[bin/rake config_lint], 10),
Task.new(%w[bin/rake gitlab:sidekiq:all_queues_yml:check], 15),
(Gitlab.ee? ? Task.new(%w[bin/rake gitlab:sidekiq:sidekiq_queues_yml:check], 11) : nil),

View File

@ -3,240 +3,284 @@
require 'spec_helper'
RSpec.describe 'User creates branch and merge request on issue page', :js, feature_category: :team_planning do
let(:membership_level) { :developer }
include ProjectForksHelper
let(:user) { create(:user) }
let!(:project) { create(:project, :repository, :public) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
let(:membership_level) { :developer }
context 'when signed out' do
before do
visit project_issue_path(project, issue)
end
it "doesn't show 'Create merge request' button" do
expect(page).not_to have_selector('.create-mr-dropdown-wrap')
end
end
context 'when signed in' do
before do
project.add_member(user, membership_level)
sign_in(user)
end
context 'when interacting with the dropdown' do
shared_examples "user creates branch and merge request from issue" do
context 'when signed out' do
before do
visit project_issue_path(project, issue)
end
# In order to improve tests performance, all UI checks are placed in this test.
it 'shows elements' do
button_create_merge_request = find('.js-create-merge-request')
button_toggle_dropdown = find('.create-mr-dropdown-wrap .dropdown-toggle')
it "doesn't show 'Create merge request' button" do
expect(page).not_to have_selector('.create-mr-dropdown-wrap')
end
end
button_toggle_dropdown.click
context 'when signed in' do
before do
project.add_member(user, membership_level)
dropdown = find('.create-merge-request-dropdown-menu')
sign_in(user)
end
page.within(dropdown) do
button_create_target = find('.js-create-target')
input_branch_name = find('.js-branch-name')
input_source = find('.js-ref')
li_create_branch = find("li[data-value='create-branch']")
li_create_merge_request = find("li[data-value='create-mr']")
context 'when interacting with the dropdown' do
before do
visit project_issue_path(project, issue)
end
# Test that all elements are presented.
expect(page).to have_content('Create merge request and branch')
expect(page).to have_content('Create branch')
expect(page).to have_content('Branch name')
expect(page).to have_content('Source (branch or tag)')
expect(page).to have_button('Create merge request')
expect(page).to have_selector('.js-branch-name:focus')
# In order to improve tests performance, all UI checks are placed in this test.
it 'shows elements' do
button_create_merge_request = find('.js-create-merge-request')
button_toggle_dropdown = find('.create-mr-dropdown-wrap .dropdown-toggle')
test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
test_branch_name_checking(input_branch_name)
test_source_checking(input_source)
button_toggle_dropdown.click
# The button inside dropdown should be disabled if any errors occurred.
dropdown = find('.create-merge-request-dropdown-menu')
page.within(dropdown) do
button_create_target = find('.js-create-target')
input_branch_name = find('.js-branch-name')
input_source = find('.js-ref')
li_create_branch = find("li[data-value='create-branch']")
li_create_merge_request = find("li[data-value='create-mr']")
# Test that all elements are presented.
expect(page).to have_content('Create merge request and branch')
expect(page).to have_content('Create branch')
expect(page).to have_content('Branch name')
expect(page).to have_content('Source (branch or tag)')
expect(page).to have_button('Create merge request')
expect(page).to have_selector('.js-branch-name:focus')
test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
test_branch_name_checking(input_branch_name)
test_source_checking(input_source)
# The button inside dropdown should be disabled if any errors occurred.
expect(page).to have_button('Create branch', disabled: true)
end
# The top level button should be disabled if any errors occurred.
expect(page).to have_button('Create branch', disabled: true)
end
# The top level button should be disabled if any errors occurred.
expect(page).to have_button('Create branch', disabled: true)
end
context 'when branch name is auto-generated' do
it 'creates a merge request', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
select_dropdown_option('create-mr')
context 'when branch name is auto-generated' do
it 'creates a merge request', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
select_dropdown_option('create-mr')
expect(page).to have_content('New merge request')
expect(page).to have_content("From #{issue.to_branch_name} into #{project.default_branch}")
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
expect(page).to have_field("Description", with: "Closes ##{issue.iid}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
expect(page).to have_content('New merge request')
expect(page).to have_content(/From .*#{Regexp.escape(issue.to_branch_name)} into .*#{Regexp.escape(project.default_branch)}/)
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
expect(page).to have_field("Description", with: "Closes #{issue.to_reference(upstream_project)}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
end
end
end
it 'creates a branch' do
select_dropdown_option('create-branch')
wait_for_requests
expect(page).to have_selector('.ref-selector ', text: '1-cherry-coloured-funk')
expect(page).to have_current_path project_tree_path(project, '1-cherry-coloured-funk'), ignore_query: true
end
end
context 'when branch name is custom' do
let(:branch_name) { 'custom-branch-name' }
it 'creates a merge request', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
select_dropdown_option('create-mr', branch_name)
expect(page).to have_content('New merge request')
expect(page).to have_content("From #{branch_name} into #{project.default_branch}")
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
expect(page).to have_field("Description", with: "Closes ##{issue.iid}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
end
end
it 'creates a branch' do
select_dropdown_option('create-branch', branch_name)
wait_for_requests
expect(page).to have_selector('.ref-selector', text: branch_name)
expect(page).to have_current_path project_tree_path(project, branch_name), ignore_query: true
end
context 'when source branch is non-default' do
let(:source_branch) { 'feature' }
it 'creates a branch' do
select_dropdown_option('create-branch', branch_name, source_branch)
select_dropdown_option('create-branch')
wait_for_requests
expect(page).to have_selector('.ref-selector ', text: '1-cherry-coloured-funk')
expect(page).to have_current_path project_tree_path(project, '1-cherry-coloured-funk'), ignore_query: true
end
end
context 'when branch name is custom' do
let(:branch_name) { 'custom-branch-name' }
it 'creates a merge request', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
select_dropdown_option('create-mr', branch_name)
expect(page).to have_content('New merge request')
expect(page).to have_content(/From .*#{Regexp.escape(branch_name)} into .*#{Regexp.escape(project.default_branch)}/)
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
expect(page).to have_field("Description", with: "Closes #{issue.to_reference(upstream_project)}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
end
end
it 'creates a branch' do
select_dropdown_option('create-branch', branch_name)
wait_for_requests
expect(page).to have_selector('.ref-selector', text: branch_name)
expect(page).to have_current_path project_tree_path(project, branch_name), ignore_query: true
end
end
end
context 'when branch name is invalid' do
shared_examples 'has error message' do |dropdown|
it 'has error message' do
select_dropdown_option(dropdown, 'custom-branch-name w~th ^bad chars?')
context 'when source branch is non-default' do
let(:source_branch) { 'feature' }
wait_for_requests
it 'creates a branch' do
select_dropdown_option('create-branch', branch_name, source_branch)
wait_for_requests
expect(page).to have_text("Can't contain spaces, ~, ^, ?")
expect(page).to have_selector('.ref-selector', text: branch_name)
expect(page).to have_current_path project_tree_path(project, branch_name), ignore_query: true
end
end
end
context 'when creating a merge request', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-mr'
context 'when branch name is invalid' do
shared_examples 'has error message' do |dropdown|
it 'has error message' do
select_dropdown_option(dropdown, 'custom-branch-name w~th ^bad chars?')
wait_for_requests
expect(page).to have_text("Can't contain spaces, ~, ^, ?")
end
end
context 'when creating a merge request', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-mr'
end
context 'when creating a branch', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-branch'
end
end
end
context "when there is a referenced merge request" do
let!(:note) do
create(
:note,
:on_issue,
:system,
project: project,
noteable: issue,
note: "mentioned in #{referenced_mr.to_reference}"
)
end
context 'when creating a branch', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-branch'
let(:referenced_mr) do
create(
:merge_request,
:simple,
source_project: project,
target_project: project,
description: "Fixes #{issue.to_reference}",
author: user
)
end
before do
referenced_mr.cache_merge_request_closes_issues!(user)
visit project_issue_path(project, issue)
end
it 'disables the create branch button', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/27985' do
expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hidden)')
expect(page).to have_css('.create-mr-dropdown-wrap .available.hidden', visible: false)
expect(page).to have_content /Related merge requests/
end
end
context 'when merge requests are disabled' do
before do
project.project_feature.update!(merge_requests_access_level: 0)
visit project_issue_path(project, issue)
end
it 'shows only create branch button' do
expect(page).not_to have_button('Create merge request')
expect(page).to have_button('Create branch')
end
end
context 'when issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it 'enables the create branch button' do
visit project_issue_path(project, issue)
expect(page).to have_css('.create-mr-dropdown-wrap')
expect(page).to have_button('Create confidential merge request')
end
end
context 'when related branch exists' do
let!(:project) { create(:project, :repository, :private) }
let(:branch_name) { "#{issue.iid}-foo" }
before do
project.repository.create_branch(branch_name)
visit project_issue_path(project, issue)
end
context 'when user is developer' do
it 'shows related branches' do
expect(page).to have_css('#related-branches')
wait_for_requests
expect(page).to have_content(branch_name)
end
end
context 'when user is guest' do
let(:membership_level) { :guest }
it 'does not show related branches' do
expect(page).not_to have_css('#related-branches')
wait_for_requests
expect(page).not_to have_content(branch_name)
end
end
end
end
end
context "when there is a referenced merge request" do
let!(:note) do
create(
:note,
:on_issue,
:system,
project: project,
noteable: issue,
note: "mentioned in #{referenced_mr.to_reference}"
)
end
context 'with source project' do
let!(:project) { create(:project, :repository, :public) }
let!(:upstream_project) { project }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
let(:referenced_mr) do
create(
:merge_request,
:simple,
source_project: project,
target_project: project,
description: "Fixes #{issue.to_reference}",
author: user
)
end
include_examples 'user creates branch and merge request from issue'
end
context 'with forked project' do
let!(:upstream_project) { create(:project, :repository, :public) }
let!(:project) { fork_project(upstream_project, user, repository: true) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
before do
upstream_project.add_member(user, :guest)
end
include_examples 'user creates branch and merge request from issue'
context 'when logged in' do
before do
referenced_mr.cache_merge_request_closes_issues!(user)
project.add_member(user, membership_level)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
it 'disables the create branch button', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/27985' do
expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hidden)')
expect(page).to have_css('.create-mr-dropdown-wrap .available.hidden', visible: false)
expect(page).to have_content /Related merge requests/
end
end
context 'when merge requests are disabled' do
before do
project.project_feature.update!(merge_requests_access_level: 0)
visit project_issue_path(project, issue)
it 'creates MR in an upstream project' do
find('.js-create-merge-request').click
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: upstream_project.default_branch, issue_iid: issue.iid }))
end
it 'shows only create branch button' do
expect(page).not_to have_button('Create merge request')
expect(page).to have_button('Create branch')
end
end
context 'when issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it 'enables the create branch button' do
visit project_issue_path(project, issue)
expect(page).to have_css('.create-mr-dropdown-wrap')
expect(page).to have_button('Create confidential merge request')
end
end
context 'when related branch exists' do
let!(:project) { create(:project, :repository, :private) }
let(:branch_name) { "#{issue.iid}-foo" }
before do
project.repository.create_branch(branch_name)
visit project_issue_path(project, issue)
end
context 'when user is developer' do
it 'shows related branches' do
expect(page).to have_css('#related-branches')
wait_for_requests
expect(page).to have_content(branch_name)
end
end
context 'when user is guest' do
let(:membership_level) { :guest }
it 'does not show related branches' do
expect(page).not_to have_css('#related-branches')
wait_for_requests
expect(page).not_to have_content(branch_name)
end
it 'creates MR in a forked project' do
find('.create-mr-dropdown-wrap .dropdown-toggle').click
find("#target-project option[label='#{project.full_name}']").select_option
wait_for_requests
find('.js-create-merge-request').click
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: project.default_branch, issue_iid: issue.iid, target_project_id: project.id }))
end
end
end

View File

@ -4,6 +4,8 @@ import confidentialState from '~/confidential_merge_request/state';
import CreateMergeRequestDropdown from '~/issues/create_merge_request_dropdown';
import axios from '~/lib/utils/axios_utils';
const REFS_PATH = `${TEST_HOST}/dummy/refs?search=`;
describe('CreateMergeRequestDropdown', () => {
let axiosMock;
let dropdown;
@ -12,7 +14,7 @@ describe('CreateMergeRequestDropdown', () => {
axiosMock = new MockAdapter(axios);
document.body.innerHTML = `
<div id="dummy-wrapper-element">
<div id="dummy-wrapper-element" data-refs-path="${REFS_PATH}">
<div class="available"></div>
<div class="unavailable">
<div class="js-create-mr-spinner"></div>
@ -30,7 +32,6 @@ describe('CreateMergeRequestDropdown', () => {
const dummyElement = document.getElementById('dummy-wrapper-element');
dropdown = new CreateMergeRequestDropdown(dummyElement);
dropdown.refsPath = `${TEST_HOST}/dummy/refs?search=`;
});
afterEach(() => {
@ -39,7 +40,7 @@ describe('CreateMergeRequestDropdown', () => {
describe('getRef', () => {
it('escapes branch names correctly', async () => {
const endpoint = `${dropdown.refsPath}contains%23hash`;
const endpoint = `${REFS_PATH}contains%23hash`;
jest.spyOn(axios, 'get');
axiosMock.onGet(endpoint).replyOnce({});

View File

@ -3,6 +3,15 @@
require 'spec_helper'
RSpec.describe Projects::IssuesHelper, feature_category: :team_planning do
include ProjectForksHelper
let_it_be(:current_user) { build(:user) }
let_it_be(:project) { build(:project, :public) }
# rubocop:disable RSpec/FactoryBot/AvoidCreate -- forked projects require persistence
let_it_be(:source_project) { create(:project, :public) }
# rubocop:enable RSpec/FactoryBot/AvoidCreate
let_it_be(:forked_project) { fork_project(source_project, current_user, repository: true) }
describe '#create_mr_tracking_data' do
using RSpec::Parameterized::TableSyntax
@ -18,4 +27,24 @@ RSpec.describe Projects::IssuesHelper, feature_category: :team_planning do
end
end
end
describe '#default_target' do
context 'when a project has no forks' do
it 'returns the same project' do
expect(default_target(project)).to be project
end
end
context 'when a project has forks' do
it 'returns the source project' do
expect(default_target(forked_project)).to eq source_project
end
end
end
describe '#target_projects' do
it 'returns all the forks and the source project' do
expect(target_projects(forked_project)).to eq [source_project, forked_project]
end
end
end

View File

@ -1122,7 +1122,7 @@ RSpec.describe Issue, feature_category: :team_planning do
allow(project).to receive(:forked?).and_return(true)
end
it { is_expected.not_to be_can_be_worked_on }
it { is_expected.to be_can_be_worked_on }
end
it { is_expected.to be_can_be_worked_on }

View File

@ -12,6 +12,7 @@ RSpec.describe MergeRequests::BuildService, feature_category: :code_review_workf
let(:user) { create(:user) }
let(:issue_confidential) { false }
let(:issue) { create(:issue, project: project, title: 'A bug', confidential: issue_confidential) }
let(:issue_iid) { nil }
let(:description) { nil }
let(:source_branch) { 'feature-branch' }
let(:target_branch) { 'master' }
@ -62,7 +63,8 @@ RSpec.describe MergeRequests::BuildService, feature_category: :code_review_workf
source_project: source_project,
target_project: target_project,
milestone_id: milestone_id,
label_ids: label_ids
label_ids: label_ids,
issue_iid: issue_iid
}
end
@ -531,6 +533,17 @@ RSpec.describe MergeRequests::BuildService, feature_category: :code_review_workf
expect(merge_request.description).to eq('Create the app')
end
end
context 'when the issue is created in the fork' do
let(:source_project) { create(:project, forked_from_project: project) }
let(:issue) { create(:issue, project: source_project, title: 'A bug') }
let(:issue_iid) { issue.iid }
it 'uses a full reference to the issue' do
allow(project).to receive(:get_issue).and_return issue
expect(merge_request.description).to include("Closes #{source_project.full_path}##{issue_iid}")
end
end
end
context 'source branch does not exist' do

View File

@ -6,6 +6,8 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -33,6 +35,14 @@ type entryParams struct {
Method string
}
type cacheKey struct {
requestTimeout time.Duration
responseTimeout time.Duration
allowRedirects bool
}
var httpClients sync.Map
var SendURL = &entry{"send-url:"}
var rangeHeaderKeys = []string{
@ -129,9 +139,7 @@ func (e *entry) Inject(w http.ResponseWriter, r *http.Request, sendData string)
}
// execute new request
var resp *http.Response
resp, err = newClient(params).Do(newReq)
resp, err := cachedClient(params).Do(newReq)
if err != nil {
status := http.StatusInternalServerError
@ -174,7 +182,17 @@ func (e *entry) Inject(w http.ResponseWriter, r *http.Request, sendData string)
sendURLRequestsSucceeded.Inc()
}
func newClient(params entryParams) *http.Client {
func cachedClient(params entryParams) *http.Client {
key := cacheKey{
requestTimeout: params.DialTimeout.Duration,
responseTimeout: params.ResponseHeaderTimeout.Duration,
allowRedirects: params.AllowRedirects,
}
cachedClient, found := httpClients.Load(key)
if found {
return cachedClient.(*http.Client)
}
var options []transport.Option
if params.DialTimeout.Duration != 0 {
@ -187,11 +205,12 @@ func newClient(params entryParams) *http.Client {
client := &http.Client{
Transport: transport.NewRestrictedTransport(options...),
}
if !params.AllowRedirects {
client.CheckRedirect = httpClientNoRedirect
}
httpClients.Store(key, client)
return client
}

View File

@ -292,3 +292,22 @@ func TestErrorWithCustomStatusCode(t *testing.T) {
require.Equal(t, http.StatusTeapot, response.Code)
}
func TestHttpClientReuse(t *testing.T) {
expectedKey := cacheKey{
requestTimeout: 0,
responseTimeout: 0,
allowRedirects: false,
}
httpClients.Delete(expectedKey)
response := testEntryServer(t, "/get/request", nil, false)
require.Equal(t, http.StatusOK, response.Code)
_, found := httpClients.Load(expectedKey)
require.Equal(t, true, found)
storedClient := &http.Client{}
httpClients.Store(expectedKey, storedClient)
require.Equal(t, cachedClient(entryParams{}), storedClient)
require.NotEqual(t, cachedClient(entryParams{AllowRedirects: true}), storedClient)
}

View File

@ -1321,10 +1321,10 @@
stylelint-declaration-strict-value "1.10.4"
stylelint-scss "6.0.0"
"@gitlab/svgs@3.93.0":
version "3.93.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.93.0.tgz#10a548931d7ece5e7f5d77b77c0aa76d02f7b408"
integrity sha512-RUMIf72M8trVZXRmUjH/54WJpMpV0tZTShSdV9ey1gtPgcpXEDg7HGprAzSfCZ6pMuhGXIasR3or7BHFM4DrSQ==
"@gitlab/svgs@3.94.0":
version "3.94.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.94.0.tgz#517e7d6b570f6687b255dfa9812f840eec2649c6"
integrity sha512-08E6Vl8so4VUxnPueHNmgYBsLV0ZCXrzL9c1Ry2AQnCDVwCmesjRyjB1cS6FjdUxjTri8RUCU3a5+Cy6zFk30Q==
"@gitlab/ui@78.6.1":
version "78.6.1"
@ -6958,10 +6958,10 @@ fs-minipass@^2.0.0:
dependencies:
minipass "^3.0.0"
fs-monkey@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3"
integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==
fs-monkey@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788"
integrity sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==
fs-write-stream-atomic@^1.0.8:
version "1.0.10"
@ -7691,12 +7691,7 @@ ignore-by-default@^1.0.1:
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk=
ignore@^5.2.0, ignore@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
ignore@^5.2.4, ignore@~5.3.0:
ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.0, ignore@~5.3.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
@ -8758,12 +8753,12 @@ json5@^2.1.2, json5@^2.2.3:
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonc-parser@3.2.0, jsonc-parser@^3.0.0:
jsonc-parser@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76"
integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==
jsonc-parser@~3.2.1:
jsonc-parser@^3.0.0, jsonc-parser@~3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.1.tgz#031904571ccf929d7670ee8c547545081cb37f1a"
integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==
@ -9503,12 +9498,12 @@ media-typer@0.3.0:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
memfs@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.1.tgz#b78092f466a0dce054d63d39275b24c71d3f1305"
integrity sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==
memfs@^3.4.3:
version "3.6.0"
resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6"
integrity sha512-EGowvkkgbMcIChjMTMkESFDbZeSh8xZ7kNSF0hAiAN4Jh6jgHCRS0Ga/+C8y6Au+oqpezRHCfPsmJ2+DwAgiwQ==
dependencies:
fs-monkey "1.0.3"
fs-monkey "^1.0.4"
memory-fs@^0.2.0:
version "0.2.0"
@ -9978,14 +9973,7 @@ minimatch@^5.0.1:
dependencies:
brace-expansion "^2.0.1"
minimatch@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
dependencies:
brace-expansion "^2.0.1"
minimatch@~9.0.3:
minimatch@^9.0.1, minimatch@~9.0.3:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
@ -11734,6 +11722,11 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rgb-hex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/rgb-hex/-/rgb-hex-4.1.0.tgz#b343f394c8f9df629a7504c34c00b5bc63bd006f"
integrity sha512-UZLM57BW09Yi9J1R3OP8B1yCbbDK3NT8BDtihGZkGkGEs2b6EaV85rsfJ6yK4F+8UbxFFmfA+9xHT5ZWhN1gDQ==
rimraf@^2.5.4, rimraf@^2.6.3:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
@ -13950,21 +13943,21 @@ webpack-cli@^4.10.0:
rechoir "^0.7.0"
webpack-merge "^5.7.3"
webpack-dev-middleware@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.1.tgz#aa079a8dedd7e58bfeab358a9af7dab304cee57f"
integrity sha512-81EujCKkyles2wphtdrnPg/QqegC/AtqNH//mQkBYSMqwFVCQrxM6ktB2O/SPlZy7LqeEfTbV3cZARGQz6umhg==
webpack-dev-middleware@^5.3.4:
version "5.3.4"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517"
integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==
dependencies:
colorette "^2.0.10"
memfs "^3.4.1"
memfs "^3.4.3"
mime-types "^2.1.31"
range-parser "^1.2.1"
schema-utils "^4.0.0"
webpack-dev-server@4.15.1:
version "4.15.1"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz#8944b29c12760b3a45bdaa70799b17cb91b03df7"
integrity sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==
webpack-dev-server@4.15.2:
version "4.15.2"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz#9e0c70a42a012560860adb186986da1248333173"
integrity sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==
dependencies:
"@types/bonjour" "^3.5.9"
"@types/connect-history-api-fallback" "^1.3.5"
@ -13994,7 +13987,7 @@ webpack-dev-server@4.15.1:
serve-index "^1.9.1"
sockjs "^0.3.24"
spdy "^4.0.2"
webpack-dev-middleware "^5.3.1"
webpack-dev-middleware "^5.3.4"
ws "^8.13.0"
webpack-merge@^5.7.3: