Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
a3b88e15d8
commit
20ec39e5bf
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
71
yarn.lock
71
yarn.lock
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue