Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-06-12 21:16:42 +00:00
parent d2ce6b490c
commit 4315ea0bc4
56 changed files with 1145 additions and 746 deletions

View File

@ -5,7 +5,7 @@ workflow:
name: $PIPELINE_NAME
include:
- component: "gitlab.com/gitlab-org/quality/pipeline-common/allure-report@8.15.1"
- component: "gitlab.com/gitlab-org/quality/pipeline-common/allure-report@8.16.0"
inputs:
job_name: "e2e-test-report"
job_stage: "report"
@ -15,7 +15,7 @@ include:
gitlab_auth_token_variable_name: "PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE"
allure_job_name: "${QA_RUN_TYPE}"
- project: gitlab-org/quality/pipeline-common
ref: 8.15.1
ref: 8.16.0
file:
- /ci/base.gitlab-ci.yml
- /ci/knapsack-report.yml

View File

@ -16,4 +16,4 @@ variables:
QA_OMNIBUS_MR_TESTS: "only-smoke"
# Retry failed specs in separate process
QA_RETRY_FAILED_SPECS: "true"
GITLAB_HELM_CHART_REF: "c7532b6e1ba98d5663b58012a879a781689db916" # helm chart ref used by test-on-cng pipeline
GITLAB_HELM_CHART_REF: "4c21d49231b948fa31f502feee136f688e3abf76" # helm chart ref used by test-on-cng pipeline

View File

@ -0,0 +1,119 @@
<!--
# README first!
This template covers steps required to do a Root Cause Analysis for unplanned upgrade stop.
Unplanned upgrade stop documentation: https://handbook.gitlab.com/handbook/engineering/unplanned-upgrade-stop/
Example RCA as a reference: https://gitlab.com/gitlab-org/gitlab/-/issues/423895
-->
## Summary
A brief summary of what happened. Try to make it as executive-friendly as possible.
- Upgrade path(s) affected:
- Upgrade type: downtime / zero downtime upgrade
- [Type of degradation](https://handbook.gitlab.com/handbook/engineering/unplanned-upgrade-stop/#unplanned-upgrade-types): Database migration error / Configuration changes / Breaking functionality changes
- Relevant bug issue:
- Team attribution: ~group::
## Impact & Metrics
Start with the following:
| Question | Answer |
| ----- | ----- |
| Who was impacted? | (i.e. customers with specific environment configurations or data ...) |
| How many customers affected? | |
Include any additional metrics that are of relevance.
## Detection & Response
Start with the following:
| Question | Answer |
| ----- | ----- |
| When was the issue detected? | YYYY-MM-DD |
| How was the issue detected? | (i.e. Support request, Bug raised, ...) |
| How long did it take from the start of the issue to its detection? | |
| How long did it take from detection to remediation? | |
| What steps were taken to remediate? | |
| Was patch release created? | |
| Was patch backported to older versions? | |
## Timeline
YYYY-MM-DD
- something happened
- something else happened
- ...
YYYY-MM-DD+1
- and then this happened
- and more happened
- ...
## Root Cause Analysis
The purpose of this document is to understand the reasons that caused an issue, and to create mechanisms to prevent it from recurring in the future. A root cause can **never be a person**, the way of writing has to refer to the system and the context rather than the specific actors.
### What is causing upgrade error
Start with the following:
- What is the cause of the upgrade error?
- What steps could be done to reproduce the upgrade error?
### What can be improved
DRI: Corresponding Engineering team
- Using the root cause analysis, explain what can be improved to prevent this from happening again.
- What changes to our tooling or review process would have prevented this issue?
- Is there an existing issue that would have either prevented this issue or reduced the impact?
- Did we have any indication or beforehand knowledge that this issue might take place?
DRI: Test Platform
- What is the cause of the test gap on integration and system level testing?
- What can be done to increase test coverage?
- Did relevant tests pass for this upgrade path?
## Corrective actions
- Link issues that have been created as corrective actions from this issue.
- For each issue, include the following:
- `<Issue link>` - Issue labeled as ~"corrective action" ~upgrades.
- An estimated date of completion of the corrective action. Priority of the issue should correspond with RCA severity.
## Guidelines
- [Blameless RCA Guideline](https://about.gitlab.com/handbook/customer-success/professional-services-engineering/workflows/internal/root-cause-analysis.html)
- [5 whys](https://en.wikipedia.org/wiki/5_Whys)
/confidential
/label ~RCA ~upgrades ~"type::ignore"
<!--
Workflow and other relevant labels
Select appropriate severity based on impact. For example:
~"severity::1" when unplanned upgrade stop needed to be added,
~"severity::2" when no unplanned stop was introduced but patch releases needed to be created
https://handbook.gitlab.com/handbook/engineering/infrastructure/engineering-productivity/issue-triage/#severity
-->
/label ~severity::
<!--
Specify Engineering group owning the bug related to this RCA
-->
/label ~group::
<!--
Link existing upgrade bug to this RCA
-->
/relate

View File

@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { debounce, throttle } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -19,7 +19,7 @@ import { InternalEvents } from '~/tracking';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { parseBoolean, handleLocationHash } from '~/lib/utils/common_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { BV_HIDE_TOOLTIP, DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory, getLocationHash } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@ -309,6 +309,13 @@ export default {
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
},
hideTooltips() {
const hide = () => {
if (!this.shouldShow) return;
this.$root.$emit(BV_HIDE_TOOLTIP);
};
return throttle(hide, 100);
},
},
watch: {
commit(newCommit, oldCommit) {
@ -387,6 +394,7 @@ export default {
this.subscribeToVirtualScrollingEvents();
window.addEventListener('hashchange', this.handleHashChange);
window.addEventListener('scroll', this.hideTooltips);
},
beforeCreate() {
diffsApp.instrument();
@ -414,6 +422,7 @@ export default {
this.removeEventListeners();
window.removeEventListener('hashchange', this.handleHashChange);
window.removeEventListener('scroll', this.hideTooltips);
diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);

View File

@ -256,6 +256,8 @@ export default {
this.idState.moreActionsShown = val;
},
toggleReview(newReviewedStatus) {
// this is the easiest way to hide an already open tooltip that triggers on focus
document.activeElement.blur();
const autoCollapsed =
this.isCollapsed && collapsedType(this.diffFile) === DIFF_FILE_AUTOMATIC_COLLAPSE;
const open = this.expanded;

View File

@ -26,7 +26,6 @@ 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';
@ -62,32 +61,36 @@ export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
this.availableButton = this.wrapperEl.querySelector('.available');
this.branchNameInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchInput = 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.sourceRefInput = this.wrapperEl.querySelector('.js-ref');
this.refInput = 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.suggestedRef = this.refName;
this.cancelSources = new Set();
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
this.projectPath = this.wrapperEl.dataset.projectPath;
this.projectId = this.wrapperEl.dataset.projectId;
// These regexps are used to replace
// a backend generated new branch name and its source (ref)
@ -111,53 +114,6 @@ 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');
@ -172,36 +128,17 @@ export default class CreateMergeRequestDropdown {
'click',
this.onClickCreateMergeRequestButton.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.branchInput.addEventListener('input', this.onChangeInput.bind(this));
this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
// Detect for example when user pastes ref using the mouse
this.sourceRefInput.addEventListener('input', this.onRefChange.bind(this));
this.refInput.addEventListener('input', this.onChangeInput.bind(this));
// Detect for example when user presses right arrow to apply the suggested ref
this.sourceRefInput.addEventListener('keyup', this.onRefChange.bind(this));
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
// Detect when user clicks inside the input to apply the suggested ref
this.sourceRefInput.addEventListener('click', this.onRefChange.bind(this));
this.refInput.addEventListener('click', this.onChangeInput.bind(this));
// Detect when user presses tab to apply the suggested ref
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;
}
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
checkAbilityToCreateBranch() {
@ -228,6 +165,8 @@ export default class CreateMergeRequestDropdown {
}
})
.catch(() => {
this.unavailable();
this.disable();
createAlert({
message: __('Failed to check related branches.'),
});
@ -235,21 +174,19 @@ 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.sourceProject.projectPath,
mergeUrlParams({ ref: this.refName, branch_name: this.branchName }, this.createBranchPath),
this.projectPath,
mergeUrlParams(
{ ref: this.refInput.value, branch_name: this.branchInput.value },
this.createBranchPath,
),
);
return axios
.post(endpoint, {
confidential_issue_project_id: canCreateConfidentialMergeRequest()
? this.sourceProject.projectId
: null,
confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null,
})
.then(({ data }) => {
this.branchCreated = true;
@ -273,14 +210,14 @@ export default class CreateMergeRequestDropdown {
.then(() => {
let path = canCreateConfidentialMergeRequest()
? this.createMrPath.replace(
this.targetProject.projectPath,
this.projectPath,
confidentialMergeRequestState.selectedProject.pathWithNamespace,
)
: this.createMrPath;
path = mergeUrlParams(
{
'merge_request[target_branch]': this.refName,
'merge_request[source_branch]': this.branchName,
'merge_request[target_branch]': this.refInput.value,
'merge_request[source_branch]': this.branchInput.value,
},
path,
);
@ -315,18 +252,6 @@ 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;
@ -370,17 +295,13 @@ export default class CreateMergeRequestDropdown {
}
getRef(ref, target = 'all') {
const source = axios.CancelToken.source();
this.cancelSources.add(source);
if (!ref) return false;
const project =
target === INPUT_TARGET_REF && !this.isBranchCreationMode
? this.targetProject
: this.sourceProject;
this.refCancelToken = axios.CancelToken.source();
return axios
.get(`${createEndpoint(project.projectPath, project.refsPath)}${encodeURIComponent(ref)}`, {
cancelToken: source.token,
.get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, {
cancelToken: this.refCancelToken.token,
})
.then(({ data }) => {
const branches = data[Object.keys(data)[0]];
@ -396,22 +317,33 @@ export default class CreateMergeRequestDropdown {
this.suggestedRef = result;
}
this.updateInputState(target, ref, result);
this.isGettingRef = false;
return this.updateInputState(target, ref, result);
})
.catch((thrown) => {
if (axios.isCancel(thrown)) {
return;
return false;
}
this.unavailable();
this.disable();
createAlert({
message: __('Failed to get ref.'),
});
})
.finally(() => {
this.cancelSources.delete(source);
this.isGettingRef = false;
return false;
});
}
getTargetData(target) {
return {
input: this[`${target}Input`],
message: this[`${target}Message`],
};
}
hide() {
this.wrapperEl.classList.add('hidden');
}
@ -440,53 +372,43 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
this.branchCreated
this.branchCreated ||
this.isGettingRef
);
}
beforeChange() {
onChangeInput(event) {
this.disable();
let target;
let value;
// User changed input, cancel to prevent previous request from interfering
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 (this.refCancelToken !== null) {
this.refCancelToken.cancel();
}
const value =
event.target.value.slice(0, event.target.selectionStart) +
event.target.value.slice(event.target.selectionEnd);
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;
}
this.handleChange(event, target, value);
}
if (this.isGettingRef) return false;
handleChange(event, target, value) {
// `ENTER` key submits the data.
if (event.keyCode === 13 && this.inputsAreValid()) {
event.preventDefault();
this.createMergeRequestButton.click();
return;
return this.createMergeRequestButton.click();
}
// If the input is empty, use the original value generated by the backend.
@ -500,18 +422,19 @@ export default class CreateMergeRequestDropdown {
this.enable();
this.showAvailableMessage(target);
this.refDebounce(value, target);
return;
return true;
}
if (target !== INPUT_TARGET_PROJECT) this.showCheckingMessage(target);
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());
@ -522,7 +445,6 @@ 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) {
@ -542,12 +464,12 @@ export default class CreateMergeRequestDropdown {
}
onClickSetFocusOnBranchNameInput() {
this.branchNameInput.focus();
this.branchInput.focus();
}
// `TAB` autocompletes the source.
static processTab(event) {
if (event.keyCode !== 9) return;
if (event.keyCode !== 9 || this.isGettingRef) return;
const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
@ -560,22 +482,6 @@ 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'];
@ -628,8 +534,13 @@ export default class CreateMergeRequestDropdown {
message.style.display = 'inline-block';
}
unavailable() {
this.availableButton.classList.add('hidden');
this.unavailableButton.classList.remove('hidden');
}
updateBranchName(suggestedBranchName) {
this.branchNameInput.value = suggestedBranchName;
this.branchInput.value = suggestedBranchName;
this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, '');
}
@ -648,20 +559,20 @@ export default class CreateMergeRequestDropdown {
}
updateRefInput(ref, result) {
this.sourceRefInput.dataset.value = ref;
this.refInput.dataset.value = ref;
if (ref === result) {
this.refIsValid = true;
this.showAvailableMessage(INPUT_TARGET_REF);
} else {
this.refIsValid = false;
this.sourceRefInput.dataset.value = ref;
this.refInput.dataset.value = ref;
this.disableCreateAction();
this.showNotAvailableMessage(INPUT_TARGET_REF);
// Show ref hint.
if (result) {
this.sourceRefInput.value = result;
this.sourceRefInput.setSelectionRange(ref.length, result.length);
this.refInput.value = result;
this.refInput.setSelectionRange(ref.length, result.length);
}
}
}

View File

@ -36,7 +36,7 @@ export default {
i18n,
DRAWER_Z_INDEX,
removeBlobsHelpLink: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', {
anchor: 'repository-cleanup',
anchor: 'get-a-list-of-object-ids',
}),
modalCancel: { text: i18n.modalCancelText },
components: { GlButton, GlDrawer, GlLink, GlFormTextarea, GlModal, GlFormInput },

View File

@ -1,23 +1,18 @@
import Api from '~/api';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import {
visitUrl,
setUrlParams,
getBaseURL,
queryToObject,
objectToQuery,
} from '~/lib/utils/url_utility';
import { visitUrl, setUrlParams, getNormalizedURL } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { SCOPE_BLOB } from '~/search/sidebar/constants';
import {
GROUPS_LOCAL_STORAGE_KEY,
PROJECTS_LOCAL_STORAGE_KEY,
SIDEBAR_PARAMS,
PRESERVED_PARAMS,
LOCAL_STORAGE_NAME_SPACE_EXTENSION,
} from './constants';
REGEX_PARAM,
LS_REGEX_HANDLE,
} from '~/search/store/constants';
import * as types from './mutation_types';
import {
loadDataFromLS,
@ -114,8 +109,8 @@ export const setQuery = ({ state, commit }, { key, value }) => {
commit(types.SET_SIDEBAR_DIRTY, isSidebarDirty(state.query, state.urlQuery));
}
if (PRESERVED_PARAMS.includes(key)) {
setDataToLS(`${key}_${LOCAL_STORAGE_NAME_SPACE_EXTENSION}`, value);
if (key === REGEX_PARAM) {
setDataToLS(LS_REGEX_HANDLE, value);
}
};
@ -152,26 +147,24 @@ export const setLabelFilterSearch = ({ commit }, { value }) => {
commit(types.SET_LABEL_SEARCH_STRING, value);
};
const injectWildCardSearch = (state, link) => {
const urlObject = new URL(`${getBaseURL()}${link}`);
if (!state.urlQuery.search) {
const queryObject = queryToObject(urlObject.search);
urlObject.search = objectToQuery({ ...queryObject, search: '*' });
}
return urlObject.href;
};
export const fetchSidebarCount = ({ commit, state }) => {
const items = Object.values(state.navigation)
.filter((navigationItem) => !navigationItem.active && navigationItem.count_link)
.map((navItem) => {
const navigationItem = { ...navItem };
const modifications = {
search: state.query?.search || '*',
};
if (navigationItem.count_link) {
navigationItem.count_link = injectWildCardSearch(state, navigationItem.count_link);
if (navigationItem.scope === SCOPE_BLOB && loadDataFromLS(LS_REGEX_HANDLE)) {
modifications[REGEX_PARAM] = true;
}
navigationItem.count_link = setUrlParams(
modifications,
getNormalizedURL(navigationItem.count_link),
);
return navigationItem;
});

View File

@ -22,10 +22,6 @@ export const SIDEBAR_PARAMS = [
export const REGEX_PARAM = 'regex';
export const PRESERVED_PARAMS = [REGEX_PARAM];
export const LOCAL_STORAGE_NAME_SPACE_EXTENSION = 'advanced_search';
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
export const ICON_MAP = {
@ -49,3 +45,5 @@ export const BASIC_SEARCH_TYPE = 'basic';
export const SEARCH_LEVEL_GLOBAL = 'global';
export const SEARCH_LEVEL_PROJECT = 'project';
export const SEARCH_LEVEL_GROUP = 'group';
export const LS_REGEX_HANDLE = `${REGEX_PARAM}_advanced_search`;

View File

@ -1,9 +1,13 @@
import { findKey, intersection } from 'lodash';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
import {
formatSearchResultCount,
addCountOverLimit,
injectRegexSearch,
} from '~/search/store/utils';
import { PROJECT_DATA } from '~/search/sidebar/constants';
import { PROJECT_DATA, SCOPE_BLOB } from '~/search/sidebar/constants';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants';
const queryLabelFilters = (state) => state?.query?.[labelFilterData.filterParam] || [];
@ -81,7 +85,7 @@ export const navigationItems = (state) =>
Object.values(state.navigation).map((item) => ({
title: item.label,
icon: ICON_MAP[item.scope] || '',
link: item.link,
link: item.scope === SCOPE_BLOB ? injectRegexSearch(item.link) : item.link,
is_active: Boolean(item?.active),
pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '',
items: [],

View File

@ -1,13 +1,15 @@
import { isEqual, orderBy } from 'lodash';
import { isEqual, orderBy, isEmpty } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { formatNumber } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import { joinPaths, queryToObject, objectToQuery, getBaseURL } from '~/lib/utils/url_utility';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
SIDEBAR_PARAMS,
NUMBER_FORMATING_OPTIONS,
REGEX_PARAM,
LS_REGEX_HANDLE,
} from './constants';
const LANGUAGE_AGGREGATION_NAME = languageFilterData.filterParam;
@ -164,3 +166,16 @@ export const prepareSearchAggregations = (state, aggregationData) =>
export const addCountOverLimit = (count = '') => {
return count.includes('+') ? '+' : '';
};
/** @param { string } link */
export const injectRegexSearch = (link) => {
const urlObject = new URL(link, getBaseURL());
const queryObject = queryToObject(urlObject.search);
if (loadDataFromLS(LS_REGEX_HANDLE) && (queryObject.project_id || queryObject.group_id)) {
queryObject[REGEX_PARAM] = true;
}
if (isEmpty(queryObject)) {
return urlObject.pathname;
}
return `${urlObject.pathname}?${objectToQuery(queryObject)}`;
};

View File

@ -10,10 +10,10 @@ import {
ZOEKT_SEARCH_TYPE,
ADVANCED_SEARCH_TYPE,
REGEX_PARAM,
LOCAL_STORAGE_NAME_SPACE_EXTENSION,
LS_REGEX_HANDLE,
} from '~/search/store/constants';
import { loadDataFromLS } from '~/search/store/utils';
import { SYNTAX_OPTIONS_ADVANCED_DOCUMENT, SYNTAX_OPTIONS_ZOEKT_DOCUMENT } from '../constants';
import { loadDataFromLS } from '../../store/utils';
import SearchTypeIndicator from './search_type_indicator.vue';
import GlSearchBoxByType from './search_box_by_type.vue';
@ -72,20 +72,10 @@ export default {
this.glFeatures.zoektExactSearch
);
},
localstorageReguralExpressionItem() {
return `${REGEX_PARAM}_${LOCAL_STORAGE_NAME_SPACE_EXTENSION}`;
},
loadRegexStateFromLocalStorage() {
return loadDataFromLS(this.localstorageReguralExpressionItem);
},
},
created() {
this.preloadStoredFrequentItems();
this.regexEnabled = this.loadRegexStateFromLocalStorage;
if (!this.urlQuery?.[REGEX_PARAM] && this.regexEnabled) {
this.addReguralExpressionToQuery(this.regexEnabled);
}
this.regexEnabled = loadDataFromLS(LS_REGEX_HANDLE);
},
methods: {
...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']),

View File

@ -28,6 +28,7 @@ import {
import modalKeyboardNavigationMixin from '~/vue_shared/mixins/modal_keyboard_navigation_mixin';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue';
import { injectRegexSearch } from '~/search/store/utils';
import {
EVENT_PRESS_ENTER_TO_ADVANCED_SEARCH,
EVENT_PRESS_ESCAPE_IN_COMMAND_PALETTE,
@ -255,7 +256,8 @@ export default {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
return;
}
visitUrl(this.searchQuery);
visitUrl(injectRegexSearch(this.searchQuery));
},
runFirstCommand() {
this.getFocusableOptions()[0]?.firstChild.click();

View File

@ -10,6 +10,7 @@ import {
EVENT_CLICK_GROUP_SCOPED_SEARCH_TO_ADVANCED_SEARCH,
EVENT_CLICK_PROJECT_SCOPED_SEARCH_TO_ADVANCED_SEARCH,
} from '~/super_sidebar/components/global_search/tracking_constants';
import { injectRegexSearch } from '~/search/store/utils';
import {
OVERLAY_SEARCH,
SCOPE_SEARCH_ALL,
@ -40,6 +41,7 @@ export default {
name: this.scopedSearchGroup.name,
items: this.scopedSearchGroup.items.map((item) => ({
...item,
href: item.text === SCOPE_SEARCH_PROJECT ? injectRegexSearch(item.href) : item.href,
scopeName: item.scope || item.description,
extraAttrs: {
class: 'show-hover-layover',

View File

@ -24,7 +24,12 @@ import {
COMMAND_PALETTE_FILES_CHAR,
} from '~/vue_shared/global_search/constants';
import { getFormattedItem } from '../utils';
import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants';
import {
TRACKING_CLICK_COMMAND_PALETTE_ITEM,
SCOPE_SEARCH_PROJECT,
SCOPE_SEARCH_GROUP,
SCOPE_SEARCH_ALL,
} from '../command_palette/constants';
import {
ICON_GROUP,
@ -178,7 +183,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext?.project) {
items.push({
text: 'scoped-in-project',
text: SCOPE_SEARCH_PROJECT,
scope: state.searchContext.project?.name || '',
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
@ -192,7 +197,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext?.group) {
items.push({
text: 'scoped-in-group',
text: SCOPE_SEARCH_GROUP,
scope: state.searchContext.group?.name || '',
scopeCategory: GROUPS_CATEGORY,
icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
@ -205,7 +210,7 @@ export const scopedSearchOptions = (state, getters) => {
}
items.push({
text: 'scoped-in-all',
text: SCOPE_SEARCH_ALL,
description: MSG_IN_ALL_GITLAB,
href: getters.allUrl,
extraAttrs: {

View File

@ -9,9 +9,10 @@ import {
GlLoadingIcon,
GlSprintf,
GlToggle,
GlTooltipDirective,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { __, s__, n__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import inboundAddGroupOrProjectCIJobTokenScope from '../graphql/mutations/inbound_add_group_or_project_ci_job_token_scope.mutation.graphql';
@ -41,17 +42,6 @@ export default {
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
},
fields: [
{
key: 'fullPath',
label: '',
},
{
key: 'actions',
label: '',
tdClass: 'gl-text-right',
},
],
components: {
GlAlert,
GlButton,
@ -64,6 +54,9 @@ export default {
GlToggle,
TokenAccessTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
fullPath: {
default: '',
@ -92,8 +85,13 @@ export default {
};
},
update({ project }) {
this.projects = project?.ciJobTokenScope?.inboundAllowlist?.nodes ?? [];
this.groups = project?.ciJobTokenScope?.groupsAllowlist?.nodes ?? [];
const projects = project?.ciJobTokenScope?.inboundAllowlist?.nodes ?? [];
const groups = project?.ciJobTokenScope?.groupsAllowlist?.nodes ?? [];
this.projectCount = projects.length;
this.groupCount = groups.length;
return [...groups, ...projects];
},
error() {
createAlert({ message: this.$options.i18n.projectsFetchError });
@ -103,19 +101,36 @@ export default {
data() {
return {
inboundJobTokenScopeEnabled: null,
targetPath: '',
projects: [],
groups: [],
groupsAndProjectsWithAccess: [],
groupOrProjectPath: '',
projectCount: 0,
groupCount: 0,
isAddFormVisible: false,
};
},
computed: {
isTargetPathEmpty() {
return this.targetPath === '';
isGroupOrProjectPathEmpty() {
return this.groupOrProjectPath === '';
},
ciJobTokenHelpPage() {
return helpPagePath('ci/jobs/ci_job_token#control-job-token-access-to-your-project');
},
groupCountTooltip() {
return sprintf(
n__('%{count} group has access', '%{count} groups have access', this.groupCount),
{
count: this.groupCount,
},
);
},
projectCountTooltip() {
return sprintf(
n__('%{count} project has access', '%{count} projects have access', this.projectCount),
{
count: this.projectCount,
},
);
},
},
methods: {
async updateCIJobTokenScope() {
@ -152,7 +167,7 @@ export default {
mutation: inboundAddGroupOrProjectCIJobTokenScope,
variables: {
projectPath: this.fullPath,
targetPath: this.targetPath,
targetPath: this.groupOrProjectPath,
},
});
@ -162,7 +177,7 @@ export default {
} catch (error) {
createAlert({ message: error.message });
} finally {
this.clearTargetPath();
this.clearGroupOrProjectPath();
this.getGroupsAndProjects();
}
},
@ -204,8 +219,8 @@ export default {
this.getGroupsAndProjects();
}
},
clearTargetPath() {
this.targetPath = '';
clearGroupOrProjectPath() {
this.groupOrProjectPath = '';
this.isAddFormVisible = false;
},
getGroupsAndProjects() {
@ -262,16 +277,28 @@ export default {
body-class="gl-new-card-body gl-px-0"
>
<template #header>
<div class="gl-new-card-title-wrapper">
<h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5>
<span class="gl-new-card-count">
<gl-icon name="group" class="gl-mr-2" />
{{ groups.length }}
</span>
<span class="gl-new-card-count">
<gl-icon name="project" class="gl-mr-2" />
{{ projects.length }}
</span>
<div class="gl-new-card-title-wrapper gl-flex-direction-column gl-flex-wrap">
<div class="gl-new-card-title gl-items-center">
<h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
<span
v-gl-tooltip
:title="groupCountTooltip"
class="gl-new-card-count"
data-testid="group-count"
>
<gl-icon name="group" class="gl-mr-2" />
{{ groupCount }}
</span>
<span
v-gl-tooltip
:title="projectCountTooltip"
class="gl-new-card-count"
data-testid="project-count"
>
<gl-icon name="project" class="gl-mr-2" />
{{ projectCount }}
</span>
</div>
</div>
<div class="gl-new-card-actions">
<gl-button
@ -287,35 +314,24 @@ export default {
<div v-if="isAddFormVisible" class="gl-new-card-add-form gl-m-3">
<h4 class="gl-mt-0">{{ $options.i18n.addGroupOrProject }}</h4>
<gl-form-input
v-model="targetPath"
v-model="groupOrProjectPath"
:placeholder="$options.i18n.addProjectPlaceholder"
/>
<div class="gl-display-flex gl-mt-5">
<gl-button
variant="confirm"
:disabled="isTargetPathEmpty"
:disabled="isGroupOrProjectPathEmpty"
class="gl-mr-3"
data-testid="add-project-btn"
@click="addGroupOrProject"
>
{{ $options.i18n.add }}
</gl-button>
<gl-button @click="clearTargetPath">{{ $options.i18n.cancel }}</gl-button>
<gl-button @click="clearGroupOrProjectPath">{{ $options.i18n.cancel }}</gl-button>
</div>
</div>
<token-access-table
:is-group="true"
:items="groups"
:table-fields="$options.fields"
@removeItem="removeItem"
/>
<token-access-table
:items="projects"
:table-fields="$options.fields"
@removeItem="removeItem"
/>
<token-access-table :items="groupsAndProjectsWithAccess" @removeItem="removeItem" />
</gl-card>
</div>
</template>

View File

@ -8,9 +8,10 @@ import {
GlLoadingIcon,
GlSprintf,
GlToggle,
GlTooltipDirective,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { __, s__, n__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
@ -46,17 +47,6 @@ export default {
deprecationDocumentationLink: helpPagePath('ci/jobs/ci_job_token', {
anchor: 'limit-your-projects-job-token-access',
}),
fields: [
{
key: 'fullPath',
label: __('Project that can be accessed'),
},
{
key: 'actions',
label: '',
tdClass: 'gl-text-right',
},
],
components: {
GlAlert,
GlButton,
@ -68,6 +58,9 @@ export default {
GlToggle,
TokenAccessTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
@ -121,6 +114,14 @@ export default {
disableTokenToggle() {
return !this.jobTokenScopeEnabled;
},
projectCountTooltip() {
return sprintf(
n__('%{count} project has access', '%{count} projects have access', this.projects.length),
{
count: this.projects.length,
},
);
},
},
methods: {
async updateCIJobTokenScope() {
@ -265,13 +266,13 @@ export default {
<div>
<gl-card
class="gl-new-card"
header-class="gl-new-card-header"
header-class="gl-new-card-header gl-border-b-0"
body-class="gl-new-card-body gl-px-0"
>
<template #header>
<div class="gl-new-card-title-wrapper">
<h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5>
<span class="gl-new-card-count">
<span v-gl-tooltip :title="projectCountTooltip" class="gl-new-card-count">
<gl-icon name="project" class="gl-mr-2" />
{{ projects.length }}
</span>
@ -280,11 +281,7 @@ export default {
<gl-button size="small" disabled>{{ $options.i18n.addProject }}</gl-button>
</div>
</template>
<token-access-table
:items="projects"
:table-fields="$options.fields"
@removeItem="removeProject"
/>
<token-access-table :items="projects" @removeItem="removeProject" />
</gl-card>
</div>
</template>

View File

@ -1,11 +1,27 @@
<script>
import { GlButton, GlTableLite } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { GlButton, GlIcon, GlLink, GlTableLite } from '@gitlab/ui';
import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
fields: [
{
key: 'fullPath',
label: '',
tdClass: 'gl-w-3/4',
},
{
key: 'actions',
label: '',
tdClass: 'gl-w-1/4 gl-text-right',
},
],
components: {
GlButton,
GlIcon,
GlLink,
GlTableLite,
ProjectAvatar,
},
inject: {
fullPath: {
@ -22,19 +38,11 @@ export default {
type: Array,
required: true,
},
tableFields: {
type: Array,
required: true,
},
},
computed: {
emptyText() {
return sprintf(s__('CI/CD|No %{itemType}s have been added to the scope'), {
itemType: this.itemType,
});
},
itemType() {
return this.isGroup ? 'group' : 'project';
methods: {
itemType(item) {
// eslint-disable-next-line no-underscore-dangle
return item.__typename === TYPENAME_GROUP ? 'group' : 'project';
},
},
};
@ -42,16 +50,34 @@ export default {
<template>
<gl-table-lite
:items="items"
:fields="tableFields"
:tbody-tr-attr="{ 'data-testid': `${itemType}s-token-table-row` }"
:empty-text="emptyText"
show-empty
stacked="sm"
:fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'token-access-table-row' }"
thead-class="gl-display-none"
class="gl-mb-0"
fixed
>
<template #cell(fullPath)="{ item }">
<span :data-testid="`token-access-${itemType}-name`">{{ item.fullPath }}</span>
<div class="gl-inline-flex gl-items-center">
<gl-icon
:name="itemType(item)"
class="gl-mr-3 gl-flex-shrink-0"
:data-testid="`token-access-${itemType(item)}-icon`"
/>
<project-avatar
:alt="item.name"
:project-avatar-url="item.avatarUrl"
:project-id="item.id"
:project-name="item.name"
class="gl-mr-3"
:data-testid="`token-access-${itemType(item)}-avatar`"
/>
<gl-link
class="gl-text-gray-900"
:href="`/${item.fullPath}`"
:data-testid="`token-access-${itemType(item)}-name`"
>{{ item.fullPath }}</gl-link
>
</div>
</template>
<template #cell(actions)="{ item }">

View File

@ -7,6 +7,7 @@ query inboundGetGroupsAndProjectsWithCIJobTokenScope($fullPath: ID!) {
id
name
fullPath
avatarUrl
}
}
inboundAllowlist {
@ -14,6 +15,7 @@ query inboundGetGroupsAndProjectsWithCIJobTokenScope($fullPath: ID!) {
id
name
fullPath
avatarUrl
}
}
}

View File

@ -280,10 +280,8 @@ export default {
>
<template v-if="isLoading">{{ $options.FETCH_LOADING }}</template>
<template v-else>
<div class="gl-display-flex gl-flex-direction-column">
<div
class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-align-items-baseline"
>
<div class="gl-flex gl-flex-col">
<div class="gl-flex gl-flex-col sm:gl-flex-row gl-gap-3 gl-items-baseline">
<div v-if="requireSamlAuthToApprove && showApprove">
<gl-form
ref="form"

View File

@ -1,6 +1,6 @@
.gl-new-card {
margin-top: $gl-spacing-scale-5;
background-color: $gray-10;
background-color: var(--gl-background-color-subtle);
border-width: $gl-border-size-1;
border-style: solid;
border-color: $gray-100;
@ -13,7 +13,7 @@
padding-bottom: $gl-spacing-scale-4;
display: flex;
justify-content: space-between;
background-color: $white;
background-color: var(--gl-background-color-default);
border-bottom-width: $gl-border-size-1;
border-bottom-style: solid;
border-bottom-color: $gray-100;
@ -88,14 +88,14 @@
}
&-footer {
background-color: $white;
background-color: var(--gl-background-color-default);
}
&-add-form {
padding: $gl-spacing-scale-4;
margin-top: $gl-spacing-scale-2;
margin-bottom: $gl-spacing-scale-2;
background-color: $white;
background-color: var(--gl-background-color-default);
border-width: $gl-border-size-1;
border-style: solid;
border-color: $gray-100;

View File

@ -4,6 +4,7 @@ module RendersNotes
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def prepare_notes_for_rendering(notes)
preload_noteable_for_regular_notes(notes)
preload_note_namespace(notes)
preload_max_access_for_authors(notes, @project)
preload_author_status(notes)
Notes::RenderService.new(current_user).execute(notes)
@ -14,6 +15,10 @@ module RendersNotes
private
def preload_note_namespace(notes)
ActiveRecord::Associations::Preloader.new(records: notes, associations: :namespace).call
end
def preload_max_access_for_authors(notes, project)
return unless project

View File

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

View File

@ -560,7 +560,7 @@ class Issue < ApplicationRecord
end
def can_be_worked_on?
!self.closed?
!self.closed? && !self.project.forked?
end
# Returns `true` if the current issue can be viewed by either a logged in User
@ -624,7 +624,10 @@ class Issue < ApplicationRecord
end
def banzai_render_context(field)
super.merge(label_url_method: :project_issues_url)
additional_attributes = { label_url_method: :project_issues_url }
additional_attributes[:group] = namespace if namespace.is_a?(Group)
super.merge(additional_attributes)
end
def design_collection

View File

@ -557,7 +557,10 @@ class Note < ApplicationRecord
end
def banzai_render_context(field)
super.merge(noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method)
additional_attributes = { noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method }
additional_attributes[:group] = namespace if namespace.is_a?(Group)
super.merge(additional_attributes)
end
def retrieve_upload(_identifier, paths)

View File

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

View File

@ -5,14 +5,12 @@
- 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(default_project.namespace, default_project, search: '')
- refs_path = refs_namespace_project_path(@project.namespace, @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.gl-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 } }
.create-mr-dropdown-wrap.gl-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 } }
.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-hidden')
@ -28,10 +26,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' } })
%form.droplab-dropdown
.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.js-type-toggle.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
%li.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?
@ -39,7 +37,7 @@
- else
= _('Create merge request and branch')
%li.js-type-toggle{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
%li{ 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')
@ -48,13 +46,6 @@
%li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5
- if can_create_confidential_merge_request?
#js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index') } }
- if @project.forked? && can_create_merge_request
.form-group
%label{ for: 'target-project' }
= _('Target project')
= select nil, nil, options_for_select(merge_request_target_projects_options(@issue, @project, default_create_mr_path), refs_path), {}, id: 'target-project', class: 'js-target-project form-control gl-form-select custom-select'
.form-group
%label{ for: 'new-branch-name' }
= _('Branch name')

View File

@ -1,7 +1,7 @@
%h5.gl-heading-4= s_('ProjectMaintenance|Remove blobs')
%p.gl-text-secondary
= s_('ProjectMaintenance|Provide a list of blob object IDs to be removed.')
= link_to s_('ProjectMaintenance|How do I get a list of object IDs?'), help_page_path('user/project/repository/reducing_the_repo_size_using_git', anchor: 'repository-cleanup')
= link_to s_('ProjectMaintenance|How do I get a list of object IDs?'), help_page_path('user/project/repository/reducing_the_repo_size_using_git', anchor: 'get-a-list-of-object-ids')
%p.gl-text-secondary
= s_('ProjectMaintenance|Afterwards, run housekeeping manually to remove old versions of the file from Git history.')

View File

@ -5,5 +5,5 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150407
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/444929
milestone: '17.0'
group: group::authentication
type: gitlab_com_derisk
default_enabled: false
type: ops
default_enabled: true

View File

@ -43,6 +43,10 @@ As part of the plan to switch to the new server, reindex all [affected indexes](
This approach was used for GitLab.com. To learn more about this process and how the different types of indexes were handled, see the blog post about [upgrading the operating system on our Postgres database clusters](https://about.gitlab.com/blog/2022/08/12/upgrading-database-os/).
After reindexing bad indexes, the collation must be refreshed.
To update the system catalog to record the current collation version,
run the query `ALTER COLLATION <collation_name> REFRESH VERSION`.
**Advantages**:
- Downtime is shorter: the time to perform the necessary reindexing, plus validation.

View File

@ -82,8 +82,8 @@ Some of the scenarios where these `Security::Finding` records may be promoted to
If the pipeline ran on the default branch then the following steps, in addition to the steps in [Scan runs in a pipeline for a non-default branch](#scan-runs-in-a-pipeline-for-a-non-default-branch), are executed:
1. `Security::StoreScansService` gets called and schedules `StoreSecurityReportsWorker`.
1. `StoreSecurityReportsWorker` executes `Security::Ingestion::IngestReportsService`.
1. `Security::StoreScansService` gets called and schedules `StoreSecurityReportsByProjectWorker`.
1. `StoreSecurityReportsByProjectWorker` executes `Security::Ingestion::IngestReportsService`.
1. `Security::Ingestion::IngestReportsService` takes all reports from a given Pipeline and calls `Security::Ingestion::IngestReportService` and then calls `Security::Ingestion::MarkAsResolvedService`.
1. `Security::Ingestion::IngestReportService` calls `Security::Ingestion::IngestReportSliceService` which executes a number of tasks for a report slice.

View File

@ -236,6 +236,67 @@ When using repository cleanup, note:
[Clearing the instance cache](../../../administration/raketasks/maintenance.md#clear-redis-cache)
may help to remove some of them, but it should not be depended on for security purposes!
## Remove blobs
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) in GitLab 17.1 [with a flag](../../../administration/feature_flags.md) named `rewrite_history_ui`. Disabled by default.
FLAG:
The availability of this feature is controlled by a feature flag.
For more information, see the history.
This feature is available for testing, but not ready for production use.
Permanently delete sensitive or confidential information that was accidentally committed, ensuring
it's no longer accessible in your repository's history.
Prerequisites:
- You must have the Owner role for the instance.
- You must have [a list of object IDs](#get-a-list-of-object-ids) to remove.
To remove blobs from your repository:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > Repository**.
1. Expand **Repository maintenance**.
1. Select **Remove blobs**.
1. On the drawer, enter a list of blob IDs to remove, each ID on its own line.
1. Select **Remove blobs**.
1. On the confirmation dialog, enter your project path.
1. Select **Yes, remove blobs**.
1. On the left sidebar, select **Settings > General**.
1. Expand the section labeled **Advanced**.
1. Select **Run housekeeping**.
### Get a list of object IDs
To remove blobs, you need a list of objects to remove.
To get these IDs, use the Git `ls-tree command`.
Prerequisites:
- You must have the repository cloned to your local machine.
For example, to get a list of files at a given commit or branch sorted by size:
1. Open a terminal and go to your repository directory.
1. Run the following command:
```shell
git ls-tree -r -t --long --full-name <COMMIT/BRANCH> | sort -nk 4
```
Example output:
```plaintext
100644 blob 8150ee86f923548d376459b29afecbe8495514e9 133508 doc/howto/img/remote-development-new-workspace-button.png
100644 blob cde4360b3d3ee4f4c04c998d43cfaaf586f09740 214231 doc/howto/img/dependency_proxy_macos_config_new.png
100644 blob 2ad0e839a709e73a6174e78321e87021b20be445 216452 doc/howto/img/gdk-in-gitpod.jpg
100644 blob 115dd03fc0828a9011f012abbc58746f7c587a05 242304 doc/howto/img/gitpod-button-repository.jpg
100644 blob c41ebb321a6a99f68ee6c353dd0ed29f52c1dc80 491158 doc/howto/img/dependency_proxy_macos_config.png
```
The third column in the output is the object ID of the blob.
## Storage limits
Repository size limits:

View File

@ -20,6 +20,7 @@ module Banzai
[
Filter::References::UserReferenceFilter,
Filter::References::IssueReferenceFilter,
Filter::References::WorkItemReferenceFilter,
Filter::References::ExternalIssueReferenceFilter,
Filter::References::MergeRequestReferenceFilter,
Filter::References::SnippetReferenceFilter,

View File

@ -675,6 +675,11 @@ msgid_plural "%{count} groups"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} group has access"
msgid_plural "%{count} groups have access"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} issue"
msgid_plural "%{count} issues"
msgstr[0] ""
@ -718,6 +723,11 @@ msgid_plural "%{count} projects"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} project has access"
msgid_plural "%{count} projects have access"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr ""
@ -9971,9 +9981,6 @@ msgstr ""
msgid "CI/CD limits"
msgstr ""
msgid "CI/CD|No %{itemType}s have been added to the scope"
msgstr ""
msgid "CICDAnalytics|%{percent}%{percentSymbol}"
msgstr ""
@ -40767,9 +40774,6 @@ msgstr ""
msgid "Project slug"
msgstr ""
msgid "Project that can be accessed"
msgstr ""
msgid "Project uploads"
msgstr ""
@ -52184,9 +52188,6 @@ msgstr ""
msgid "Target branches"
msgstr ""
msgid "Target project"
msgstr ""
msgid "Target project cannot be equal to source project"
msgstr ""

View File

@ -138,6 +138,11 @@ FactoryBot.define do
noteable { association(:work_item, project: project) }
end
trait :on_group_work_item do
project { nil }
noteable { association(:work_item, :group_level) }
end
trait :on_merge_request do
noteable { association(:merge_request, source_project: project) }
end

View File

@ -3,284 +3,240 @@
require 'spec_helper'
RSpec.describe 'User creates branch and merge request on issue page', :js, feature_category: :team_planning do
include ProjectForksHelper
let(:user) { create(:user) }
let(:membership_level) { :developer }
let(:user) { create(:user) }
let!(:project) { create(:project, :repository, :public) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
shared_examples "user creates branch and merge request from issue" do
context 'when signed out' do
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
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
# 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')
context 'when signed in' do
before do
project.add_member(user, membership_level)
button_toggle_dropdown.click
sign_in(user)
end
dropdown = find('.create-merge-request-dropdown-menu')
context 'when interacting with the dropdown' do
before do
visit project_issue_path(project, issue)
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']")
# 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 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')
button_toggle_dropdown.click
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)
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.
# The button inside dropdown 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')
# The top level button should be disabled if any errors occurred.
expect(page).to have_button('Create branch', disabled: true)
end
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
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')
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
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 }))
end
end
context 'when branch name is custom' do
let(:branch_name) { 'custom-branch-name' }
it 'creates a branch' do
select_dropdown_option('create-branch')
it 'creates a merge request', :sidekiq_might_not_need_inline do
perform_enqueued_jobs do
select_dropdown_option('create-mr', branch_name)
wait_for_requests
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
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)
select_dropdown_option('create-branch', branch_name, source_branch)
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)
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?')
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
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')
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_content(branch_name)
expect(page).to have_text("Can't contain spaces, ~, ^, ?")
end
end
context 'when user is guest' do
let(:membership_level) { :guest }
context 'when creating a merge request', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-mr'
end
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
context 'when creating a branch', :sidekiq_might_not_need_inline do
it_behaves_like 'has error message', 'create-branch'
end
end
end
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') }
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
include_examples 'user creates branch and merge request from issue'
end
let(:referenced_mr) do
create(
:merge_request,
:simple,
source_project: project,
target_project: project,
description: "Fixes #{issue.to_reference}",
author: user
)
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
project.add_member(user, membership_level)
sign_in(user)
referenced_mr.cache_merge_request_closes_issues!(user)
visit project_issue_path(project, issue)
wait_for_requests
end
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 }))
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 '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 }))
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

View File

@ -81,7 +81,6 @@ export default (
return Promise.resolve();
};
const validateResults = () => {
expect({
mutations,

View File

@ -1,5 +1,5 @@
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createWrapper, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@ -31,7 +31,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { Mousetrap } from '~/lib/mousetrap';
import * as urlUtils from '~/lib/utils/url_utility';
import * as commonUtils from '~/lib/utils/common_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { BV_HIDE_TOOLTIP, DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { stubPerformanceWebAPI } from 'helpers/performance';
import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
import waitForPromises from 'helpers/wait_for_promises';
@ -1049,4 +1049,26 @@ describe('diffs/components/app', () => {
});
});
});
describe('tooltips', () => {
const scroll = () => {
const scrollEvent = document.createEvent('Event');
scrollEvent.initEvent('scroll', true, true, window, 1);
window.dispatchEvent(scrollEvent);
};
it('hides tooltips on scroll', () => {
createComponent({ props: { shouldShow: true } });
const rootWrapper = createWrapper(wrapper.vm.$root);
scroll();
expect(rootWrapper.emitted(BV_HIDE_TOOLTIP)).toStrictEqual([[]]);
});
it('does not hide tooltips on scroll when invisible', () => {
createComponent({ props: { shouldShow: false } });
const rootWrapper = createWrapper(wrapper.vm.$root);
scroll();
expect(rootWrapper.emitted(BV_HIDE_TOOLTIP)).toStrictEqual(undefined);
});
});
});

View File

@ -545,6 +545,7 @@ describe('DiffFileHeader component', () => {
describe('file reviews', () => {
it('calls the action to set the new review', () => {
jest.spyOn(document.activeElement, 'blur');
createComponent({
props: {
diffFile: {
@ -564,6 +565,8 @@ describe('DiffFileHeader component', () => {
findReviewFileCheckbox().vm.$emit('change', true);
expect(document.activeElement.blur).toHaveBeenCalled();
return testAction(
reviewFile,
{ file, reviewed: true },

View File

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

View File

@ -122,6 +122,12 @@ export const MOCK_NAVIGATION = {
blobs: {
label: 'Code',
scope: 'blobs',
link: '/search?scope=blobs&search=et&group_id=123',
count_link: '/search/count?scope=blobs&group_id=123&search=et',
},
blobs2: {
label: 'Code2',
scope: 'blobs',
link: '/search?scope=blobs&search=et',
count_link: '/search/count?scope=blobs&search=et',
},
@ -489,6 +495,14 @@ export const MOCK_NAVIGATION_ITEMS = [
{
title: 'Code',
icon: 'code',
link: '/search?scope=blobs&search=et&group_id=123&regex=true',
is_active: false,
pill_count: '0',
items: [],
},
{
title: 'Code2',
icon: 'code',
link: '/search?scope=blobs&search=et',
is_active: false,
pill_count: '0',

View File

@ -5,6 +5,7 @@ import Api from '~/api';
import { createAlert } from '~/alert';
import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as urlUtils from '~/lib/utils/url_utility';
import * as actions from '~/search/store/actions';
@ -13,7 +14,7 @@ import {
PROJECTS_LOCAL_STORAGE_KEY,
SIDEBAR_PARAMS,
REGEX_PARAM,
LOCAL_STORAGE_NAME_SPACE_EXTENSION,
LS_REGEX_HANDLE,
} from '~/search/store/constants';
import * as types from '~/search/store/mutation_types';
import createState from '~/search/store/state';
@ -29,6 +30,7 @@ import {
PRELOAD_EXPECTED_MUTATIONS,
PROMISE_ALL_EXPECTED_MUTATIONS,
MOCK_NAVIGATION_DATA,
MOCK_NAVIGATION,
MOCK_NAVIGATION_ACTION_MUTATION,
MOCK_ENDPOINT_RESPONSE,
MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION,
@ -38,18 +40,6 @@ import {
} from '../mock_data';
jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
setUrlParams: jest.fn(),
joinPaths: jest.fn().mockReturnValue(''),
visitUrl: jest.fn(),
queryToObject: jest.fn().mockReturnValue({ scope: 'projects', search: '' }),
objectToQuery: jest.fn((params) =>
Object.keys(params)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&'),
),
getBaseURL: jest.fn().mockReturnValue('http://gdk.test:3000'),
}));
jest.mock('~/lib/logger', () => ({
logError: jest.fn(),
@ -203,28 +193,32 @@ describe('Global Search Store Actions', () => {
});
it(`setsItem in local storage`, () => {
expect(storeUtils.setDataToLS).toHaveBeenCalledWith(
`${payload.key}_${LOCAL_STORAGE_NAME_SPACE_EXTENSION}`,
expect.anything(),
);
expect(storeUtils.setDataToLS).toHaveBeenCalledWith(LS_REGEX_HANDLE, expect.anything());
});
});
});
describe('applyQuery', () => {
beforeEach(() => {
setWindowLocation('https://test/');
jest.spyOn(urlUtils, 'visitUrl').mockReturnValue({});
});
it('calls visitUrl and setParams with the state.query', async () => {
await testAction(actions.applyQuery, null, state, [], []);
expect(urlUtils.setUrlParams).toHaveBeenCalledWith(
{ ...state.query, page: null },
'http://test.host/',
false,
true,
expect(urlUtils.visitUrl).toHaveBeenCalledWith(
'https://test/?scope=issues&state=all&group_id=1&language%5B%5D=C&language%5B%5D=JavaScript&labels%5B%5D=60&labels%5B%5D=37&search=*',
);
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
describe('resetQuery', () => {
beforeEach(() => {
setWindowLocation('https://test/');
jest.spyOn(urlUtils, 'visitUrl').mockReturnValue({});
jest.spyOn(urlUtils, 'setUrlParams').mockReturnValue({});
});
it('calls visitUrl and setParams with empty values', async () => {
await testAction(actions.resetQuery, null, state, [], []);
const resetParams = SIDEBAR_PARAMS.reduce((acc, param) => {
@ -327,6 +321,9 @@ describe('Global Search Store Actions', () => {
state.urlQuery = {
scope,
};
state.query = {
search: 'et',
};
if (axiosMock.method) {
mock[axiosMock.method]().reply(axiosMock.code, MOCK_ENDPOINT_RESPONSE);
@ -360,17 +357,15 @@ describe('Global Search Store Actions', () => {
describe('fetchSidebarCount uses wild card seach', () => {
beforeEach(() => {
state.navigation = mapValues(MOCK_NAVIGATION_DATA, (navItem) => ({
...navItem,
count_link: '/search/count?scope=projects&search=',
}));
state.navigation = MOCK_NAVIGATION;
state.urlQuery.search = '';
});
it('should use wild card', async () => {
await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] });
expect(mock.history.get[0].url).toBe(
'http://gdk.test:3000/search/count?scope=projects&search=*',
expect(mock.history.get[0].url).toBe('http://test.host/search/count?scope=projects&search=*');
expect(mock.history.get[3].url).toBe(
'http://test.host/search/count?scope=merge_requests&search=*',
);
});
});

View File

@ -1,5 +1,9 @@
import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import {
GROUPS_LOCAL_STORAGE_KEY,
PROJECTS_LOCAL_STORAGE_KEY,
LS_REGEX_HANDLE,
} from '~/search/store/constants';
import * as getters from '~/search/store/getters';
import createState from '~/search/store/state';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
@ -71,6 +75,8 @@ describe('Global Search Store Getters', () => {
describe('navigationItems', () => {
it('returns the re-mapped navigation data', () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(true));
state.navigation = MOCK_NAVIGATION;
expect(getters.navigationItems(state)).toStrictEqual(MOCK_NAVIGATION_ITEMS);
});

View File

@ -1,5 +1,5 @@
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { MAX_FREQUENCY, SIDEBAR_PARAMS } from '~/search/store/constants';
import { MAX_FREQUENCY, SIDEBAR_PARAMS, LS_REGEX_HANDLE } from '~/search/store/constants';
import {
loadDataFromLS,
setFrequentItemToLS,
@ -9,8 +9,10 @@ import {
getAggregationsUrl,
prepareSearchAggregations,
addCountOverLimit,
injectRegexSearch,
} from '~/search/store/utils';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import {
MOCK_LS_KEY,
MOCK_GROUPS,
@ -303,4 +305,55 @@ describe('Global Search Store Utils', () => {
expect(addCountOverLimit()).toEqual('');
});
});
describe('injectRegexSearch', () => {
describe.each`
urlIn | urlOut
${`${TEST_HOST}/search?search=test&group_id=123`} | ${'/search?search=test&group_id=123'}
${'/search?search=test&group_id=123'} | ${'/search?search=test&group_id=123'}
${`${TEST_HOST}/search?search=test&project_id=123`} | ${'/search?search=test&project_id=123'}
${'/search?search=test&project_id=123'} | ${'/search?search=test&project_id=123'}
${`${TEST_HOST}/search?search=test&project_id=123&group_id=123`} | ${'/search?search=test&project_id=123&group_id=123'}
${'/search?search=test&project_id=123&group_id=123'} | ${'/search?search=test&project_id=123&group_id=123'}
`('modifies urls and links', ({ urlIn, urlOut }) => {
it(`should add regex=true to ${urlIn}`, () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(true));
expect(injectRegexSearch(urlIn)).toEqual(`${urlOut}&regex=true`);
});
it(`should NOT add regex=true to ${urlIn}`, () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(false));
expect(injectRegexSearch(urlIn)).toEqual(urlOut);
});
});
describe.each`
urlIn | urlOut
${'/search?search=test'} | ${'/search?search=test'}
${`${TEST_HOST}/search?search=test`} | ${'/search?search=test'}
${'/'} | ${'/'}
`('does not modify urls and links', ({ urlIn, urlOut }) => {
it('should return link with regex equals true', () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(true));
expect(injectRegexSearch(urlIn)).toEqual(urlOut);
});
});
describe.each`
urlIn | urlOut
${'/search?search=test&group_id=123&regex=true'} | ${'/search?search=test&group_id=123&regex=true'}
${'/search?search=test&project_id=123&regex=true'} | ${'/search?search=test&project_id=123&regex=true'}
${'/search?search=test&project_id=123&group_id=123&regex=true'} | ${'/search?search=test&project_id=123&group_id=123&regex=true'}
`('does not double params', ({ urlIn, urlOut }) => {
it(`should not add additional regex=true to ${urlIn} if enabled`, () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(true));
expect(injectRegexSearch(urlIn)).toEqual(`${urlOut}`);
});
it(`should NOT remove regex=true from ${urlIn} if disabled`, () => {
localStorage.setItem(LS_REGEX_HANDLE, JSON.stringify(false));
expect(injectRegexSearch(urlIn)).toEqual(urlOut);
});
});
});
});

View File

@ -19,6 +19,7 @@ Vue.use(Vuex);
jest.mock('~/search/store/utils', () => ({
loadDataFromLS: jest.fn(() => true),
LS_REGEX_HANDLE: jest.fn(() => 'test'),
}));
describe('GlobalSearchTopbar', () => {
@ -184,7 +185,7 @@ describe('GlobalSearchTopbar', () => {
describe.each`
search | reload
${''} | ${0}
${'test'} | ${2}
${'test'} | ${1}
`('clicking regular expression button', ({ search, reload }) => {
beforeEach(() => {
createComponent({ query: { search }, searchType: 'zoekt' }, '', { GlSearchBoxByType });

View File

@ -13,6 +13,7 @@ import CommandPaletteItems from '~/super_sidebar/components/global_search/comman
import CommandsOverviewDropdown from '~/super_sidebar/components/global_search/command_palette/command_overview_dropdown.vue';
import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue';
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import {
SEARCH_OR_COMMAND_MODE_PLACEHOLDER,
COMMON_HANDLES,
@ -52,6 +53,14 @@ Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
queryToObject: jest.fn(),
objectToQuery: jest.fn(() => 'search=test'),
isRootRelative: jest.fn(),
getBaseURL: jest.fn(() => 'https://gdk.test:3000'),
}));
jest.mock('~/search/store/utils.js', () => ({
injectRegexSearch: jest.fn(() => '/search?search=test'),
}));
const triggerKeydownEvent = (target, code, metaKey = false) => {
@ -136,6 +145,9 @@ describe('GlobalSearchModal', () => {
const findCommandPaletteDropdown = () => wrapper.findComponent(CommandsOverviewDropdown);
describe('template', () => {
beforeEach(() => {
useMockLocationHelper();
});
describe('always renders', () => {
beforeEach(() => {
createComponent();
@ -379,6 +391,7 @@ describe('GlobalSearchModal', () => {
it('will submit a search with the sufficient number of characters', () => {
createComponent();
findGlobalSearchInput().vm.$emit('input', MOCK_SEARCH);
submitSearch();
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});

View File

@ -15,6 +15,12 @@ import {
MSG_IN_ALL_GITLAB,
} from '~/vue_shared/global_search/constants';
import {
SCOPE_SEARCH_PROJECT,
SCOPE_SEARCH_GROUP,
SCOPE_SEARCH_ALL,
} from '~/super_sidebar/components/global_search/command_palette/constants';
export const MOCK_USERNAME = 'anyone';
export const MOCK_SEARCH_PATH = '/search';
@ -51,7 +57,7 @@ export const MOCK_SUBGROUP = {
path: `${MOCK_GROUP}/mock-subgroup`,
};
export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
export const MOCK_SEARCH_QUERY = '/search?search=test';
export const MOCK_SEARCH = 'test';
@ -104,7 +110,7 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [
];
export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
{
text: 'scoped-in-project',
text: SCOPE_SEARCH_PROJECT,
scope: MOCK_PROJECT.name,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
@ -115,7 +121,7 @@ export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
},
},
{
text: 'scoped-in-group',
text: SCOPE_SEARCH_GROUP,
scope: MOCK_GROUP.name,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_GROUP,
@ -126,7 +132,7 @@ export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
},
},
{
text: 'scoped-in-all',
text: SCOPE_SEARCH_ALL,
description: MSG_IN_ALL_GITLAB,
href: MOCK_ALL_PATH,
extraAttrs: {
@ -137,7 +143,7 @@ export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
];
export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
text: 'scoped-in-project',
text: SCOPE_SEARCH_PROJECT,
scope: MOCK_PROJECT.name,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
@ -151,7 +157,7 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
url: MOCK_PROJECT_LONG.path,
},
{
text: 'scoped-in-group',
text: SCOPE_SEARCH_GROUP,
scope: MOCK_GROUP.name,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_GROUP,
@ -165,7 +171,7 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
url: MOCK_SUBGROUP.path,
},
{
text: 'scoped-in-all',
text: SCOPE_SEARCH_ALL,
description: MSG_IN_ALL_GITLAB,
url: MOCK_ALL_PATH,
},
@ -174,21 +180,21 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
export const MOCK_SCOPED_SEARCH_GROUP = {
items: [
{
text: 'scoped-in-project',
text: SCOPE_SEARCH_PROJECT,
scope: MOCK_PROJECT.name,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
href: MOCK_PROJECT.path,
},
{
text: 'scoped-in-group',
text: SCOPE_SEARCH_GROUP,
scope: MOCK_GROUP.name,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_GROUP,
href: MOCK_GROUP.path,
},
{
text: 'scoped-in-all',
text: SCOPE_SEARCH_ALL,
description: MSG_IN_ALL_GITLAB,
href: MOCK_ALL_PATH,
},

View File

@ -16,6 +16,7 @@ import {
inboundJobTokenScopeEnabledResponse,
inboundJobTokenScopeDisabledResponse,
inboundGroupsAndProjectsWithScopeResponse,
inboundGroupsAndProjectsWithScopeResponseWithAddedItem,
inboundAddGroupOrProjectSuccessResponse,
inboundRemoveGroupSuccess,
inboundRemoveProjectSuccess,
@ -232,7 +233,7 @@ describe('TokenAccess component', () => {
type | testPath
${'group'} | ${testGroupPath}
${'project'} | ${testProjectPath}
`('add $type', ({ testPath }) => {
`('add $type', ({ type, testPath }) => {
it(`calls add group or project mutation`, async () => {
createComponent(
[
@ -261,6 +262,44 @@ describe('TokenAccess component', () => {
});
});
it(`increments the ${type} count`, async () => {
createComponent(
[
[inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
[
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
jest
.fn()
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponse)
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponseWithAddedItem),
],
[
inboundAddGroupOrProjectCIJobTokenScopeMutation,
inboundAddGroupOrProjectSuccessResponseHandler,
],
],
mountExtended,
);
await waitForPromises();
expect(wrapper.findByTestId(`${type}-count`).text()).toBe('1');
expect(wrapper.findByTestId(`${type}-count`).attributes('title')).toBe(
`1 ${type} has access`,
);
await findToggleFormBtn().trigger('click');
await findProjectInput().vm.$emit('input', testPath);
findAddProjectBtn().trigger('click');
await waitForPromises();
expect(wrapper.findByTestId(`${type}-count`).text()).toBe('2');
expect(wrapper.findByTestId(`${type}-count`).attributes('title')).toBe(
`2 ${type}s have access`,
);
});
it('add group or project handles error correctly', async () => {
createComponent(
[
@ -343,6 +382,39 @@ describe('TokenAccess component', () => {
});
});
it(`decrements the ${type} count`, async () => {
createComponent(
[
[inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
[
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
jest
.fn()
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponseWithAddedItem)
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponse),
],
[mutation, handler],
],
mountExtended,
);
await waitForPromises();
expect(wrapper.findByTestId(`${type}-count`).text()).toBe('2');
expect(wrapper.findByTestId(`${type}-count`).attributes('title')).toBe(
`2 ${type}s have access`,
);
findRemoveProjectBtnAt(index).trigger('click');
await waitForPromises();
expect(wrapper.findByTestId(`${type}-count`).text()).toBe('1');
expect(wrapper.findByTestId(`${type}-count`).attributes('title')).toBe(
`1 ${type} has access`,
);
});
it(`remove ${type} handles error correctly`, async () => {
createComponent(
[

View File

@ -1,7 +1,7 @@
export const enabledJobTokenScope = {
data: {
project: {
id: '1',
id: 1,
ciCdSettings: {
jobTokenScopeEnabled: true,
__typename: 'ProjectCiCdSetting',
@ -14,7 +14,7 @@ export const enabledJobTokenScope = {
export const disabledJobTokenScope = {
data: {
project: {
id: '1',
id: 1,
ciCdSettings: {
jobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
@ -28,14 +28,14 @@ export const projectsWithScope = {
data: {
project: {
__typename: 'Project',
id: '1',
id: 1,
ciJobTokenScope: {
__typename: 'CiJobTokenScopeType',
projects: {
__typename: 'ProjectConnection',
nodes: [
{
id: '2',
id: 2,
fullPath: 'root/332268-test',
name: 'root/332268-test',
namespace: {
@ -141,7 +141,7 @@ export const mockFields = [
export const inboundJobTokenScopeEnabledResponse = {
data: {
project: {
id: '1',
id: 1,
ciCdSettings: {
inboundJobTokenScopeEnabled: true,
__typename: 'ProjectCiCdSetting',
@ -154,7 +154,7 @@ export const inboundJobTokenScopeEnabledResponse = {
export const inboundJobTokenScopeDisabledResponse = {
data: {
project: {
id: '1',
id: 1,
ciCdSettings: {
inboundJobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
@ -168,7 +168,7 @@ export const inboundGroupsAndProjectsWithScopeResponse = {
data: {
project: {
__typename: 'Project',
id: '1',
id: 1,
ciJobTokenScope: {
__typename: 'CiJobTokenScopeType',
inboundAllowlist: {
@ -179,7 +179,7 @@ export const inboundGroupsAndProjectsWithScopeResponse = {
fullPath: 'root/ci-project',
id: 'gid://gitlab/Project/23',
name: 'ci-project',
namespace: { id: 'gid://gitlab/Namespaces::UserNamespace/1', fullPath: 'root' },
avatarUrl: '',
},
],
},
@ -191,6 +191,43 @@ export const inboundGroupsAndProjectsWithScopeResponse = {
fullPath: 'root/ci-group',
id: 'gid://gitlab/Group/45',
name: 'ci-group',
avatarUrl: '',
},
],
},
},
},
},
};
export const inboundGroupsAndProjectsWithScopeResponseWithAddedItem = {
data: {
project: {
...inboundGroupsAndProjectsWithScopeResponse.data.project,
ciJobTokenScope: {
inboundAllowlist: {
nodes: [
...inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope
.inboundAllowlist.nodes,
{
__typename: 'Project',
fullPath: 'root/test',
id: 'gid://gitlab/Project/25',
name: 'test',
avatarUrl: '',
},
],
},
groupsAllowlist: {
nodes: [
...inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope
.groupsAllowlist.nodes,
{
__typename: 'Group',
fullPath: 'gitlab-org',
id: 'gid://gitlab/Group/49',
name: 'gitlab-org',
avatarUrl: '',
},
],
},

View File

@ -19,7 +19,9 @@ describe('Token access table', () => {
const findTable = () => wrapper.findComponent(GlTableLite);
const findDeleteButton = () => wrapper.findComponent(GlButton);
const findAllTableRows = (type) => wrapper.findAllByTestId(`${type}s-token-table-row`);
const findAllTableRows = () => wrapper.findAllByTestId('token-access-table-row');
const findIcon = (type) => wrapper.findByTestId(`token-access-${type}-icon`);
const findProjectAvatar = (type) => wrapper.findByTestId(`token-access-${type}-avatar`);
const findName = (type) => wrapper.findByTestId(`token-access-${type}-name`);
describe.each`
@ -49,8 +51,14 @@ describe('Token access table', () => {
expect(wrapper.emitted('removeItem')).toEqual([[items[0]]]);
});
it('displays fullpath', () => {
it('displays icon and avatar', () => {
expect(findIcon(type).props('name')).toBe(type);
expect(findProjectAvatar(type).props('projectName')).toBe(items[0].name);
});
it('displays fullpath as a link to the project', () => {
expect(findName(type).text()).toBe(items[0].fullPath);
expect(findName(type).attributes('href')).toBe(`/${items[0].fullPath}`);
});
});
});

View File

@ -3,15 +3,6 @@
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
@ -27,24 +18,4 @@ RSpec.describe Projects::IssuesHelper, feature_category: :team_planning do
end
end
end
describe '#default_target' do
context 'when a project has no forks' do
it 'returns the same project' do
expect(default_target(project)).to be project
end
end
context 'when a project has forks' do
it 'returns the source project' do
expect(default_target(forked_project)).to eq source_project
end
end
end
describe '#target_projects' do
it 'returns all the forks and the source project' do
expect(target_projects(forked_project)).to eq [source_project, forked_project]
end
end
end

View File

@ -1140,7 +1140,7 @@ RSpec.describe Issue, feature_category: :team_planning do
allow(project).to receive(:forked?).and_return(true)
end
it { is_expected.to be_can_be_worked_on }
it { is_expected.not_to be_can_be_worked_on }
end
it { is_expected.to be_can_be_worked_on }
@ -2292,4 +2292,100 @@ RSpec.describe Issue, feature_category: :team_planning do
it { is_expected.to be_truthy }
end
end
shared_examples 'a markdown field that parses work item references' do
shared_examples 'a html field with work item information' do
it 'parses the work item reference' do
html_link = Nokogiri::HTML.fragment(issue[:"#{field}_html"]).css('a').first
expect(html_link.text).to eq(expected_link_text)
expect(html_link[:href]).to eq(work_item_path)
end
end
let_it_be(:group) { create(:group) }
context 'when it is a group level issue', :aggregate_failures do
let(:issue) { create(:issue, :group_level, namespace: group, field => work_item_reference) }
let(:work_item_path) { Gitlab::UrlBuilder.build(group_work_item, only_path: true) }
let(:expected_link_text) { group_work_item.to_reference }
context 'when field contains a work item reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(group_work_item) }
let(:work_item_reference) { work_item_path }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a work item reference (short)' do
let(:work_item_reference) { group_work_item.to_reference }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a work item reference (full)' do
let(:work_item_reference) { group_work_item.to_reference(full: true) }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a project level work item reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(project_work_item) }
let(:work_item_reference) { work_item_path }
let(:expected_link_text) { "#{reusable_project.full_path}##{project_work_item.iid}" }
it_behaves_like 'a html field with work item information'
end
end
context 'when it is a project level issue', :aggregate_failures do
let(:issue) { create(:issue, :task, project: reusable_project, field => work_item_reference) }
let(:work_item_path) { Gitlab::UrlBuilder.build(project_work_item, only_path: true) }
let(:expected_link_text) { group_work_item.to_reference }
context 'when field contains a work item reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(project_work_item) }
let(:work_item_reference) { work_item_path }
let(:expected_link_text) { project_work_item.to_reference }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a work item reference (short)' do
let(:work_item_reference) { project_work_item.to_reference }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a work item reference (full)' do
let(:work_item_reference) { project_work_item.to_reference(full: true) }
it_behaves_like 'a html field with work item information'
end
context 'when field contains a group level work item reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(group_work_item) }
let(:work_item_reference) { work_item_path }
let(:expected_link_text) { "#{group.full_path}##{group_work_item.iid}" }
it_behaves_like 'a html field with work item information'
end
end
end
describe '#title_html' do
it_behaves_like 'a markdown field that parses work item references' do
let_it_be(:group_work_item) { create(:work_item, :group_level, namespace: group) }
let_it_be(:project_work_item) { create(:work_item, :task, project: reusable_project) }
let(:field) { :title }
end
end
describe '#description_html' do
it_behaves_like 'a markdown field that parses work item references' do
let_it_be(:group_work_item) { create(:work_item, :group_level, namespace: group) }
let_it_be(:project_work_item) { create(:work_item, :task, project: reusable_project) }
let(:field) { :description }
end
end
end

View File

@ -518,6 +518,55 @@ RSpec.describe Note, feature_category: :team_planning do
let(:set_mentionable_text) { ->(txt) { subject.note = txt } }
end
describe '#note_html' do
shared_examples 'note that parses work item references' do
it 'parses the work item reference' do
html_link = Nokogiri::HTML.fragment(note.note_html).css('a').first
expect(html_link.text).to eq(expected_link_text)
expect(html_link[:href]).to eq(work_item_path)
end
end
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:group_work_item) { create(:work_item, :group_level, namespace: group) }
let_it_be(:project_work_item) { create(:work_item, :task, project: project) }
context 'when noteable is a group level work item', :aggregate_failures do
let(:work_item_path) { Gitlab::UrlBuilder.build(group_work_item, only_path: true) }
let(:expected_link_text) { group_work_item.to_reference }
let(:note) { create(:note, :on_group_work_item, noteable: group_work_item, note: note_text) }
context 'when note text contains a group reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(group_work_item) }
let(:note_text) { work_item_path }
it_behaves_like 'note that parses work item references'
end
context 'when note text contains a group reference (short)' do
let(:note_text) { group_work_item.to_reference }
it_behaves_like 'note that parses work item references'
end
context 'when note text contains a group reference (full)' do
let(:note_text) { group_work_item.to_reference(full: true) }
it_behaves_like 'note that parses work item references'
end
context 'when note text contains a project reference (URL)' do
let(:work_item_path) { Gitlab::UrlBuilder.build(project_work_item) }
let(:note_text) { work_item_path }
let(:expected_link_text) { "#{project.path}##{project_work_item.iid}" }
it_behaves_like 'note that parses work item references'
end
end
end
describe "#all_references" do
let!(:note1) { create(:note_on_issue) }
let!(:note2) { create(:note_on_issue) }

View File

@ -12,7 +12,6 @@ RSpec.describe MergeRequests::BuildService, feature_category: :code_review_workf
let(:user) { create(:user, name: "John Doe", email: "jdoe@gitlab.com") }
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' }
let(:target_branch) { 'master' }
@ -63,8 +62,7 @@ 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,
issue_iid: issue_iid
label_ids: label_ids
}
end
@ -535,17 +533,6 @@ 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, :repository, forked_from_project: project) }
let(:issue) { create(:issue, project: source_project, title: 'A bug') }
let(:issue_iid) { issue.iid }
it 'uses a full reference to the issue' do
allow(project).to receive(:get_issue).and_return issue
expect(merge_request.description).to include("Closes #{source_project.full_path}##{issue_iid}")
end
end
end
context 'source branch does not exist' do

View File

@ -2728,7 +2728,6 @@
- './ee/spec/services/security/security_orchestration_policies/validate_policy_service_spec.rb'
- './ee/spec/services/security/store_grouped_scans_service_spec.rb'
- './ee/spec/services/security/store_scan_service_spec.rb'
- './ee/spec/services/security/store_scans_service_spec.rb'
- './ee/spec/services/security/token_revocation_service_spec.rb'
- './ee/spec/services/security/track_scan_service_spec.rb'
- './ee/spec/services/security/update_training_service_spec.rb'
@ -2950,7 +2949,6 @@
- './ee/spec/workers/security/create_orchestration_policy_worker_spec.rb'
- './ee/spec/workers/security/orchestration_policy_rule_schedule_namespace_worker_spec.rb'
- './ee/spec/workers/security/orchestration_policy_rule_schedule_worker_spec.rb'
- './ee/spec/workers/security/store_scans_worker_spec.rb'
- './ee/spec/workers/security/sync_scan_policies_worker_spec.rb'
- './ee/spec/workers/security/track_secure_scans_worker_spec.rb'
- './ee/spec/workers/set_user_status_based_on_user_cap_setting_worker_spec.rb'

View File

@ -449,6 +449,7 @@ RSpec.describe 'Every Sidekiq worker', feature_category: :shared do
'StageUpdateWorker' => 3,
'StatusPage::PublishWorker' => 5,
'StoreSecurityReportsWorker' => 3,
'Security::StoreSecurityReportsByProjectWorker' => 3,
'SyncSeatLinkRequestWorker' => 20,
'SyncSeatLinkWorker' => 12,
'SystemHookPushWorker' => 3,