Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-02-19 12:12:26 +00:00
parent fe2f83b699
commit addf13e0d0
111 changed files with 2382 additions and 691 deletions

View File

@ -1,45 +1,4 @@
.review-docs: .review-docs:
extends:
- .default-retry
- .docs:rules:review-docs
image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}-alpine
stage: review
needs: []
variables:
# We're cloning the repo instead of downloading the script for now
# because some repos are private and CI_JOB_TOKEN cannot access files.
# See https://gitlab.com/gitlab-org/gitlab/issues/191273
GIT_DEPTH: 1
# By default, deploy the Review App using the `main` branch of the `gitlab-org/gitlab-docs` project
DOCS_BRANCH: main
environment:
name: review-docs/mr-${CI_MERGE_REQUEST_IID}
# DOCS_REVIEW_APPS_DOMAIN and DOCS_GITLAB_REPO_SUFFIX are CI variables
# Discussion: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/14236/diffs#note_40140693
auto_stop_in: 2 weeks
url: http://${DOCS_BRANCH}-${DOCS_GITLAB_REPO_SUFFIX}-${CI_MERGE_REQUEST_IID}.${DOCS_REVIEW_APPS_DOMAIN}/${DOCS_GITLAB_REPO_SUFFIX}
on_stop: review-docs-cleanup
before_script:
- source ./scripts/utils.sh
- install_gitlab_gem
# Always trigger a docs build in gitlab-docs only on docs-only branches.
# Useful to preview the docs changes live.
review-docs-deploy:
extends: .review-docs
script:
- ./scripts/trigger-build.rb docs deploy
# Cleanup remote environment of gitlab-docs
review-docs-cleanup:
extends: .review-docs
environment:
name: review-docs/mr-${CI_MERGE_REQUEST_IID}
action: stop
script:
- ./scripts/trigger-build.rb docs cleanup
.review-docs-hugo:
extends: extends:
- .default-retry - .default-retry
- .docs:rules:review-docs - .docs:rules:review-docs
@ -51,25 +10,25 @@ review-docs-cleanup:
# By default, deploy the Review App using the `main` branch of the `gitlab-org/technical-writing/docs-gitlab-com` project # By default, deploy the Review App using the `main` branch of the `gitlab-org/technical-writing/docs-gitlab-com` project
DOCS_BRANCH: main DOCS_BRANCH: main
environment: environment:
name: review-docs/mr-${CI_MERGE_REQUEST_IID}-hugo name: review-docs/mr-${CI_MERGE_REQUEST_IID}
auto_stop_in: 2 weeks auto_stop_in: 2 weeks
url: https://new.docs.gitlab.com/upstream-review-mr-${DOCS_GITLAB_REPO_SUFFIX}-${CI_MERGE_REQUEST_IID} url: https://docs.gitlab.com/upstream-review-mr-${DOCS_GITLAB_REPO_SUFFIX}-${CI_MERGE_REQUEST_IID}
on_stop: review-docs-hugo-cleanup on_stop: review-docs-cleanup
before_script: before_script:
- source ./scripts/utils.sh - source ./scripts/utils.sh
- install_gitlab_gem - install_gitlab_gem
# Deploy documentation review app by using GitLab Docs Hugo project (gitlab-org/technical-writing/docs-gitlab-com) # Deploy documentation review app by using GitLab Docs project (gitlab-org/technical-writing/docs-gitlab-com)
review-docs-hugo-deploy: review-docs-deploy:
extends: .review-docs-hugo extends: .review-docs
script: script:
- ./scripts/trigger-build.rb docs-hugo deploy - ./scripts/trigger-build.rb docs-hugo deploy
# Cleanup remote environment of gitlab-org/technical-writing/docs-gitlab-com # Cleanup remote environment of gitlab-org/technical-writing/docs-gitlab-com
review-docs-hugo-cleanup: review-docs-cleanup:
extends: .review-docs-hugo extends: .review-docs
environment: environment:
name: review-docs/mr-${CI_MERGE_REQUEST_IID}-hugo name: review-docs/mr-${CI_MERGE_REQUEST_IID}
action: stop action: stop
script: script:
- ./scripts/trigger-build.rb docs-hugo cleanup - ./scripts/trigger-build.rb docs-hugo cleanup

View File

@ -1125,12 +1125,9 @@ Gitlab/BoundedContexts:
- 'app/models/preloaders/commit_status_preloader.rb' - 'app/models/preloaders/commit_status_preloader.rb'
- 'app/models/preloaders/environments/deployment_preloader.rb' - 'app/models/preloaders/environments/deployment_preloader.rb'
- 'app/models/preloaders/group_policy_preloader.rb' - 'app/models/preloaders/group_policy_preloader.rb'
- 'app/models/preloaders/group_root_ancestor_preloader.rb'
- 'app/models/preloaders/labels_preloader.rb' - 'app/models/preloaders/labels_preloader.rb'
- 'app/models/preloaders/merge_request_diff_preloader.rb' - 'app/models/preloaders/merge_request_diff_preloader.rb'
- 'app/models/preloaders/namespace_root_ancestor_preloader.rb'
- 'app/models/preloaders/project_policy_preloader.rb' - 'app/models/preloaders/project_policy_preloader.rb'
- 'app/models/preloaders/project_root_ancestor_preloader.rb'
- 'app/models/preloaders/projects/notes_preloader.rb' - 'app/models/preloaders/projects/notes_preloader.rb'
- 'app/models/preloaders/runner_manager_policy_preloader.rb' - 'app/models/preloaders/runner_manager_policy_preloader.rb'
- 'app/models/preloaders/user_max_access_level_in_groups_preloader.rb' - 'app/models/preloaders/user_max_access_level_in_groups_preloader.rb'

View File

@ -3475,7 +3475,6 @@ RSpec/FeatureCategory:
- 'spec/presenters/blobs/notebook_presenter_spec.rb' - 'spec/presenters/blobs/notebook_presenter_spec.rb'
- 'spec/presenters/blobs/unfold_presenter_spec.rb' - 'spec/presenters/blobs/unfold_presenter_spec.rb'
- 'spec/presenters/ci/bridge_presenter_spec.rb' - 'spec/presenters/ci/bridge_presenter_spec.rb'
- 'spec/presenters/ci/build_presenter_spec.rb'
- 'spec/presenters/ci/build_runner_presenter_spec.rb' - 'spec/presenters/ci/build_runner_presenter_spec.rb'
- 'spec/presenters/ci/group_variable_presenter_spec.rb' - 'spec/presenters/ci/group_variable_presenter_spec.rb'
- 'spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb' - 'spec/presenters/ci/pipeline_artifacts/code_coverage_presenter_spec.rb'
@ -3678,7 +3677,6 @@ RSpec/FeatureCategory:
- 'spec/serializers/blob_entity_spec.rb' - 'spec/serializers/blob_entity_spec.rb'
- 'spec/serializers/build_action_entity_spec.rb' - 'spec/serializers/build_action_entity_spec.rb'
- 'spec/serializers/build_artifact_entity_spec.rb' - 'spec/serializers/build_artifact_entity_spec.rb'
- 'spec/serializers/build_details_entity_spec.rb'
- 'spec/serializers/build_trace_entity_spec.rb' - 'spec/serializers/build_trace_entity_spec.rb'
- 'spec/serializers/ci/codequality_mr_diff_report_serializer_spec.rb' - 'spec/serializers/ci/codequality_mr_diff_report_serializer_spec.rb'
- 'spec/serializers/ci/daily_build_group_report_result_entity_spec.rb' - 'spec/serializers/ci/daily_build_group_report_result_entity_spec.rb'

View File

@ -12,7 +12,6 @@ import DomElementListener from '~/vue_shared/components/dom_element_listener.vue
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue'; import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
import UserDate from '~/vue_shared/components/user_date.vue'; import UserDate from '~/vue_shared/components/user_date.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { createAlert, VARIANT_DANGER } from '~/alert'; import { createAlert, VARIANT_DANGER } from '~/alert';
import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants'; import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants';
@ -41,7 +40,6 @@ export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagsMixin()],
lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', { lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', {
anchor: 'view-token-usage-information', anchor: 'view-token-usage-information',
}), }),
@ -117,10 +115,6 @@ export default {
ignoredFields.push('role'); ignoredFields.push('role');
} }
if (!this.glFeatures.patIp) {
ignoredFields.push('lastUsedIps');
}
const fields = FIELDS.filter(({ key }) => !ignoredFields.includes(key)); const fields = FIELDS.filter(({ key }) => !ignoredFields.includes(key));
// Remove the sortability of the columns if backend pagination is on. // Remove the sortability of the columns if backend pagination is on.

View File

@ -0,0 +1,292 @@
<script>
import { GlButton, GlFormGroup, GlFormInput, GlAnimatedUploadIcon } from '@gitlab/ui';
import { kebabCase } from 'lodash';
import validation from '~/vue_shared/directives/validation';
import csrf from '~/lib/utils/csrf';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import MultiStepFormTemplate from '~/vue_shared/components/multi_step_form_template.vue';
import { START_RULE, CONTAINS_RULE } from '~/projects/project_name_rules';
import NewProjectDestinationSelect from '~/projects/new_v2/components/project_destination_select.vue';
const feedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
},
nameStartPattern: {
isInvalid: (el) => el.validity?.patternMismatch && !START_RULE.reg.test(el.value),
message: START_RULE.msg,
},
nameContainsPattern: {
isInvalid: (el) => el.validity?.patternMismatch && !CONTAINS_RULE.reg.test(el.value),
message: CONTAINS_RULE.msg,
},
};
const initFormField = ({ value = null, required = true } = {}) => ({
value,
required,
state: null,
feedback: null,
});
export default {
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlAnimatedUploadIcon,
FileIcon,
MultiStepFormTemplate,
NewProjectDestinationSelect,
},
directives: {
validation: validation(feedbackMap),
},
props: {
backButtonPath: {
type: String,
required: true,
},
namespaceFullPath: {
type: String,
required: true,
},
namespaceId: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
importGitlabProjectPath: {
type: String,
required: true,
},
},
data() {
const form = {
state: false,
showValidation: false,
fields: {
name: initFormField(),
path: initFormField(),
},
};
return {
file: null,
filePreviewURL: null,
form,
animateUploadIcon: false,
dropzoneState: true,
};
},
computed: {
formattedFileSize() {
return numberToHumanSize(this.file.size);
},
},
watch: {
// eslint-disable-next-line func-names
'form.fields.name.value': function (newVal) {
this.form.fields.path.value = kebabCase(newVal);
},
},
methods: {
setFile() {
this.file = this.$refs.fileUpload.files['0'];
const fileUrlReader = new FileReader();
fileUrlReader.readAsDataURL(this.file);
fileUrlReader.onload = (e) => {
this.filePreviewURL = e.target?.result;
};
this.dropzoneState = true;
},
onDropzoneMouseEnter() {
this.animateUploadIcon = true;
},
onDropzoneMouseLeave() {
this.animateUploadIcon = false;
},
openFileUpload() {
this.$refs.fileUpload.click();
},
onSubmit() {
if (!this.form.state) {
this.form.showValidation = true;
}
if (this.file === null) {
this.dropzoneState = false;
}
if (!this.form.state || !this.dropzoneState) {
return;
}
this.$refs.form.submit();
},
},
csrf,
projectNamePattern: `(${START_RULE.reg.source})|(${CONTAINS_RULE.reg.source})`,
validFileMimetypes: ['application/gzip'],
};
</script>
<template>
<form
ref="form"
:action="importGitlabProjectPath"
enctype="multipart/form-data"
method="post"
@submit.prevent="onSubmit"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<multi-step-form-template
:title="__('Import an exported GitLab project')"
:current-step="3"
:steps-total="3"
>
<template #form>
<gl-form-group
:label="__('Project name')"
label-for="name"
:description="
s__(
'ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.',
)
"
:invalid-feedback="form.fields.name.feedback"
data-testid="project-name-form-group"
>
<gl-form-input
id="name"
v-model="form.fields.name.value"
v-validation:[form.showValidation]
:validation-message="s__('ProjectsNew|Please enter a valid project name.')"
:state="form.fields.name.state"
:pattern="$options.projectNamePattern"
name="name"
required
:placeholder="s__('ProjectsNew|My awesome project')"
data-testid="project-name"
/>
</gl-form-group>
<div class="gl-flex gl-flex-col gl-gap-4 sm:gl-flex-row">
<gl-form-group
:label="s__('ProjectsNew|Choose a group')"
class="sm:gl-w-1/2"
label-for="namespace"
>
<new-project-destination-select
toggle-aria-labelled-by="namespace"
:namespace-full-path="namespaceFullPath"
:namespace-id="namespaceId"
:root-url="rootPath"
/>
</gl-form-group>
<div class="gl-mt-2 gl-hidden gl-pt-6 sm:gl-block">{{ __('/') }}</div>
<gl-form-group
:label="s__('ProjectsNew|Project slug')"
label-for="path"
class="sm:gl-w-1/2"
:invalid-feedback="form.fields.path.feedback"
>
<gl-form-input
id="path"
v-model="form.fields.path.value"
v-validation:[form.showValidation]
:validation-message="s__('ProjectsNew|Please enter a valid project slug.')"
:state="form.fields.path.state"
name="path"
required
:placeholder="s__('ProjectsNew|my-awesome-project')"
data-testid="project-slug"
/>
</gl-form-group>
</div>
<p class="-gl-mt-3 gl-text-base gl-leading-normal gl-text-subtle">
{{
s__(
"ProjectsNew|To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.",
)
}}
</p>
<gl-form-group
:label="s__('ProjectsNew|GitLab project export')"
label-for="file-button"
:invalid-feedback="s__('ProjectsNew|Please upload a valid GitLab project export file.')"
:state="dropzoneState"
data-testid="project-file-form-group"
>
<button
id="file-button"
class="upload-dropzone-card upload-dropzone-border gl-mb-0 gl-h-full gl-w-full gl-items-center gl-justify-center gl-bg-default gl-px-5 gl-py-4"
type="button"
data-testid="dropzone-button"
@click="openFileUpload"
@mouseenter="onDropzoneMouseEnter"
@mouseleave="onDropzoneMouseLeave"
>
<div
v-if="file"
class="gl-flex gl-w-full gl-flex-col gl-items-center gl-justify-center gl-gap-3"
>
<file-icon :file-name="file.name" :size="24" class="gl-flex" />
<span>
{{ file.name }}
&middot;
<span class="gl-text-subtle">{{ formattedFileSize }}</span>
</span>
</div>
<div v-else class="gl-flex gl-items-center gl-justify-center gl-gap-3 gl-text-center">
<gl-animated-upload-icon :is-on="animateUploadIcon" />
<span>{{ __('Drop or upload file to attach') }}</span>
</div>
<input
ref="fileUpload"
type="file"
name="file"
hidden
:accept="$options.validFileMimetypes"
required
:multiple="false"
data-testid="dropzone-input"
@change="setFile"
/>
</button>
</gl-form-group>
</template>
<template #back>
<gl-button
category="primary"
variant="default"
:href="backButtonPath"
data-testid="back-button"
>
{{ __('Go back') }}
</gl-button>
</template>
<template #next>
<gl-button
type="submit"
category="primary"
variant="confirm"
data-testid="next-button"
@click.prevent="onSubmit"
>
{{ __('Import project') }}
</gl-button>
</template>
</multi-step-form-template>
</form>
</template>

View File

@ -0,0 +1,49 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import importFromGitlabExportApp from './import_from_gitlab_export_app.vue';
export function initGitLabImportProjectForm() {
const el = document.getElementById('js-import-gitlab-project-root');
if (!el) {
return null;
}
const {
backButtonPath,
namespaceFullPath,
namespaceId,
rootPath,
importGitlabProjectPath,
userNamespaceId,
canCreateProject,
rootUrl,
} = el.dataset;
const props = {
backButtonPath,
namespaceFullPath,
namespaceId,
rootPath,
importGitlabProjectPath,
};
const provide = {
userNamespaceId,
canCreateProject,
rootUrl,
};
return new Vue({
el,
name: 'ImportGitLabProjectRoot',
apolloProvider: new VueApollo({
defaultClient: createDefaultClient(),
}),
provide,
render(h) {
return h(importFromGitlabExportApp, { props });
},
});
}

View File

@ -1,5 +1,7 @@
import initGitLabImportProject from '~/projects/project_import_gitlab_project'; import initGitLabImportProject from '~/projects/project_import_gitlab_project';
import { initNewProjectUrlSelect } from '~/projects/new'; import { initNewProjectUrlSelect } from '~/projects/new';
import { initGitLabImportProjectForm } from '~/import/gitlab_project';
initNewProjectUrlSelect(); initNewProjectUrlSelect();
initGitLabImportProject(); initGitLabImportProject();
initGitLabImportProjectForm();

View File

@ -1,19 +1,17 @@
<script> <script>
import { GlButton, GlTruncate, GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; import { GlCollapsibleListbox } from '@gitlab/ui';
import { PATH_SEPARATOR } from '~/lib/utils/url_utility'; import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { __, s__, n__ } from '~/locale'; import { __, s__, n__ } from '~/locale';
import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
import eventHub from '../event_hub';
export default { export default {
components: { components: {
GlButton,
GlTruncate,
GlCollapsibleListbox, GlCollapsibleListbox,
GlIcon,
}, },
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
apollo: { apollo: {
@ -49,6 +47,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
toggleAriaLabelledBy: {
type: String,
required: false,
default: '',
},
groupsOnly: { groupsOnly: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -155,6 +158,19 @@ export default {
this.shouldSkipQuery = false; this.shouldSkipQuery = false;
} }
}, },
handleDropdownItemClick(namespaceId) {
const namespace = this.allItems.find((item) => item.id === namespaceId);
if (namespace) {
eventHub.$emit('update-visibility', {
name: namespace.name,
visibility: namespace.visibility,
showPath: namespace.webUrl,
editPath: joinPaths(namespace.webUrl, '-', 'edit'),
});
}
this.setNamespace(namespace);
},
handleSelectTemplate(id, fullPath) { handleSelectTemplate(id, fullPath) {
this.groupPathToFilterBy = fullPath.split(PATH_SEPARATOR).shift(); this.groupPathToFilterBy = fullPath.split(PATH_SEPARATOR).shift();
this.setNamespace({ id, fullPath }); this.setNamespace({ id, fullPath });
@ -187,31 +203,34 @@ export default {
</script> </script>
<template> <template>
<div>
<gl-collapsible-listbox <gl-collapsible-listbox
searchable searchable
fluid-width fluid-width
:searching="loading" :searching="loading"
:items="items" :items="items"
:toggle-text="dropdownText" :toggle-text="dropdownText"
toggle-class="gl-w-full"
:toggle-aria-labelled-by="toggleAriaLabelledBy"
:no-results-text="$options.i18n.emptySearchResult" :no-results-text="$options.i18n.emptySearchResult"
class="gl-w-full" class="project-destination-select gl-w-full gl-max-w-full"
@show="trackDropdownShow" @show="trackDropdownShow"
@shown="handleDropdownShown" @shown="handleDropdownShown"
@select="handleDropdownItemClick"
@search="onSearch" @search="onSearch"
> >
<template #toggle>
<gl-button :class="dropdownPlaceholderClass">
<gl-truncate
:text="dropdownText"
position="start"
class="gl-mr-auto gl-overflow-hidden"
with-tooltip
/>
<gl-icon class="gl-button-icon dropdown-chevron !gl-ml-2 !gl-mr-0" name="chevron-down" />
</gl-button>
</template>
<template #search-summary-sr-only> <template #search-summary-sr-only>
{{ searchSummary }} {{ searchSummary }}
</template> </template>
</gl-collapsible-listbox> </gl-collapsible-listbox>
<input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" />
<input
id="project[namespace_id]"
type="hidden"
name="namespace_id"
:value="selectedNamespace.id || userNamespaceUniqueId"
/>
</div>
</template> </template>

View File

@ -25,6 +25,7 @@ export default () => {
const $projectPath = document.querySelector('.js-path-name'); const $projectPath = document.querySelector('.js-path-name');
const { name, path } = prepareParameters(); const { name, path } = prepareParameters();
if ($projectName || $projectPath) {
// get the project name from the URL and set it as input value // get the project name from the URL and set it as input value
$projectName.value = name; $projectName.value = name;
@ -41,4 +42,5 @@ export default () => {
$projectPath.addEventListener('keyup', () => $projectPath.addEventListener('keyup', () =>
projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName), projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName),
); );
}
}; };

View File

@ -1,5 +1,7 @@
<script> <script>
import { GlButton, GlButtonGroup, GlDisclosureDropdownItem } from '@gitlab/ui'; import { GlButton, GlButtonGroup, GlDisclosureDropdownItem } from '@gitlab/ui';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import { InternalEvents } from '~/tracking';
export default { export default {
components: { components: {
@ -7,16 +9,26 @@ export default {
GlButtonGroup, GlButtonGroup,
GlDisclosureDropdownItem, GlDisclosureDropdownItem,
}, },
mixins: [InternalEvents.mixin()],
props: { props: {
ideItem: { ideItem: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
computed: {
shortcutsDisabled() {
return shouldDisableShortcuts();
},
},
methods: { methods: {
closeDropdown() { closeDropdown() {
this.$emit('close-dropdown'); this.$emit('close-dropdown');
}, },
trackAndClose({ action, label }) {
this.trackEvent(action, { label });
this.closeDropdown();
},
}, },
}; };
</script> </script>
@ -41,5 +53,16 @@ export default {
</gl-button> </gl-button>
</gl-button-group> </gl-button-group>
</gl-disclosure-dropdown-item> </gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item v-else-if="ideItem.href" :item="ideItem" @action="closeDropdown" /> <gl-disclosure-dropdown-item
v-else-if="ideItem.href"
:item="ideItem"
@action="trackAndClose(ideItem.tracking)"
>
<template #list-item>
<span class="gl-mb-2 gl-flex gl-items-center gl-justify-between">
<span>{{ ideItem.text }}</span>
<kbd v-if="ideItem.shortcut && !shortcutsDisabled" class="flat">{{ ideItem.shortcut }}</kbd>
</span>
</template>
</gl-disclosure-dropdown-item>
</template> </template>

View File

@ -2,6 +2,7 @@
import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { getHTTPProtocol } from '~/lib/utils/url_utility'; import { getHTTPProtocol } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { GO_TO_PROJECT_WEBIDE, keysFor } from '~/behaviors/shortcuts/keybindings';
import CodeDropdownCloneItem from './code_dropdown_clone_item.vue'; import CodeDropdownCloneItem from './code_dropdown_clone_item.vue';
import CodeDropdownDownloadItems from './code_dropdown_download_items.vue'; import CodeDropdownDownloadItems from './code_dropdown_download_items.vue';
import CodeDropdownIdeItem from './code_dropdown_ide_item.vue'; import CodeDropdownIdeItem from './code_dropdown_ide_item.vue';
@ -36,6 +37,16 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
webIdeUrl: {
type: String,
required: false,
default: '',
},
gitpodUrl: {
type: String,
required: false,
default: '',
},
currentPath: { currentPath: {
type: String, type: String,
required: false, required: false,
@ -46,6 +57,16 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
showWebIdeButton: {
type: Boolean,
required: false,
default: true,
},
showGitpodButton: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
httpLabel() { httpLabel() {
@ -58,22 +79,52 @@ export default {
httpUrlEncoded() { httpUrlEncoded() {
return encodeURIComponent(this.httpUrl); return encodeURIComponent(this.httpUrl);
}, },
webIdeActionShortcutKey() {
return keysFor(GO_TO_PROJECT_WEBIDE)[0];
},
webIdeAction() {
return {
text: __('Web IDE'),
shortcut: this.webIdeActionShortcutKey,
tracking: {
action: 'click_consolidated_edit',
label: 'web_ide',
},
href: this.webIdeUrl,
extraAttrs: {
target: '_blank',
},
};
},
gitPodAction() {
return {
text: __('GitPod'),
tracking: {
action: 'click_consolidated_edit',
label: 'gitpod',
},
href: this.gitpodUrl,
extraAttrs: {
target: '_blank',
},
};
},
ideGroup() { ideGroup() {
const groups = [ const actions = [];
/* eslint-disable-next-line @gitlab/require-i18n-strings */
this.createIdeGroup('Visual Studio Code', VSCODE_BASE_URL),
this.createIdeGroup('IntelliJ IDEA', JETBRAINS_BASE_URL),
];
if (this.xcodeUrl) { if (this.showWebIdeButton) actions.push(this.webIdeAction);
groups.push({ if (this.showGitpodButton) actions.push(this.gitPodAction);
/* eslint-disable-next-line @gitlab/require-i18n-strings */
text: 'Xcode', if (this.httpUrl || this.sshUrl) {
href: this.xcodeUrl, actions.push(this.createIdeGroup(__('Visual Studio Code'), VSCODE_BASE_URL));
}); actions.push(this.createIdeGroup(__('IntelliJ IDEA'), JETBRAINS_BASE_URL));
} }
return groups.filter((group) => group.items?.length || group.href); if (this.xcodeUrl) {
actions.push({ text: __('Xcode'), href: this.xcodeUrl });
}
return actions;
}, },
sourceCodeGroup() { sourceCodeGroup() {
return this.directoryDownloadLinks.map((link) => ({ return this.directoryDownloadLinks.map((link) => ({
@ -107,7 +158,7 @@ export default {
...(this.sshUrl ...(this.sshUrl
? [ ? [
{ {
text: 'SSH', text: __('SSH'),
href: `${baseUrl}${this.sshUrlEncoded}`, href: `${baseUrl}${this.sshUrlEncoded}`,
}, },
] ]
@ -115,7 +166,7 @@ export default {
...(this.httpUrl ...(this.httpUrl
? [ ? [
{ {
text: 'HTTPS', text: __('HTTPS'),
href: `${baseUrl}${this.httpUrlEncoded}`, href: `${baseUrl}${this.httpUrlEncoded}`,
}, },
] ]

View File

@ -323,8 +323,12 @@ export default {
:http-url="httpUrl" :http-url="httpUrl"
:kerberos-url="kerberosUrl" :kerberos-url="kerberosUrl"
:xcode-url="xcodeUrl" :xcode-url="xcodeUrl"
:web-ide-url="webIDEUrl"
:gitpod-url="gitpodUrl"
:current-path="currentPath" :current-path="currentPath"
:directory-download-links="downloadLinks" :directory-download-links="downloadLinks"
:show-web-ide-button="showWebIdeButton"
:show-gitpod-button="showGitpodButton"
/> />
<repository-overflow-menu v-if="comparePath" /> <repository-overflow-menu v-if="comparePath" />
</div> </div>

View File

@ -2,6 +2,10 @@ import { s__ } from '~/locale';
export const BASE_IMPORT_TABLE_ROW_GRID_CLASSES = 'gl-grid-cols-[repeat(2,1fr),200px,200px]'; export const BASE_IMPORT_TABLE_ROW_GRID_CLASSES = 'gl-grid-cols-[repeat(2,1fr),200px,200px]';
export const SOURCE_TYPE_GROUP = 'group';
export const SOURCE_TYPE_PROJECT = 'project';
export const SOURCE_TYPE_FILE = 'file';
export const IMPORT_HISTORY_TABLE_STATUS = { export const IMPORT_HISTORY_TABLE_STATUS = {
inProgress: 'started', inProgress: 'started',
complete: 'finished', complete: 'finished',

View File

@ -0,0 +1,21 @@
import { basic } from 'jest/vue_shared/components/import_history_table/mock_data';
import ImportHistoryTableSource from './import_history_table_source.vue';
export default {
component: ImportHistoryTableSource,
title: 'vue_shared/import/import_history_table_source',
};
const defaultProps = {
item: basic.items[0],
};
const Template = (args, { argTypes }) => ({
components: { ImportHistoryTableSource },
props: Object.keys(argTypes),
template: `<import-history-table-source v-bind="$props"/>`,
});
export const Default = Template.bind({});
Default.args = defaultProps;

View File

@ -0,0 +1,55 @@
<script>
import { GlIcon, GlLink, GlTruncate } from '@gitlab/ui';
import { SOURCE_TYPE_GROUP, SOURCE_TYPE_PROJECT, SOURCE_TYPE_FILE } from './constants';
/**
* A basic formatter for showing the source information of an import
*/
export default {
name: 'ImportHistoryTableSource',
components: {
GlIcon,
GlLink,
GlTruncate,
},
props: {
/**
* Should accept the data that comes form the BulkImport API
*/
item: {
type: Object,
required: true,
},
},
computed: {
sourceIconName() {
switch (this.item.entity_type) {
case SOURCE_TYPE_PROJECT:
return 'project';
case SOURCE_TYPE_FILE:
return 'project';
case SOURCE_TYPE_GROUP:
return 'group';
default:
return '';
}
},
isFile() {
return this.item.entity_type === SOURCE_TYPE_FILE;
},
},
};
</script>
<template>
<div class="gl-flex gl-items-start gl-gap-3 gl-pt-1">
<gl-icon :name="sourceIconName" class="gl-mt-1 gl-flex-shrink-0" />
<span v-if="isFile">{{ item.fileName }}</span>
<gl-link
v-else
class="gl-overflow-hidden !gl-text-default hover:gl-underline"
:href="item.source_full_path"
>
<gl-truncate :text="item.source_full_path" position="middle" with-tooltip />
</gl-link>
</div>
</template>

View File

@ -10,6 +10,7 @@ import Tracking from '~/tracking';
import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { keysFor, GO_TO_PROJECT_WEBIDE } from '~/behaviors/shortcuts/keybindings'; import { keysFor, GO_TO_PROJECT_WEBIDE } from '~/behaviors/shortcuts/keybindings';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
export const i18n = { export const i18n = {
@ -32,7 +33,7 @@ export default {
ConfirmForkModal, ConfirmForkModal,
}, },
i18n, i18n,
mixins: [Tracking.mixin()], mixins: [Tracking.mixin(), glFeatureFlagsMixin()],
props: { props: {
isFork: { isFork: {
type: Boolean, type: Boolean,
@ -141,13 +142,15 @@ export default {
}; };
}, },
computed: { computed: {
hideIDEActionsInDirectoryView() {
return this.glFeatures.directoryCodeDropdownUpdates && !this.isBlob;
},
actions() { actions() {
return [ return this.hideIDEActionsInDirectoryView
this.pipelineEditorAction, ? [this.pipelineEditorAction, this.editAction].filter(Boolean)
this.webIdeAction, : [this.pipelineEditorAction, this.webIdeAction, this.editAction, this.gitpodAction].filter(
this.editAction, Boolean,
this.gitpodAction, );
].filter((action) => action);
}, },
hasActions() { hasActions() {
return this.actions.length > 0; return this.actions.length > 0;

View File

@ -557,3 +557,8 @@
@apply gl-line-clamp-2 gl-whitespace-normal; @apply gl-line-clamp-2 gl-whitespace-normal;
margin-bottom: 0; margin-bottom: 0;
} }
// stylelint-disable-next-line gitlab/no-gl-class
.project-destination-select .gl-button-text {
flex-grow: 1;
}

View File

@ -8,9 +8,6 @@ module UserSettings
feature_category :system_access feature_category :system_access
before_action :check_personal_access_tokens_enabled before_action :check_personal_access_tokens_enabled
before_action do
push_frontend_feature_flag(:pat_ip, current_user)
end
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:ics) } prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:ics) }
def index def index

View File

@ -17,7 +17,7 @@ module Resolvers
private private
def unconditional_includes def unconditional_includes
[:trigger_requests] [:trigger_requests, :pipelines]
end end
end end
end end

View File

@ -268,7 +268,11 @@ module Types
end end
def triggered def triggered
object.try(:trigger_request) if Feature.enabled?(:ci_read_trigger_from_ci_pipeline, object.project)
object.pipeline.trigger_id.present?
else
object.try(:trigger_request).present?
end
end end
def manual_variables def manual_variables

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Types
module WorkItems
module Widgets
module ErrorTracking
# rubocop:disable Graphql/AuthorizeTypes -- we already authorize the work item itself
class StackTraceContextType < BaseObject
graphql_name 'WorkItemWidgetErrorTrackingStackTraceContext'
description 'Represents details about a line of code of the stack trace'
field :line_number, GraphQL::Types::Int,
null: true,
description: 'Line number of code.', method: :first
field :line, GraphQL::Types::String,
null: true,
description: 'Line of code.', method: :last
end
# rubocop:enable Graphql/AuthorizeTypes
end
end
end
end

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Types
module WorkItems
module Widgets
module ErrorTracking
# Disabling widget level authorization as it might be too granular
# and we already authorize the parent work item
# rubocop:disable Graphql/AuthorizeTypes -- reason above
class StackTraceType < BaseObject
graphql_name 'ErrorTrackingStackTrace'
description 'Represents a stack trace'
connection_type_class Types::CountableConnectionType
field :filename, GraphQL::Types::String,
null: true,
description: 'Filename of the stack trace.'
field :absolute_path, GraphQL::Types::String,
null: true,
description: 'Absolute path of the stack trace.', hash_key: "absPath"
field :function, GraphQL::Types::String,
null: true,
description: 'Name of the function where the error occured.'
field :line_number, GraphQL::Types::Int,
null: true,
description: 'Line number of the stack trace.', hash_key: "lineNo"
field :column_number, GraphQL::Types::Int,
null: true,
description: 'Column number of the stack trace.', hash_key: "colNo"
field :context, [Types::WorkItems::Widgets::ErrorTracking::StackTraceContextType],
null: true,
description: 'Context of the stack trace.', hash_key: "context"
end
# rubocop:enable Graphql/AuthorizeTypes
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Types
module WorkItems
module Widgets
class ErrorTrackingStatusEnum < BaseEnum
graphql_name 'ErrorTrackingStatus'
description 'Status of the error tracking service'
value 'SUCCESS', value: :success, description: 'Successfuly fetch the stack trace.'
value 'ERROR', value: :error, description: 'Error tracking service respond with an error.'
value 'NOT_FOUND', value: :not_found, description: 'Sentry issue not found.'
value 'RETRY', value: :retry, description: 'Error tracking service is not ready.'
end
end
end
end

View File

@ -13,7 +13,56 @@ module Types
implements ::Types::WorkItems::WidgetInterface implements ::Types::WorkItems::WidgetInterface
field :identifier, GraphQL::Types::BigInt, null: true, field :identifier, GraphQL::Types::BigInt, null: true,
description: 'Error tracking issue id.', method: :sentry_issue_identifier description: 'Error tracking issue id.' \
'This field can only be resolved for one work item in any single request.',
method: :sentry_issue_identifier do
extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
end
field :stack_trace, ::Types::WorkItems::Widgets::ErrorTracking::StackTraceType.connection_type,
null: true,
description: 'Stack trace details of the error.' \
'This field can only be resolved for one work item in any single request.' do
extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
end
field :status, ErrorTrackingStatusEnum, null: true,
description: 'Response status of error service.' \
'This field can only be resolved for one work item in any single request.' do
extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
end
def stack_trace
return [] if object.sentry_issue_identifier.nil?
if latest_event_result[:status] == :success
Gitlab::ErrorTracking::StackTraceHighlightDecorator
.decorate(latest_event_result[:latest_event])
.stack_trace_entries
else
[]
end
end
def status
return :not_found if object.sentry_issue_identifier.nil?
if latest_event_result[:status] == :success
:success
elsif latest_event_result[:http_status] == :no_content
:retry
else
:error
end
end
private
def latest_event_result
@latest_event ||= ::ErrorTracking::IssueLatestEventService
.new(object.work_item.project, current_user, issue_id: object.sentry_issue_identifier)
.execute
end
end end
# rubocop:enable Graphql/AuthorizeTypes # rubocop:enable Graphql/AuthorizeTypes
end end

View File

@ -21,10 +21,6 @@ module BreadcrumbsHelper
@breadcrumb_title = title @breadcrumb_title = title
end end
def breadcrumb_list_item(link)
content_tag :li, link, class: 'gl-breadcrumb-item gl-inline-flex'
end
def add_to_breadcrumb_collapsed_links(link, location: :before) def add_to_breadcrumb_collapsed_links(link, location: :before)
@breadcrumb_collapsed_links ||= {} @breadcrumb_collapsed_links ||= {}
@breadcrumb_collapsed_links[location] ||= [] @breadcrumb_collapsed_links[location] ||= []

View File

@ -41,29 +41,12 @@ module GroupsHelper
group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png') group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png')
end end
def group_title(group) def push_group_breadcrumbs(group)
@has_group_title = true sorted_ancestors(group).with_route.reverse_each do |parent|
full_title = []
sorted_ancestors(group).with_route.reverse_each.with_index do |parent, index|
if index > 0
add_to_breadcrumb_collapsed_links(
{ text: simple_sanitize(parent.name), href: group_path(parent), avatar_url: parent.try(:avatar_url) },
location: :before
)
else
full_title << breadcrumb_list_item(group_title_link(parent, hidable: false))
end
push_to_schema_breadcrumb(simple_sanitize(parent.name), group_path(parent), parent.try(:avatar_url)) push_to_schema_breadcrumb(simple_sanitize(parent.name), group_path(parent), parent.try(:avatar_url))
end end
full_title << render("layouts/nav/breadcrumbs/collapsed_inline_list", location: :before, title: _("Show all breadcrumbs"))
full_title << breadcrumb_list_item(group_title_link(group))
push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group), group.try(:avatar_url)) push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group), group.try(:avatar_url))
full_title.join.html_safe
end end
def projects_lfs_status(group) def projects_lfs_status(group)

View File

@ -88,14 +88,10 @@ module PageLayoutHelper
end end
def header_title(title = nil, title_url = nil) def header_title(title = nil, title_url = nil)
if title return @header_title unless title
@header_title = title @header_title = title
@header_title_url = title_url @header_title_url = title_url
else
return @header_title unless @header_title_url
breadcrumb_list_item(link_to(@header_title, @header_title_url))
end
end end
def sidebar(name = nil) def sidebar(name = nil)

View File

@ -103,14 +103,18 @@ module ProjectsHelper
end end
end end
def project_title(project) def push_project_breadcrumbs(project)
namespace_link = build_namespace_breadcrumb_link(project) if project.group
project_link = build_project_breadcrumb_link(project) push_group_breadcrumbs(project.group)
else
owner = project.namespace.owner
name = sanitize(owner.name, tags: [])
url = user_path(owner)
namespace_link = breadcrumb_list_item(namespace_link) unless project.group push_to_schema_breadcrumb(name, url)
project_link = breadcrumb_list_item project_link end
"#{namespace_link} #{project_link}".html_safe push_to_schema_breadcrumb(simple_sanitize(project.name), project_path(project), project.try(:avatar_url))
end end
def remove_project_message(project) def remove_project_message(project)
@ -1071,38 +1075,6 @@ module ProjectsHelper
} }
end end
def build_project_breadcrumb_link(project)
project_name = simple_sanitize(project.name)
push_to_schema_breadcrumb(project_name, project_path(project), project.try(:avatar_url))
link_to project_path(project), class: '!gl-inline-flex' do
if project.avatar_url && !Rails.env.test?
icon = render Pajamas::AvatarComponent.new(
project,
alt: project.name,
size: 16,
class: 'avatar-tile'
)
end
[icon, content_tag("span", project_name, class: "js-breadcrumb-item-text")].join.html_safe
end
end
def build_namespace_breadcrumb_link(project)
if project.group
group_title(project.group)
else
owner = project.namespace.owner
name = sanitize(owner.name, tags: [])
url = user_path(owner)
push_to_schema_breadcrumb(name, url)
link_to(name, url)
end
end
def delete_inactive_projects? def delete_inactive_projects?
strong_memoize(:delete_inactive_projects_setting) do strong_memoize(:delete_inactive_projects_setting) do
::Gitlab::CurrentSettings.delete_inactive_projects? ::Gitlab::CurrentSettings.delete_inactive_projects?

View File

@ -170,20 +170,6 @@ module Ci
) )
end end
scope :eager_load_everything, -> do
includes(
[
{ pipeline: [:project, :user] },
:job_artifacts_archive,
:metadata,
:trigger_request,
:project,
:user,
:tags
]
)
end
scope :with_exposed_artifacts, -> do scope :with_exposed_artifacts, -> do
joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts) joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts)
.includes(:metadata, :job_artifacts_metadata) .includes(:metadata, :job_artifacts_metadata)

View File

@ -15,12 +15,11 @@ module Ci
has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable
has_one :sourced_pipeline, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :source_job has_one :sourced_pipeline, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :source_job
has_one :trigger, through: :pipeline
belongs_to :trigger_request belongs_to :trigger_request
belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :processables belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :processables
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
accepts_nested_attributes_for :needs accepts_nested_attributes_for :needs
scope :preload_needs, -> { preload(:needs) } scope :preload_needs, -> { preload(:needs) }
@ -268,6 +267,14 @@ module Ci
options[:manual_confirmation] if manual_job? options[:manual_confirmation] if manual_job?
end end
def trigger_short_token
if ::Feature.enabled?(:ci_read_trigger_from_ci_pipeline, project)
trigger&.short_token
else
trigger_request&.trigger_short_token
end
end
private private
def dependencies def dependencies

View File

@ -37,12 +37,12 @@ module Ci
self.token = "#{TRIGGER_TOKEN_PREFIX}#{SecureRandom.hex(20)}" if self.token.blank? self.token = "#{TRIGGER_TOKEN_PREFIX}#{SecureRandom.hex(20)}" if self.token.blank?
end end
def last_trigger_request
trigger_requests.last
end
def last_used def last_used
last_trigger_request.try(:created_at) if ::Feature.enabled?(:ci_read_trigger_from_ci_pipeline, project)
pipelines.last&.created_at
else
trigger_requests.last&.created_at
end
end end
def short_token def short_token

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Namespaces
module Preloaders
class GroupRootAncestorPreloader < NamespaceRootAncestorPreloader
extend Gitlab::Utils::Override
private
override :join_sql
def join_sql
Group.select('id, traversal_ids[1] as root_id').where(id: @namespaces.map(&:id)).to_sql
end
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Namespaces
module Preloaders
class NamespaceRootAncestorPreloader
def initialize(namespaces, root_ancestor_preloads = [])
@namespaces = namespaces
@root_ancestor_preloads = root_ancestor_preloads
end
def execute
root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
.select('namespaces.*, root_query.id as source_id')
root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
root_ancestors_by_id = root_query.group_by(&:source_id)
@namespaces.each do |namespace|
namespace.root_ancestor = root_ancestors_by_id[namespace.id].first
end
end
private
def join_sql
Namespace.select('id, traversal_ids[1] as root_id').where(id: @namespaces.map(&:id)).to_sql
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
module Namespaces
module Preloaders
class ProjectRootAncestorPreloader
def initialize(projects, namespace_sti_name = :namespace, root_ancestor_preloads = [])
@projects = projects
@namespace_sti_name = namespace_sti_name
@root_ancestor_preloads = root_ancestor_preloads
end
def execute
return unless @projects.is_a?(ActiveRecord::Relation)
root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
.select('namespaces.*, root_query.project_id as source_id')
root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
root_ancestors_by_id = root_query.group_by(&:source_id)
ActiveRecord::Associations::Preloader.new(records: @projects, associations: :namespace).call
@projects.each do |project|
root_ancestor = root_ancestors_by_id[project.id]&.first
project.namespace.root_ancestor = root_ancestor if root_ancestor.present?
end
end
private
def join_sql
@projects
.joins(@namespace_sti_name)
.select('projects.id as project_id, namespaces.traversal_ids[1] as root_id')
.to_sql
end
end
end
end

View File

@ -1,14 +0,0 @@
# frozen_string_literal: true
module Preloaders
class GroupRootAncestorPreloader < NamespaceRootAncestorPreloader
extend Gitlab::Utils::Override
private
override :join_sql
def join_sql
Group.select('id, traversal_ids[1] as root_id').where(id: @namespaces.map(&:id)).to_sql
end
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
module Preloaders
class NamespaceRootAncestorPreloader
def initialize(namespaces, root_ancestor_preloads = [])
@namespaces = namespaces
@root_ancestor_preloads = root_ancestor_preloads
end
def execute
root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
.select('namespaces.*, root_query.id as source_id')
root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
root_ancestors_by_id = root_query.group_by(&:source_id)
@namespaces.each do |namespace|
namespace.root_ancestor = root_ancestors_by_id[namespace.id].first
end
end
private
def join_sql
Namespace.select('id, traversal_ids[1] as root_id').where(id: @namespaces.map(&:id)).to_sql
end
end
end

View File

@ -1,37 +0,0 @@
# frozen_string_literal: true
module Preloaders
class ProjectRootAncestorPreloader
def initialize(projects, namespace_sti_name = :namespace, root_ancestor_preloads = [])
@projects = projects
@namespace_sti_name = namespace_sti_name
@root_ancestor_preloads = root_ancestor_preloads
end
def execute
return unless @projects.is_a?(ActiveRecord::Relation)
root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
.select('namespaces.*, root_query.project_id as source_id')
root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
root_ancestors_by_id = root_query.group_by(&:source_id)
ActiveRecord::Associations::Preloader.new(records: @projects, associations: :namespace).call
@projects.each do |project|
root_ancestor = root_ancestors_by_id[project.id]&.first
project.namespace.root_ancestor = root_ancestor if root_ancestor.present?
end
end
private
def join_sql
@projects
.joins(@namespace_sti_name)
.select('projects.id as project_id, namespaces.traversal_ids[1] as root_id')
.to_sql
end
end
end

View File

@ -13,15 +13,21 @@ module Ci
end end
def trigger_variables def trigger_variables
@trigger_variables ||=
if ::Feature.enabled?(:ci_read_trigger_from_ci_pipeline, project)
return [] if pipeline.trigger_id.blank?
pipeline.variables.map(&:to_hash_variable)
else
return [] unless trigger_request return [] unless trigger_request
@trigger_variables ||=
if pipeline.variables.any? if pipeline.variables.any?
pipeline.variables.map(&:to_hash_variable) pipeline.variables.map(&:to_hash_variable)
else else
trigger_request.user_variables trigger_request.user_variables
end end
end end
end
def execute_in def execute_in
scheduled? && scheduled_at && [0, scheduled_at - Time.now].max scheduled? && scheduled_at && [0, scheduled_at - Time.now].max

View File

@ -90,7 +90,8 @@ class BuildDetailsEntity < Ci::JobEntity
raw_project_job_path(project, build) raw_project_job_path(project, build)
end end
expose :trigger, if: ->(*) { build.trigger_request } do expose :trigger,
if: ->(*) { Feature.enabled?(:ci_read_trigger_from_ci_pipeline, project) ? build.trigger : build.trigger_request } do
expose :trigger_short_token, as: :short_token expose :trigger_short_token, as: :short_token
expose :trigger_variables, as: :variables, using: TriggerVariableEntity expose :trigger_variables, as: :variables, using: TriggerVariableEntity

View File

@ -39,6 +39,7 @@ class PipelineSerializer < BaseSerializer
:cancelable_statuses, :cancelable_statuses,
:retryable_builds, :retryable_builds,
:stages, :stages,
:trigger,
:trigger_requests, :trigger_requests,
:user, :user,
(:latest_statuses if preload_statuses), (:latest_statuses if preload_statuses),

View File

@ -63,7 +63,6 @@ module PersonalAccessTokens
end end
def last_used_ip_needs_update? def last_used_ip_needs_update?
return false unless Feature.enabled?(:pat_ip, @personal_access_token.user)
return false unless Gitlab::IpAddressState.current return false unless Gitlab::IpAddressState.current
return true if @personal_access_token.last_used_at.nil? return true if @personal_access_token.last_used_at.nil?

View File

@ -2,13 +2,27 @@
- header_title _("New project"), new_project_path - header_title _("New project"), new_project_path
- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
= render ::Layouts::PageHeadingComponent.new('') do |c| - if Feature.enabled?(:new_project_creation_form, @user)
- add_page_specific_style 'page_bundles/projects'
- namespace_id = namespace_id_from(params)
#js-import-gitlab-project-root{ data: {
back_button_path: new_project_path(anchor: 'import_project'),
namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || current_user.namespace.full_path,
namespace_id: namespace_id_from(params) || @current_user_group&.id,
import_gitlab_project_path: import_gitlab_project_path,
root_path: root_path,
user_namespace_id: current_user.namespace_id,
can_create_project: current_user.can_create_project?.to_s,
root_url: root_url,
} }
- else
= render ::Layouts::PageHeadingComponent.new('') do |c|
- c.with_heading do - c.with_heading do
%span.gl-inline-flex.gl-items-center.gl-gap-3 %span.gl-inline-flex.gl-items-center.gl-gap-3
= sprite_icon('tanuki', size: 32) = sprite_icon('tanuki', size: 32)
= _('Import an exported GitLab project') = _('Import an exported GitLab project')
= form_tag import_gitlab_project_path, class: 'new_project', multipart: true do = form_tag import_gitlab_project_path, class: 'new_project', multipart: true do
= render 'import/shared/new_project_form' = render 'import/shared/new_project_form'
.row .row
@ -25,4 +39,3 @@
= _('Import project') = _('Import project')
= render Pajamas::ButtonComponent.new(href: new_project_path) do = render Pajamas::ButtonComponent.new(href: new_project_path) do
= _('Cancel') = _('Cancel')

View File

@ -1,6 +1,6 @@
- page_title @group.name - page_title @group.name
- page_description @group.description_html unless page_description - page_description @group.description_html unless page_description
- header_title group_title(@group) unless header_title - push_group_breadcrumbs(@group)
- nav "group" - nav "group"
- display_subscription_banner! - display_subscription_banner!
- base_layout = local_assigns[:base_layout] - base_layout = local_assigns[:base_layout]

View File

@ -1,12 +0,0 @@
- dropdown_location = local_assigns.fetch(:location, nil)
- button_tooltip = local_assigns.fetch(:title, _("Show all breadcrumbs"))
- if defined?(@breadcrumb_collapsed_links) && @breadcrumb_collapsed_links.key?(dropdown_location)
%li.expander.gl-breadcrumb-item.gl-inline-flex
= render Pajamas::ButtonComponent.new(icon: 'ellipsis_h',
button_options: { class: 'button-ellipsis-horizontal js-breadcrumbs-collapsed-expander gl-ml-0', type: "button", data: { container: 'body' }, "aria-label": button_tooltip, title: button_tooltip })
- @breadcrumb_collapsed_links[dropdown_location].each_with_index do |item, index|
%li.gl-breadcrumb-item{ :class => "!gl-hidden" }
= link_to item[:href] do
- if item[:avatar_url]
= render Pajamas::AvatarComponent.new(item[:avatar_url], alt: item[:text], class: "avatar-tile", size: 16)
= item[:text]

View File

@ -1,6 +1,6 @@
- page_title @project.full_name - page_title @project.full_name
- page_description @project.description_html unless page_description - page_description @project.description_html unless page_description
- header_title project_title(@project) unless header_title - push_project_breadcrumbs(@project)
- nav "project" - nav "project"
- page_itemtype 'http://schema.org/SoftwareSourceCode' - page_itemtype 'http://schema.org/SoftwareSourceCode'
- display_subscription_banner! - display_subscription_banner!

View File

@ -1,6 +1,6 @@
--- ---
description: "Selects an editor in the Edit dropdown menu" description: "Selects an editor in the Edit dropdown menu"
category: default internal_events: true
action: click_consolidated_edit action: click_consolidated_edit
extra_properties: extra_properties:
identifiers: identifiers:
@ -14,4 +14,3 @@ tiers:
additional_properties: additional_properties:
label: label:
description: "The editor selected in the Edit dropdown menu" description: "The editor selected in the Edit dropdown menu"

View File

@ -1,9 +1,9 @@
--- ---
name: pat_ip name: ci_read_trigger_from_ci_pipeline
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/428577 feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/502767
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161076 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180728
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/428577 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/508601
milestone: '17.8' milestone: '17.10'
group: group::authentication group: group::ci platform
type: beta type: gitlab_com_derisk
default_enabled: true default_enabled: false

View File

@ -5,6 +5,5 @@ feature_category: continuous_integration
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/163429 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/163429
milestone: '17.4' milestone: '17.4'
queued_migration_version: 20240827095907 queued_migration_version: 20240827095907
# Replace with the approximate date you think it's best to ensure the completion of this BBM.
finalize_after: '2024-09-25' finalize_after: '2024-09-25'
finalized_by: # version of the migration that finalized this BBM finalized_by: '20250218232001'

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeHkBackfillCiBuildNeedsProjectId < Gitlab::Database::Migration[2.2]
milestone '17.10'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_ci
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillCiBuildNeedsProjectId',
table_name: :ci_build_needs,
column_name: :id,
job_arguments: [:project_id, :p_ci_builds, :project_id, :build_id, :partition_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
0ac37c0e9f2bc5df498fc0c1b095e42606eba52f120deb7e6e0df599eeab0a61

View File

@ -8,7 +8,7 @@ title: GitLab Duo Self-Hosted
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -8,7 +8,7 @@ title: Configure GitLab to access GitLab Duo Self-Hosted
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -8,7 +8,7 @@ title: Enable logging for self-hosted models
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -8,7 +8,7 @@ title: GitLab Duo Self-Hosted supported platforms
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -8,7 +8,7 @@ title: Supported GitLab Duo Self-Hosted models and hardware requirements
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -8,7 +8,7 @@ title: Troubleshooting GitLab Duo Self-Hosted
{{< details >}} {{< details >}}
- Tier: Ultimate with GitLab Duo Enterprise - [Start a trial](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial) - Tier: Ultimate with GitLab Duo Enterprise - [Start a GitLab Duo Enterprise trial on a paid Ultimate subscription](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/?type=free-trial)
- Offering: GitLab Self-Managed - Offering: GitLab Self-Managed
{{< /details >}} {{< /details >}}

View File

@ -15385,6 +15385,30 @@ The edge type for [`EpicList`](#epiclist).
| <a id="epiclistedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="epiclistedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="epiclistedgenode"></a>`node` | [`EpicList`](#epiclist) | The item at the end of the edge. | | <a id="epiclistedgenode"></a>`node` | [`EpicList`](#epiclist) | The item at the end of the edge. |
#### `ErrorTrackingStackTraceConnection`
The connection type for [`ErrorTrackingStackTrace`](#errortrackingstacktrace).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="errortrackingstacktraceconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. |
| <a id="errortrackingstacktraceconnectionedges"></a>`edges` | [`[ErrorTrackingStackTraceEdge]`](#errortrackingstacktraceedge) | A list of edges. |
| <a id="errortrackingstacktraceconnectionnodes"></a>`nodes` | [`[ErrorTrackingStackTrace]`](#errortrackingstacktrace) | A list of nodes. |
| <a id="errortrackingstacktraceconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `ErrorTrackingStackTraceEdge`
The edge type for [`ErrorTrackingStackTrace`](#errortrackingstacktrace).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="errortrackingstacktraceedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="errortrackingstacktraceedgenode"></a>`node` | [`ErrorTrackingStackTrace`](#errortrackingstacktrace) | The item at the end of the edge. |
#### `EscalationPolicyTypeConnection` #### `EscalationPolicyTypeConnection`
The connection type for [`EscalationPolicyType`](#escalationpolicytype). The connection type for [`EscalationPolicyType`](#escalationpolicytype).
@ -25463,6 +25487,21 @@ Check permissions for the current user on an epic.
| <a id="epicpermissionsreadepiciid"></a>`readEpicIid` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_epic_iid` on this resource. | | <a id="epicpermissionsreadepiciid"></a>`readEpicIid` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_epic_iid` on this resource. |
| <a id="epicpermissionsupdateepic"></a>`updateEpic` | [`Boolean!`](#boolean) | If `true`, the user can perform `update_epic` on this resource. | | <a id="epicpermissionsupdateepic"></a>`updateEpic` | [`Boolean!`](#boolean) | If `true`, the user can perform `update_epic` on this resource. |
### `ErrorTrackingStackTrace`
Represents a stack trace.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="errortrackingstacktraceabsolutepath"></a>`absolutePath` | [`String`](#string) | Absolute path of the stack trace. |
| <a id="errortrackingstacktracecolumnnumber"></a>`columnNumber` | [`Int`](#int) | Column number of the stack trace. |
| <a id="errortrackingstacktracecontext"></a>`context` | [`[WorkItemWidgetErrorTrackingStackTraceContext!]`](#workitemwidgeterrortrackingstacktracecontext) | Context of the stack trace. |
| <a id="errortrackingstacktracefilename"></a>`filename` | [`String`](#string) | Filename of the stack trace. |
| <a id="errortrackingstacktracefunction"></a>`function` | [`String`](#string) | Name of the function where the error occured. |
| <a id="errortrackingstacktracelinenumber"></a>`lineNumber` | [`Int`](#int) | Line number of the stack trace. |
### `EscalationPolicyType` ### `EscalationPolicyType`
Represents an escalation policy. Represents an escalation policy.
@ -39651,9 +39690,22 @@ Represents the error tracking widget.
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="workitemwidgeterrortrackingidentifier"></a>`identifier` | [`BigInt`](#bigint) | Error tracking issue id. | | <a id="workitemwidgeterrortrackingidentifier"></a>`identifier` | [`BigInt`](#bigint) | Error tracking issue id.This field can only be resolved for one work item in any single request. |
| <a id="workitemwidgeterrortrackingstacktrace"></a>`stackTrace` | [`ErrorTrackingStackTraceConnection`](#errortrackingstacktraceconnection) | Stack trace details of the error.This field can only be resolved for one work item in any single request. (see [Connections](#connections)) |
| <a id="workitemwidgeterrortrackingstatus"></a>`status` | [`ErrorTrackingStatus`](#errortrackingstatus) | Response status of error service.This field can only be resolved for one work item in any single request. |
| <a id="workitemwidgeterrortrackingtype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | | <a id="workitemwidgeterrortrackingtype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. |
### `WorkItemWidgetErrorTrackingStackTraceContext`
Represents details about a line of code of the stack trace.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemwidgeterrortrackingstacktracecontextline"></a>`line` | [`String`](#string) | Line of code. |
| <a id="workitemwidgeterrortrackingstacktracecontextlinenumber"></a>`lineNumber` | [`Int`](#int) | Line number of code. |
### `WorkItemWidgetHealthStatus` ### `WorkItemWidgetHealthStatus`
Represents a health status widget. Represents a health status widget.
@ -41402,6 +41454,17 @@ Epic ID wildcard values.
| <a id="epicwildcardidany"></a>`ANY` | Any epic is assigned. | | <a id="epicwildcardidany"></a>`ANY` | Any epic is assigned. |
| <a id="epicwildcardidnone"></a>`NONE` | No epic is assigned. | | <a id="epicwildcardidnone"></a>`NONE` | No epic is assigned. |
### `ErrorTrackingStatus`
Status of the error tracking service.
| Value | Description |
| ----- | ----------- |
| <a id="errortrackingstatuserror"></a>`ERROR` | Error tracking service respond with an error. |
| <a id="errortrackingstatusnot_found"></a>`NOT_FOUND` | Sentry issue not found. |
| <a id="errortrackingstatusretry"></a>`RETRY` | Error tracking service is not ready. |
| <a id="errortrackingstatussuccess"></a>`SUCCESS` | Successfuly fetch the stack trace. |
### `EscalationRuleStatus` ### `EscalationRuleStatus`
Escalation rule statuses. Escalation rule statuses.

View File

@ -30,10 +30,10 @@ A REST API request must start with the root endpoint and the path.
- The path must start with `/api/v4` (`v4` represents the API version). - The path must start with `/api/v4` (`v4` represents the API version).
In the following example, the API request retrieves the list of all projects on GitLab host In the following example, the API request retrieves the list of all projects on GitLab host
`example.com`: `gitlab.example.com`:
```shell ```shell
curl "https://example.com/api/v4/projects" curl "https://gitlab.example.com/api/v4/projects"
``` ```
Access to some endpoints require authentication. For more information, see Access to some endpoints require authentication. For more information, see
@ -69,14 +69,14 @@ send the payload body:
- Query string: - Query string:
```shell ```shell
curl --request POST "https://example.com/api/v4/projects?name=<example-name>&description=<example-description>" curl --request POST "https://gitlab.example.com/api/v4/projects?name=<example-name>&description=<example-description>"
``` ```
- Request payload (JSON): - Request payload (JSON):
```shell ```shell
curl --request POST --header "Content-Type: application/json" \ curl --request POST --header "Content-Type: application/json" \
--data '{"name":"<example-name>", "description":"<example-description>"}' "https://example.com/api/v4/projects" --data '{"name":"<example-name>", "description":"<example-description>"}' "https://gitlab.example.com/api/v4/projects"
``` ```
URL encoded query strings have a length limitation. Requests that are too large URL encoded query strings have a length limitation. Requests that are too large

View File

@ -31,7 +31,7 @@ For example, this script uses a colon:
```yaml ```yaml
job: job:
script: script:
- curl --request POST --header 'Content-Type: application/json' "https://gitlab/api/v4/projects" - curl --request POST --header 'Content-Type: application/json' "https://gitlab.example.com/api/v4/projects"
``` ```
To be considered valid YAML, you must wrap the entire command in single quotes. If To be considered valid YAML, you must wrap the entire command in single quotes. If
@ -41,7 +41,7 @@ if possible:
```yaml ```yaml
job: job:
script: script:
- 'curl --request POST --header "Content-Type: application/json" "https://gitlab/api/v4/projects"' - 'curl --request POST --header "Content-Type: application/json" "https://gitlab.example.com/api/v4/projects"'
``` ```
You can verify the syntax is valid with the [CI Lint](../yaml/lint.md) tool. You can verify the syntax is valid with the [CI Lint](../yaml/lint.md) tool.

View File

@ -66,6 +66,7 @@ Dashboards support the following filters:
- **Date range**: Date selector to filter data by date. - **Date range**: Date selector to filter data by date.
- **Anonymous users**: Toggle to include or exclude anonymous users from the dataset. - **Anonymous users**: Toggle to include or exclude anonymous users from the dataset.
- **Project**: Dropdown list to filter data by project.
#### Dashboard status #### Dashboard status
@ -132,6 +133,8 @@ To create a built-in analytics dashboard:
enabled: true enabled: true
dateRange: dateRange:
enabled: true enabled: true
projects:
enabled: true
``` ```
Refer to the `DashboardFilters` type in the [`ee/app/validators/json_schemas/analytics_dashboard.json`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/validators/json_schemas/analytics_dashboard.json) for a list of supported filters. Refer to the `DashboardFilters` type in the [`ee/app/validators/json_schemas/analytics_dashboard.json`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/validators/json_schemas/analytics_dashboard.json) for a list of supported filters.

View File

@ -25,7 +25,7 @@ and thumbs-ups. React with emoji on:
- [Issues](project/issues/_index.md). - [Issues](project/issues/_index.md).
- [Tasks](tasks.md). - [Tasks](tasks.md).
- [Merge requests](project/merge_requests/_index.md), [snippets](snippets.md). - [Merge requests](project/merge_requests/_index.md) and [snippets](snippets.md).
- [Epics](group/epics/_index.md). - [Epics](group/epics/_index.md).
- [Objectives and key results](okrs.md). - [Objectives and key results](okrs.md).
- Anywhere else you can have a comment thread. - Anywhere else you can have a comment thread.

View File

@ -216,6 +216,7 @@ When you delete or block an enterprise user account, their personal access token
- In GitLab 16.0 and earlier, token usage information is updated every 24 hours. - In GitLab 16.0 and earlier, token usage information is updated every 24 hours.
- The frequency of token usage information updates [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/410168) in GitLab 16.1 from 24 hours to 10 minutes. - The frequency of token usage information updates [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/410168) in GitLab 16.1 from 24 hours to 10 minutes.
- Ability to view IP addresses [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/428577) in GitLab 17.8 [with a flag](../../administration/feature_flags.md) named `pat_ip`. Enabled by default in 17.9. - Ability to view IP addresses [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/428577) in GitLab 17.8 [with a flag](../../administration/feature_flags.md) named `pat_ip`. Enabled by default in 17.9.
- Ability to view IP addresses made [generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/513302) in GitLab 17.10. Feature flag `pat_ip` removed.
{{< /history >}} {{< /history >}}

View File

@ -73,7 +73,7 @@ module API
authenticate! authenticate!
authorize! :admin_build, user_project authorize! :admin_build, user_project
triggers = user_project.triggers.includes(:trigger_requests) triggers = user_project.triggers.includes(:trigger_requests, :pipelines)
present paginate(triggers), with: Entities::Trigger, current_user: current_user present paginate(triggers), with: Entities::Trigger, current_user: current_user
end end

View File

@ -50,7 +50,7 @@ module API
group_projects = projects_for_group_preload(projects_relation) group_projects = projects_for_group_preload(projects_relation)
groups = group_projects.map(&:namespace) groups = group_projects.map(&:namespace)
Preloaders::GroupRootAncestorPreloader.new(groups).execute ::Namespaces::Preloaders::GroupRootAncestorPreloader.new(groups).execute
group_projects.each do |project| group_projects.each do |project|
project.group = project.namespace project.group = project.namespace

View File

@ -5,6 +5,7 @@ module BulkImports
module Pipelines module Pipelines
class MembersPipeline class MembersPipeline
include Pipeline include Pipeline
include HexdigestCacheStrategy
GROUP_MEMBER_RELATIONS = %i[direct inherited shared_from_groups].freeze GROUP_MEMBER_RELATIONS = %i[direct inherited shared_from_groups].freeze
PROJECT_MEMBER_RELATIONS = %i[direct inherited invited_groups shared_into_ancestors].freeze PROJECT_MEMBER_RELATIONS = %i[direct inherited invited_groups shared_into_ancestors].freeze
@ -16,12 +17,24 @@ module BulkImports
transformer Import::BulkImports::Common::Transformers::SourceUserMemberAttributesTransformer transformer Import::BulkImports::Common::Transformers::SourceUserMemberAttributesTransformer
def extract(context) def extract(context)
graphql_extractor.extract(context) extracted_data = graphql_extractor.extract(context)
# add source_xid to each entry to ensure uniqueness when caching
extracted_data.each do |entry|
entry['source_xid'] = context.source_xid
entry['entity_type'] = context.entity_type
end
extracted_data
end end
def load(_context, data) def load(_context, data)
return unless data return unless data
# Remove source_xid and entity_type since we don't use them in membership creation
data.delete('source_xid')
data.delete('entity_type')
if data[:source_user] if data[:source_user]
create_placeholder_membership(data) create_placeholder_membership(data)
else else

View File

@ -7,6 +7,8 @@ module BulkImports
attr_reader :tracker attr_reader :tracker
delegate :source_xid, :entity_type, to: :entity
def initialize(tracker, extra = {}) def initialize(tracker, extra = {})
@tracker = tracker @tracker = tracker
@extra = extra @extra = extra

View File

@ -49,15 +49,19 @@ module Ci
end end
def build_payload(job) def build_payload(job)
base_payload = { cell_id: Gitlab.config.cell.id } base_payload = { scoped_user_id: job.scoped_user&.id }.compact_blank
base_payload.merge(extra_payload(job)).compact_blank base_payload.merge(routable_payload(job))
end end
def extra_payload(job) # Creating routing information for routable tokens https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/cells/routable_tokens/
def routable_payload(job)
{ {
scoped_user_id: job.scoped_user&.id, c: Gitlab.config.cell.id,
organization_id: job.project.organization_id o: job.project.organization_id,
} u: job.user_id,
p: job.project_id,
g: job.project.group&.id
}.compact_blank.transform_values { |id| id.to_s(36) }
end end
def token_prefix def token_prefix
@ -101,14 +105,30 @@ module Ci
strong_memoize_attr :scoped_user strong_memoize_attr :scoped_user
def cell_id def cell_id
@jwt.payload['cell_id'] decode(@jwt.payload['c'])
end end
strong_memoize_attr :cell_id
def organization def organization_id
job&.project&.organization decode(@jwt.payload['o'])
end
def project_id
decode(@jwt.payload['p'])
end
def user_id
decode(@jwt.payload['u'])
end
def group_id
decode(@jwt.payload['g'])
end
private
def decode(encoded_value)
encoded_value&.to_i(36)
end end
strong_memoize_attr :organization
end end
end end
end end

View File

@ -48,26 +48,26 @@ module Gitlab
'de' => 97, 'de' => 97,
'en' => 100, 'en' => 100,
'eo' => 0, 'eo' => 0,
'es' => 38, 'es' => 40,
'fil_PH' => 0, 'fil_PH' => 0,
'fr' => 98, 'fr' => 97,
'gl_ES' => 0, 'gl_ES' => 0,
'id_ID' => 0, 'id_ID' => 0,
'it' => 84, 'it' => 85,
'ja' => 99, 'ja' => 96,
'ko' => 30, 'ko' => 47,
'nb_NO' => 16, 'nb_NO' => 16,
'nl_NL' => 0, 'nl_NL' => 0,
'pl_PL' => 2, 'pl_PL' => 2,
'pt_BR' => 92, 'pt_BR' => 93,
'ro_RO' => 50, 'ro_RO' => 49,
'ru' => 15, 'ru' => 54,
'si_LK' => 9, 'si_LK' => 9,
'tr_TR' => 6, 'tr_TR' => 6,
'uk' => 38, 'uk' => 37,
'zh_CN' => 89, 'zh_CN' => 86,
'zh_HK' => 1, 'zh_HK' => 1,
'zh_TW' => 85 'zh_TW' => 81
}.freeze }.freeze
private_constant :TRANSLATION_LEVELS private_constant :TRANSLATION_LEVELS

View File

@ -25,8 +25,12 @@ module Gitlab
def wrap_mentions_in_backticks(text) def wrap_mentions_in_backticks(text)
return text unless text.present? return text unless text.present?
if MENTION_REGEX.match?(text) resultant_array = []
text = MENTION_REGEX.replace_gsub(text) do |match|
split_array = text.split("\n")
split_array.each do |line|
if MENTION_REGEX.match?(line)
line = MENTION_REGEX.replace_gsub(line) do |match|
case match[0] case match[0]
when /^`/ when /^`/
match[0] match[0]
@ -40,7 +44,10 @@ module Gitlab
end end
end end
text resultant_array << line
end
resultant_array.join("\n")
end end
end end
end end

View File

@ -1021,6 +1021,7 @@ excluded_attributes:
- :pipeline_schedule_id - :pipeline_schedule_id
- :merge_request_id - :merge_request_id
- :external_pull_request_id - :external_pull_request_id
- :trigger_id
- :ci_ref_id - :ci_ref_id
- :locked - :locked
pipeline_metadata: pipeline_metadata:

View File

@ -21399,6 +21399,9 @@ msgstr ""
msgid "Drop or %{linkStart}upload%{linkEnd} files to attach" msgid "Drop or %{linkStart}upload%{linkEnd} files to attach"
msgstr "" msgstr ""
msgid "Drop or upload file to attach"
msgstr ""
msgid "Drop your designs to start your upload." msgid "Drop your designs to start your upload."
msgstr "" msgstr ""
@ -26441,6 +26444,9 @@ msgstr ""
msgid "GitLabPages|Your project is configured for GitLab Pages and the pipeline is running..." msgid "GitLabPages|Your project is configured for GitLab Pages and the pipeline is running..."
msgstr "" msgstr ""
msgid "GitPod"
msgstr ""
msgid "Gitaly servers" msgid "Gitaly servers"
msgstr "" msgstr ""
@ -30991,6 +30997,9 @@ msgstr ""
msgid "Integration|Branches for which notifications are to be sent" msgid "Integration|Branches for which notifications are to be sent"
msgstr "" msgstr ""
msgid "IntelliJ IDEA"
msgstr ""
msgid "IntelliJ IDEA (HTTPS)" msgid "IntelliJ IDEA (HTTPS)"
msgstr "" msgstr ""
@ -46060,6 +46069,9 @@ msgstr ""
msgid "ProjectsNew|Get started with one of our popular project templates." msgid "ProjectsNew|Get started with one of our popular project templates."
msgstr "" msgstr ""
msgid "ProjectsNew|GitLab project export"
msgstr ""
msgid "ProjectsNew|Gitea host URL" msgid "ProjectsNew|Gitea host URL"
msgstr "" msgstr ""
@ -46099,6 +46111,9 @@ msgstr ""
msgid "ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces." msgid "ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces."
msgstr "" msgstr ""
msgid "ProjectsNew|My awesome project"
msgstr ""
msgid "ProjectsNew|New project" msgid "ProjectsNew|New project"
msgstr "" msgstr ""
@ -46117,6 +46132,15 @@ msgstr ""
msgid "ProjectsNew|Please enter a valid personal access token." msgid "ProjectsNew|Please enter a valid personal access token."
msgstr "" msgstr ""
msgid "ProjectsNew|Please enter a valid project name."
msgstr ""
msgid "ProjectsNew|Please enter a valid project slug."
msgstr ""
msgid "ProjectsNew|Please upload a valid GitLab project export file."
msgstr ""
msgid "ProjectsNew|Project Configuration" msgid "ProjectsNew|Project Configuration"
msgstr "" msgstr ""
@ -46126,6 +46150,9 @@ msgstr ""
msgid "ProjectsNew|Project name" msgid "ProjectsNew|Project name"
msgstr "" msgstr ""
msgid "ProjectsNew|Project slug"
msgstr ""
msgid "ProjectsNew|Projects" msgid "ProjectsNew|Projects"
msgstr "" msgstr ""
@ -46144,6 +46171,9 @@ msgstr ""
msgid "ProjectsNew|Select a template" msgid "ProjectsNew|Select a template"
msgstr "" msgstr ""
msgid "ProjectsNew|To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here."
msgstr ""
msgid "ProjectsNew|Unable to suggest a path. Please refresh and try again." msgid "ProjectsNew|Unable to suggest a path. Please refresh and try again."
msgstr "" msgstr ""
@ -46168,6 +46198,9 @@ msgstr ""
msgid "ProjectsNew|https://mycompany.fogbugz.com" msgid "ProjectsNew|https://mycompany.fogbugz.com"
msgstr "" msgstr ""
msgid "ProjectsNew|my-awesome-project"
msgstr ""
msgid "Projects|An error occurred deleting the project. Please refresh the page to try again." msgid "Projects|An error occurred deleting the project. Please refresh the page to try again."
msgstr "" msgstr ""
@ -54438,9 +54471,6 @@ msgstr ""
msgid "Show all activity" msgid "Show all activity"
msgstr "" msgstr ""
msgid "Show all breadcrumbs"
msgstr ""
msgid "Show all comments" msgid "Show all comments"
msgstr "" msgstr ""
@ -63740,6 +63770,9 @@ msgstr ""
msgid "Visit new homepage" msgid "Visit new homepage"
msgstr "" msgstr ""
msgid "Visual Studio Code"
msgstr ""
msgid "Visual Studio Code (HTTPS)" msgid "Visual Studio Code (HTTPS)"
msgstr "" msgstr ""

View File

@ -536,9 +536,10 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end end
context 'when requesting triggered job JSON' do context 'when requesting triggered job JSON' do
let(:trigger) { create(:ci_trigger, project: project) } let_it_be(:trigger) { create(:ci_trigger, project: project) }
let(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, trigger: trigger) } let_it_be(:pipeline) { create(:ci_pipeline, project: project, trigger: trigger) }
let(:job) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) } let_it_be(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, trigger: trigger) }
let_it_be(:job) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) }
let(:user) { developer } let(:user) { developer }
before do before do

View File

@ -59,6 +59,10 @@ FactoryBot.define do
status { :created } status { :created }
end end
trait :triggered do
trigger { association :ci_trigger, project_id: project_id }
end
factory :ci_pipeline do factory :ci_pipeline do
trait :invalid do trait :invalid do
status { :failed } status { :failed }

View File

@ -45,6 +45,11 @@ RSpec.describe 'IDE', :js, :with_current_organization, feature_category: :web_id
end end
end end
where(:directory_code_dropdown_updates) do
[true, false]
end
with_them do
describe 'with sub-groups' do describe 'with sub-groups' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:subgroup) { create(:group, parent: group) }
@ -54,6 +59,7 @@ RSpec.describe 'IDE', :js, :with_current_organization, feature_category: :web_id
before do before do
stub_feature_flags(vscode_web_ide: true) stub_feature_flags(vscode_web_ide: true)
stub_feature_flags(directory_code_dropdown_updates: directory_code_dropdown_updates)
ide_visit(project) ide_visit(project)
end end
@ -64,10 +70,12 @@ RSpec.describe 'IDE', :js, :with_current_organization, feature_category: :web_id
describe 'with vscode feature flag off' do describe 'with vscode feature flag off' do
before do before do
stub_feature_flags(vscode_web_ide: false) stub_feature_flags(vscode_web_ide: false)
stub_feature_flags(directory_code_dropdown_updates: directory_code_dropdown_updates)
ide_visit(project) ide_visit(project)
end end
it_behaves_like 'legacy Web IDE' it_behaves_like 'legacy Web IDE'
end end
end
end end

View File

@ -201,7 +201,8 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
it 'opens the Web IDE in a forked project', :sidekiq_might_not_need_inline do it 'opens the Web IDE in a forked project', :sidekiq_might_not_need_inline do
click_link('.gitignore') click_link('.gitignore')
edit_in_web_ide click_button 'Edit'
click_link_or_button 'Web IDE'
expect_fork_prompt expect_fork_prompt

View File

@ -443,7 +443,9 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
end end
describe 'Variables' do describe 'Variables' do
let(:trigger_request) { create(:ci_trigger_request, project_id: project.id) } let(:trigger) { create(:ci_trigger, project: project) }
let(:pipeline) { create(:ci_pipeline, trigger: trigger, project: project, sha: project.commit('HEAD').sha) }
let(:trigger_request) { create(:ci_trigger_request, trigger: trigger) }
let(:job) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) } let(:job) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) }
context 'when user is a maintainer' do context 'when user is a maintainer' do
@ -459,6 +461,11 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
end end
end end
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
context 'when variables are stored in trigger_request' do context 'when variables are stored in trigger_request' do
before do before do
trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' }) trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' })
@ -468,6 +475,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
it_behaves_like 'no reveal button variables behavior' it_behaves_like 'no reveal button variables behavior'
end end
end
context 'when variables are stored in pipeline_variables' do context 'when variables are stored in pipeline_variables' do
before do before do
@ -504,6 +512,11 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
end end
end end
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
context 'when variables are stored in trigger_request' do context 'when variables are stored in trigger_request' do
before do before do
trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' }) trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' })
@ -513,6 +526,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
it_behaves_like 'reveal button variables behavior' it_behaves_like 'reveal button variables behavior'
end end
end
context 'when variables are stored in pipeline_variables' do context 'when variables are stored in pipeline_variables' do
before do before do

View File

@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :groups_and_projects do RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :groups_and_projects do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
let_it_be(:project) { create(:project, :repository, :public) } let_it_be(:project1) { create(:project, :repository, :public) }
let_it_be(:project2) { create(:project, :repository, :public) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
before do before do
@ -17,13 +18,18 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
end end
context 'with developer user' do context 'with developer user' do
context 'when directory_code_dropdown_updates is true' do
before_all do before_all do
project1.add_developer(user)
end
before do
stub_feature_flags(blob_overflow_menu: false) stub_feature_flags(blob_overflow_menu: false)
project.add_developer(user) stub_feature_flags(directory_code_dropdown_updates: true)
end end
it 'shows all the expected links' do it 'shows all the expected links' do
visit project_path(project) visit project_path(project1)
# The navigation bar # The navigation bar
within_testid('super-sidebar') do within_testid('super-sidebar') do
@ -32,7 +38,7 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
aggregate_failures 'dropdown links in the navigation bar' do aggregate_failures 'dropdown links in the navigation bar' do
expect(page).to have_link('New issue') expect(page).to have_link('New issue')
expect(page).to have_link('New merge request') expect(page).to have_link('New merge request')
expect(page).to have_link('New snippet', href: new_project_snippet_path(project)) expect(page).to have_link('New snippet', href: new_project_snippet_path(project1))
end end
find_new_menu_toggle.click find_new_menu_toggle.click
@ -52,14 +58,16 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
end end
# The Web IDE # The Web IDE
click_button 'Edit' within_testid('code-dropdown') do
expect(page).to have_button('Web IDE') click_button 'Code'
end
expect(page).to have_link('Web IDE')
end end
it 'hides the links when the project is archived' do it 'hides the links when the project is archived' do
project.update!(archived: true) project1.update!(archived: true)
visit project_path(project) visit project_path(project1)
within_testid('super-sidebar') do within_testid('super-sidebar') do
find_new_menu_toggle.click find_new_menu_toggle.click
@ -67,7 +75,7 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
aggregate_failures 'dropdown links' do aggregate_failures 'dropdown links' do
expect(page).not_to have_link('New issue') expect(page).not_to have_link('New issue')
expect(page).not_to have_link('New merge request') expect(page).not_to have_link('New merge request')
expect(page).not_to have_link('New snippet', href: new_project_snippet_path(project)) expect(page).not_to have_link('New snippet', href: new_project_snippet_path(project1))
end end
find_new_menu_toggle.click find_new_menu_toggle.click
@ -75,7 +83,78 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
expect(page).not_to have_selector('[data-testid="add-to-tree"]') expect(page).not_to have_selector('[data-testid="add-to-tree"]')
within_testid('code-dropdown') do
click_button('Code')
expect(page).not_to have_button('Edit') expect(page).not_to have_button('Edit')
expect(page).not_to have_link('Web IDE')
end
end
end
context 'when directory_code_dropdown_updates is false' do
before_all do
project2.add_developer(user)
end
before do
stub_feature_flags(blob_overflow_menu: false)
stub_feature_flags(directory_code_dropdown_updates: false)
end
it 'shows all the expected links' do
visit project_path(project2)
# The navigation bar
within_testid('super-sidebar') do
find_new_menu_toggle.click
aggregate_failures 'dropdown links in the navigation bar' do
expect(page).to have_link('New issue')
expect(page).to have_link('New merge request')
expect(page).to have_link('New snippet', href: new_project_snippet_path(project2))
end
find_new_menu_toggle.click
end
# The dropdown above the tree
page.within('.repo-breadcrumb') do
find_by_testid('add-to-tree').click
aggregate_failures 'dropdown links above the repo tree' do
expect(page).to have_link('New file')
expect(page).to have_button('Upload file')
expect(page).to have_button('New directory')
expect(page).to have_link('New branch')
expect(page).to have_link('New tag')
end
end
# The Web IDE
click_button 'Edit'
expect(page).to have_button('Web IDE')
end
it 'hides the links when the project is archived' do
project2.update!(archived: true)
visit project_path(project2)
within_testid('super-sidebar') do
find_new_menu_toggle.click
aggregate_failures 'dropdown links' do
expect(page).not_to have_link('New issue')
expect(page).not_to have_link('New merge request')
expect(page).not_to have_link('New snippet', href: new_project_snippet_path(project2))
end
find_new_menu_toggle.click
end
expect(page).not_to have_selector('[data-testid="add-to-tree"]')
expect(page).not_to have_button('Edit')
end
end end
end end
@ -90,10 +169,28 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
end end
with_them do with_them do
context 'when directory_code_dropdown_updates is true' do
before do before do
project.project_feature.update!({ merge_requests_access_level: merge_requests_access_level }) stub_feature_flags(directory_code_dropdown_updates: true)
project.add_member(user, user_level) project1.project_feature.update!({ merge_requests_access_level: merge_requests_access_level })
visit project_path(project) project1.add_member(user, user_level)
visit project_path(project1)
end
it "updates Web IDE link" do
within_testid('code-dropdown') do
click_button 'Code'
end
expect(page.has_link?('Web IDE')).to be(expect_ide_link)
end
end
context 'when directory_code_dropdown_updates is false' do
before do
stub_feature_flags(directory_code_dropdown_updates: false)
project2.project_feature.update!({ merge_requests_access_level: merge_requests_access_level })
project2.add_member(user, user_level)
visit project_path(project2)
end end
it "updates Web IDE link" do it "updates Web IDE link" do
@ -101,4 +198,5 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: :
end end
end end
end end
end
end end

View File

@ -8,8 +8,14 @@ RSpec.describe 'Multi-file editor new directory', :js, feature_category: :web_id
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
where(:directory_code_dropdown_updates) do
[true, false]
end
with_them do
before do before do
stub_feature_flags(vscode_web_ide: false) stub_feature_flags(vscode_web_ide: false)
stub_feature_flags(directory_code_dropdown_updates: directory_code_dropdown_updates)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
@ -73,4 +79,5 @@ RSpec.describe 'Multi-file editor new directory', :js, feature_category: :web_id
expect(page).to have_content('folder name') expect(page).to have_content('folder name')
end end
end
end end

View File

@ -8,8 +8,14 @@ RSpec.describe 'Multi-file editor new file', :js, feature_category: :web_ide do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
where(:directory_code_dropdown_updates) do
[true, false]
end
with_them do
before do before do
stub_feature_flags(vscode_web_ide: false) stub_feature_flags(vscode_web_ide: false)
stub_feature_flags(directory_code_dropdown_updates: directory_code_dropdown_updates)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
@ -26,7 +32,8 @@ RSpec.describe 'Multi-file editor new file', :js, feature_category: :web_ide do
end end
it 'creates file in current directory' do it 'creates file in current directory' do
wait_for_requests wait_for_all_requests
first('.ide-tree-actions button').click first('.ide-tree-actions button').click
page.within('.modal') do page.within('.modal') do
@ -60,4 +67,5 @@ RSpec.describe 'Multi-file editor new file', :js, feature_category: :web_ide do
expect(page).to have_content('file name') expect(page).to have_content('file name')
end end
end
end end

View File

@ -140,7 +140,10 @@ RSpec.describe 'Projects tree', :js, feature_category: :web_ide do
stub_feature_flags(vscode_web_ide: false) stub_feature_flags(vscode_web_ide: false)
end end
context 'when directory_code_dropdown_updates is enabled' do
it 'opens folder in IDE' do it 'opens folder in IDE' do
stub_feature_flags(directory_code_dropdown_updates: true)
visit project_tree_path(project, File.join('master', 'bar')) visit project_tree_path(project, File.join('master', 'bar'))
ide_visit_from_link ide_visit_from_link
@ -151,6 +154,21 @@ RSpec.describe 'Projects tree', :js, feature_category: :web_ide do
end end
end end
context 'when directory_code_dropdown_updates is disabled' do
it 'opens folder in IDE' do
stub_feature_flags(directory_code_dropdown_updates: false)
visit project_tree_path(project, File.join('master', 'bar'))
ide_visit_from_link
wait_for_all_requests
find('.ide-file-list')
wait_for_requests
expect(page).to have_selector('.is-open', text: 'bar')
end
end
end
context 'for subgroups' do context 'for subgroups' do
let(:group) { create(:group) } let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) } let(:subgroup) { create(:group, parent: group) }

View File

@ -62,9 +62,6 @@ describe('~/access_tokens/components/access_token_table_app', () => {
initialActiveAccessTokens: defaultActiveAccessTokens, initialActiveAccessTokens: defaultActiveAccessTokens,
noActiveTokensMessage, noActiveTokensMessage,
showRole, showRole,
glFeatures: {
patIp: true,
},
...props, ...props,
}, },
}); });

View File

@ -0,0 +1,164 @@
import { nextTick } from 'vue';
import { GlAnimatedUploadIcon, GlFormInput } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import importFromGitlabExportApp from '~/import/gitlab_project/import_from_gitlab_export_app.vue';
import MultiStepFormTemplate from '~/vue_shared/components/multi_step_form_template.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('Import from GitLab export file app', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(importFromGitlabExportApp, {
propsData: {
backButtonPath: '/projects/new#import_project',
namespaceFullPath: 'root',
namespaceId: '1',
rootPath: '/',
importGitlabProjectPath: 'import/path',
},
stubs: {
GlFormInput,
},
});
};
beforeEach(() => {
createComponent();
});
const findMultiStepForm = () => wrapper.findComponent(MultiStepFormTemplate);
const findForm = () => wrapper.find('form');
const findProjectNameInput = () => wrapper.findByTestId('project-name');
const findProjectSlugInput = () => wrapper.findByTestId('project-slug');
const findDropzoneButton = () => wrapper.findByTestId('dropzone-button');
const findDropzoneInput = () => wrapper.findByTestId('dropzone-input');
const findAnimatedUploadIcon = () => wrapper.findComponent(GlAnimatedUploadIcon);
const findBackButton = () => wrapper.findByTestId('back-button');
const findNextButton = () => wrapper.findByTestId('next-button');
const setProjectName = async (projectName) => {
await findProjectNameInput().setValue(projectName);
await findProjectNameInput().trigger('blur');
await nextTick();
};
const uploadFile = async () => {
const file = new File(['foo'], 'foo.gz', { type: 'application/gzip', size: 1024 });
Object.defineProperty(findDropzoneInput().element, 'files', { value: [file] });
findDropzoneInput().trigger('change');
await nextTick();
};
describe('form', () => {
it('renders the multi step form correctly', () => {
expect(findMultiStepForm().props()).toMatchObject({
currentStep: 3,
stepsTotal: 3,
});
});
it('renders the form element correctly', () => {
const form = findForm();
expect(form.attributes('action')).toBe('import/path');
expect(form.find('input[type=hidden][name=authenticity_token]').attributes('value')).toBe(
'mock-csrf-token',
);
});
it('does not submit the form without requried fields', () => {
const submitSpy = jest.spyOn(findForm().element, 'submit');
findForm().trigger('submit');
expect(submitSpy).not.toHaveBeenCalled();
});
it('submits the form with valid form data', async () => {
const submitSpy = jest.spyOn(findForm().element, 'submit');
await setProjectName('test project');
uploadFile();
await nextTick();
findForm().trigger('submit');
expect(submitSpy).toHaveBeenCalledWith();
});
});
describe('validation', () => {
it('shows an error message when project name is cleared', async () => {
await setProjectName('');
const formGroup = wrapper.findByTestId('project-name-form-group');
expect(formGroup.vm.$attrs['invalid-feedback']).toBe('Please enter a valid project name.');
});
it('shows an error message when project name starts with invalid characters', async () => {
await setProjectName('#test');
const formGroup = wrapper.findByTestId('project-name-form-group');
expect(formGroup.vm.$attrs['invalid-feedback']).toBe(
'Name must start with a letter, digit, emoji, or underscore.',
);
});
it('shows an error message when project name contains invalid characters', async () => {
await setProjectName('test?');
const formGroup = wrapper.findByTestId('project-name-form-group');
expect(formGroup.vm.$attrs['invalid-feedback']).toBe(
'Name can contain only lowercase or uppercase letters, digits, emoji, spaces, dots, underscores, dashes, or pluses.',
);
});
it('shows an error message when there are no file uploaded', async () => {
findForm().trigger('submit');
await nextTick();
const formGroup = wrapper.findByTestId('project-file-form-group');
expect(formGroup.vm.$attrs['invalid-feedback']).toBe(
'Please upload a valid GitLab project export file.',
);
});
});
describe('project slug', () => {
it('updates the project slug appropriately when updating project name', async () => {
await setProjectName('test project');
expect(findProjectSlugInput().props('value')).toBe('test-project');
});
});
describe('drop zone', () => {
it('renders a drop zone', () => {
expect(findDropzoneInput().exists()).toBe(true);
expect(findDropzoneButton().text()).toBe('Drop or upload file to attach');
expect(findAnimatedUploadIcon().exists()).toBe(true);
});
it('uploads a file', async () => {
await uploadFile();
expect(findDropzoneButton().text()).toContain('foo.gz');
expect(findAnimatedUploadIcon().exists()).toBe(false);
});
});
describe('back button', () => {
it('renders a back button', () => {
expect(findBackButton().attributes('href')).toBe('/projects/new#import_project');
});
});
describe('next button', () => {
it('renders a next button', () => {
expect(findNextButton().attributes('type')).toBe('submit');
});
});
});

View File

@ -1,15 +1,23 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton, GlButtonGroup, GlDisclosureDropdownItem } from '@gitlab/ui'; import { GlButton, GlButtonGroup, GlDisclosureDropdownItem } from '@gitlab/ui';
import CodeDropdownIdeItem from '~/repository/components/code_dropdown/code_dropdown_ide_item.vue'; import CodeDropdownIdeItem from '~/repository/components/code_dropdown/code_dropdown_ide_item.vue';
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
jest.mock('~/behaviors/shortcuts/shortcuts_toggle', () => ({
shouldDisableShortcuts: () => false,
}));
describe('CodeDropdownIdeItem', () => { describe('CodeDropdownIdeItem', () => {
let wrapper; let wrapper;
const { bindInternalEventDocument } = useMockInternalEventsTracking();
const findButtonGroup = () => wrapper.findComponent(GlButtonGroup); const findButtonGroup = () => wrapper.findComponent(GlButtonGroup);
const findAllGlButtons = () => wrapper.findAllComponents(GlButton); const findAllGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAtIndex = (index) => findAllGlButtons().at(index); const findGlButtonAtIndex = (index) => findAllGlButtons().at(index);
const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
const findDropdownItemAtIndex = (index) => findDropdownItems().at(index); const findDropdownItemAtIndex = (index) => findDropdownItems().at(index);
const findKbd = () => wrapper.find('kbd');
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(CodeDropdownIdeItem, { wrapper = shallowMount(CodeDropdownIdeItem, {
@ -56,6 +64,11 @@ describe('CodeDropdownIdeItem', () => {
type: 'button', type: 'button',
text: 'button 1', text: 'button 1',
href: '/link 1', href: '/link 1',
shortcut: '.',
tracking: {
action: 'click_consolidated_edit',
label: 'web_ide',
},
}; };
beforeEach(() => { beforeEach(() => {
@ -71,9 +84,27 @@ describe('CodeDropdownIdeItem', () => {
expect(dropdownItem.props('item')).toStrictEqual(mockButtonItem); expect(dropdownItem.props('item')).toStrictEqual(mockButtonItem);
}); });
it('renders shortcut if passed in', () => {
expect(findKbd().exists()).toBe(true);
expect(findKbd().text()).toBe(mockButtonItem.shortcut);
});
it('closes the dropdown on click', () => { it('closes the dropdown on click', () => {
findDropdownItemAtIndex(0).vm.$emit('action'); findDropdownItemAtIndex(0).vm.$emit('action');
expect(wrapper.emitted('close-dropdown')).toStrictEqual([[]]); expect(wrapper.emitted('close-dropdown')).toStrictEqual([[]]);
}); });
it('calls to track events if passed in', () => {
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
findDropdownItemAtIndex(0).vm.$emit('action');
expect(trackEventSpy).toHaveBeenCalledTimes(1);
expect(trackEventSpy).toHaveBeenCalledWith(
'click_consolidated_edit',
{
label: 'web_ide',
},
undefined,
);
});
}); });
}); });

View File

@ -17,6 +17,8 @@ describe('Compact Code Dropdown coomponent', () => {
const httpUrl = 'http://foo.bar'; const httpUrl = 'http://foo.bar';
const httpsUrl = 'https://foo.bar'; const httpsUrl = 'https://foo.bar';
const xcodeUrl = 'xcode://foo.bar'; const xcodeUrl = 'xcode://foo.bar';
const webIdeUrl = 'webIdeUrl://foo.bar';
const gitpodUrl = 'gitpodUrl://foo.bar';
const currentPath = null; const currentPath = null;
const directoryDownloadLinks = [ const directoryDownloadLinks = [
{ text: 'zip', path: `${httpUrl}/archive.zip` }, { text: 'zip', path: `${httpUrl}/archive.zip` },
@ -28,6 +30,10 @@ describe('Compact Code Dropdown coomponent', () => {
sshUrl, sshUrl,
httpUrl, httpUrl,
xcodeUrl, xcodeUrl,
webIdeUrl,
gitpodUrl,
showWebIdeButton: true,
showGitpodButton: true,
currentPath, currentPath,
directoryDownloadLinks, directoryDownloadLinks,
}; };
@ -116,13 +122,19 @@ describe('Compact Code Dropdown coomponent', () => {
describe('ideGroup', () => { describe('ideGroup', () => {
it('should not render if ideGroup is empty', () => { it('should not render if ideGroup is empty', () => {
createComponent({ xcodeUrl: undefined, sshUrl: undefined, httpUrl: undefined }); createComponent({
xcodeUrl: undefined,
sshUrl: undefined,
httpUrl: undefined,
showWebIdeButton: false,
showGitpodButton: false,
});
expect(findCodeDropdownIdeItems().exists()).toBe(false); expect(findCodeDropdownIdeItems().exists()).toBe(false);
}); });
it('renders with correct props', () => { it('renders with correct props', () => {
createComponent(); createComponent();
expect(findCodeDropdownIdeItems()).toHaveLength(3); expect(findCodeDropdownIdeItems()).toHaveLength(5);
mockIdeItems.forEach((item, index) => { mockIdeItems.forEach((item, index) => {
const ideItem = findCodeDropdownIdeItemAtIndex(index); const ideItem = findCodeDropdownIdeItemAtIndex(index);

View File

@ -1,4 +1,27 @@
export const mockIdeItems = [ export const mockIdeItems = [
{
extraAttrs: {
target: '_blank',
},
href: 'webIdeUrl://foo.bar',
shortcut: '.',
text: 'Web IDE',
tracking: {
action: 'click_consolidated_edit',
label: 'web_ide',
},
},
{
extraAttrs: {
target: '_blank',
},
href: 'gitpodUrl://foo.bar',
text: 'GitPod',
tracking: {
action: 'click_consolidated_edit',
label: 'gitpod',
},
},
{ {
items: [ items: [
{ {

View File

@ -183,6 +183,10 @@ describe('HeaderArea', () => {
httpUrl: headerAppInjected.httpUrl, httpUrl: headerAppInjected.httpUrl,
kerberosUrl: headerAppInjected.kerberosUrl, kerberosUrl: headerAppInjected.kerberosUrl,
xcodeUrl: headerAppInjected.xcodeUrl, xcodeUrl: headerAppInjected.xcodeUrl,
webIdeUrl: headerAppInjected.webIdeUrl,
gitpodUrl: headerAppInjected.gitpodUrl,
showWebIdeButton: headerAppInjected.showWebIdeButton,
showGitpodButton: headerAppInjected.showGitpodButton,
currentPath: defaultMockRoute.params.path, currentPath: defaultMockRoute.params.path,
directoryDownloadLinks: headerAppInjected.downloadLinks, directoryDownloadLinks: headerAppInjected.downloadLinks,
}); });

View File

@ -96,7 +96,10 @@ describe('vue_shared/components/web_ide_link', () => {
let wrapper; let wrapper;
let trackingSpy; let trackingSpy;
function createComponent(props, { mountFn = shallowMountExtended, slots = {} } = {}) { function createComponent(
props,
{ mountFn = shallowMountExtended, slots = {}, featureFlagValue = false } = {},
) {
const fakeApollo = createMockApollo([ const fakeApollo = createMockApollo([
[getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)], [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)],
]); ]);
@ -122,6 +125,11 @@ describe('vue_shared/components/web_ide_link', () => {
GlDisclosureDropdownItem, GlDisclosureDropdownItem,
}, },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
provide: {
glFeatures: {
directoryCodeDropdownUpdates: featureFlagValue,
},
},
}); });
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
@ -205,9 +213,33 @@ describe('vue_shared/components/web_ide_link', () => {
props: { showEditButton: false }, props: { showEditButton: false },
expectedActions: [ACTION_WEB_IDE], expectedActions: [ACTION_WEB_IDE],
}, },
])('for a set of props', ({ props, expectedActions }) => { {
props: {
showWebIdeButton: true,
showGitpodButton: true,
gitpodEnabled: true,
isBlob: true,
},
expectedActions: [
{ ...ACTION_WEB_IDE, text: 'Open in Web IDE' },
ACTION_EDIT,
{ ...ACTION_GITPOD, text: 'Open in Gitpod' },
],
featureFlagValue: true,
},
{
props: {
showWebIdeButton: true,
showGitpodButton: true,
gitpodEnabled: true,
isBlob: false,
},
expectedActions: [ACTION_EDIT],
featureFlagValue: true,
},
])('for a set of props', ({ props, expectedActions, featureFlagValue }) => {
beforeEach(() => { beforeEach(() => {
createComponent(props); createComponent(props, { featureFlagValue });
}); });
it('renders the appropiate actions', () => { it('renders the appropiate actions', () => {

View File

@ -89,4 +89,57 @@ RSpec.describe Types::Ci::JobType, feature_category: :continuous_integration do
is_expected.to eq("/#{project.full_path}/-/jobs/#{build.id}/artifacts/browse") is_expected.to eq("/#{project.full_path}/-/jobs/#{build.id}/artifacts/browse")
end end
end end
describe '#triggered' do
subject { resolve_field(:triggered, build, current_user: user, object_type: described_class) }
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
context 'when not triggered' do
let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project) }
let_it_be(:build) { create(:ci_build, pipeline: pipeline, project: project, user: user) }
it 'returns false' do
expect(build.pipeline).to receive(:trigger_id).and_call_original
is_expected.to be(false)
end
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
it 'returns false' do
expect(build).to receive(:trigger_request).and_call_original
is_expected.to be(false)
end
end
end
context 'when triggered' do
let_it_be(:trigger) { create(:ci_trigger, project: project) }
let_it_be(:trigger_request) { create(:ci_trigger_request, trigger: trigger) }
let_it_be(:pipeline) { create(:ci_empty_pipeline, trigger: trigger, project: project) }
let_it_be(:build) do
create(:ci_build, trigger_request: trigger_request, pipeline: pipeline, project: project, user: user)
end
it 'returns true' do
expect(build.pipeline).to receive(:trigger_id).and_call_original
is_expected.to be(true)
end
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
it 'returns true' do
expect(build).to receive(:trigger_request).and_call_original
is_expected.to be(true)
end
end
end
end
end end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::WorkItems::Widgets::ErrorTracking::StackTraceContextType, feature_category: :team_planning do
it 'exposes the expected fields' do
expected_fields = %i[line_number line]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::WorkItems::Widgets::ErrorTracking::StackTraceType, feature_category: :team_planning do
it 'exposes the expected fields' do
expected_fields = %i[filename absolute_path line_number column_number function context]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ErrorTrackingStatus'], feature_category: :team_planning do
specify { expect(described_class.graphql_name).to eq('ErrorTrackingStatus') }
describe 'enum values' do
using RSpec::Parameterized::TableSyntax
where(:field_name, :field_value) do
'SUCCESS' | :success
'ERROR' | :error
'NOT_FOUND' | :not_found
'RETRY' | :retry
end
with_them do
it 'exposes correct available fields' do
expect(described_class.values[field_name].value).to eq(field_value)
end
end
end
end

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Types::WorkItems::Widgets::ErrorTrackingType, feature_category: :team_planning do RSpec.describe Types::WorkItems::Widgets::ErrorTrackingType, feature_category: :team_planning do
it 'exposes the expected fields' do it 'exposes the expected fields' do
expected_fields = %i[type identifier] expected_fields = %i[type identifier stack_trace status]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
end end

View File

@ -82,45 +82,30 @@ RSpec.describe GroupsHelper, feature_category: :groups_and_projects do
end end
end end
describe '#group_title' do describe '#push_group_breadcrumbs' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:nested_group) { create(:group, parent: group) } let_it_be(:nested_group) { create(:group, parent: group) }
let_it_be(:deep_nested_group) { create(:group, parent: nested_group) } let_it_be(:deep_nested_group) { create(:group, parent: nested_group) }
let_it_be(:very_deep_nested_group) { create(:group, parent: deep_nested_group) } let_it_be(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
subject { helper.group_title(very_deep_nested_group) } subject { helper.push_group_breadcrumbs(very_deep_nested_group) }
context 'traversal queries' do it 'enqueues the elements in the breadcrumb schema list in the correct order' do
shared_examples 'correct ancestor order' do expect(helper).to receive(:push_to_schema_breadcrumb).with(group.name, group_path(group), nil).ordered
it 'outputs the groups in the correct order' do expect(helper).to receive(:push_to_schema_breadcrumb).with(nested_group.name, group_path(nested_group), nil).ordered
expect(subject) expect(helper).to receive(:push_to_schema_breadcrumb).with(deep_nested_group.name, group_path(deep_nested_group), nil).ordered
.to match(%r{<li.*><a.*>#{deep_nested_group.name}.*</li>.*<a.*>#{very_deep_nested_group.name}</a>}m) expect(helper).to receive(:push_to_schema_breadcrumb).with(very_deep_nested_group.name, group_path(very_deep_nested_group), nil).ordered
end
end
before do
very_deep_nested_group.reload # make sure traversal_ids are reloaded
end
include_examples 'correct ancestor order'
end
it 'enqueues the elements in the breadcrumb schema list' do
expect(helper).to receive(:push_to_schema_breadcrumb).with(group.name, group_path(group), nil)
expect(helper).to receive(:push_to_schema_breadcrumb).with(nested_group.name, group_path(nested_group), nil)
expect(helper).to receive(:push_to_schema_breadcrumb).with(deep_nested_group.name, group_path(deep_nested_group), nil)
expect(helper).to receive(:push_to_schema_breadcrumb).with(very_deep_nested_group.name, group_path(very_deep_nested_group), nil)
subject subject
end end
it 'avoids N+1 queries' do it 'avoids N+1 queries' do
control = ActiveRecord::QueryRecorder.new do control = ActiveRecord::QueryRecorder.new do
helper.group_title(nested_group) helper.push_group_breadcrumbs(nested_group)
end end
expect do expect do
helper.group_title(very_deep_nested_group) helper.push_group_breadcrumbs(very_deep_nested_group)
end.not_to exceed_query_limit(control) end.not_to exceed_query_limit(control)
end end
end end

View File

@ -947,23 +947,28 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
end end
end end
describe '#project_title' do describe '#push_project_breadcrumbs' do
subject { helper.project_title(project) } subject { helper.push_project_breadcrumbs(project) }
it 'enqueues the elements in the breadcrumb schema list' do it 'enqueues the elements in the breadcrumb schema list in the correct order' do
expect(helper).to receive(:push_to_schema_breadcrumb).with(project.namespace.name, user_path(project.owner)) expect(helper).to receive(:push_to_schema_breadcrumb).with(project.namespace.name, user_path(project.owner)).ordered
expect(helper).to receive(:push_to_schema_breadcrumb).with(project.name, project_path(project), nil) expect(helper).to receive(:push_to_schema_breadcrumb).with(project.name, project_path(project), nil).ordered
subject subject
end end
context 'with malicious owner name' do context 'with malicious owner name' do
let(:malicious_owner_name) { 'a<a class="fixed-top" href=/api/v4' }
before do before do
allow_any_instance_of(User).to receive(:name).and_return('a<a class="fixed-top" href=/api/v4') allow_any_instance_of(User).to receive(:name).and_return(malicious_owner_name)
end end
it 'escapes the malicious owner name' do it 'escapes the malicious owner name' do
expect(subject).not_to include('<a class="fixed-top" href="/api/v4"></a>') expect(helper).not_to receive(:push_to_schema_breadcrumb).with(malicious_owner_name, user_path(project.owner))
expect(helper).to receive(:push_to_schema_breadcrumb).with('a', user_path(project.owner))
subject
end end
end end
end end

View File

@ -75,6 +75,31 @@ RSpec.describe BulkImports::Common::Pipelines::MembersPipeline, feature_category
) )
end end
it 'creates member only once when source_xid and entity_type are the same' do
member = extracted_data(
email: member_user1.email,
id: member_user1.id
)
extracted = BulkImports::Pipeline::ExtractedData.new(
data: member.data,
page_info: { 'has_next_page' => false }
)
allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
allow(extractor).to receive(:extract).and_return(extracted)
end
expect { pipeline.run }.to change(portable.members, :count).by(1)
# Run again with exact same configuration
allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
allow(extractor).to receive(:extract).and_return(extracted)
end
expect { pipeline.run }.not_to change(portable.members, :count)
end
context 'when importer_user_mapping is enabled' do context 'when importer_user_mapping is enabled' do
let!(:import_source_user) do let!(:import_source_user) do
create(:import_source_user, create(:import_source_user,
@ -202,6 +227,15 @@ RSpec.describe BulkImports::Common::Pipelines::MembersPipeline, feature_category
subject.load(context, member_data) subject.load(context, member_data)
end end
it 'removes source_xid and entity_type from data before creating member' do
data = member_data.merge('source_xid' => '123', 'entity_type' => 'group')
expect { pipeline.load(context, data) }.to change(portable.members, :count).by(1)
created_member = portable.members.last
expect(created_member.attributes).not_to include('source_xid', 'entity_type')
end
context 'when user_id is current user id' do context 'when user_id is current user id' do
it 'does not create new membership' do it 'does not create new membership' do
data = { user_id: user.id } data = { user_id: user.id }

View File

@ -6,6 +6,7 @@ RSpec.describe Ci::JobToken::Jwt, feature_category: :secrets_management do
let_it_be(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } let_it_be(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:job) { create(:ci_build, user: user) } let_it_be(:job) { create(:ci_build, user: user) }
let(:cell_id) { 1 }
before do before do
allow(Gitlab::CurrentSettings) allow(Gitlab::CurrentSettings)
@ -61,11 +62,38 @@ RSpec.describe Ci::JobToken::Jwt, feature_category: :secrets_management do
subject(:decoded_token) { described_class.decode(encoded_token) } subject(:decoded_token) { described_class.decode(encoded_token) }
before do
allow(Gitlab.config.cell).to receive(:id).and_return(cell_id)
end
context 'with a valid token' do context 'with a valid token' do
let(:decoded_payload) { decoded_token.instance_variable_get(:@jwt).payload }
let(:expected_payload) do
{
"c" => cell_id.to_s(36),
"o" => job.project.organization_id.to_s(36),
"u" => user.id.to_s(36),
"p" => job.project_id.to_s(36)
}
end
it 'successfully decodes the token with subject' do it 'successfully decodes the token with subject' do
expect(decoded_token).to be_present expect(decoded_token).to be_present
expect(decoded_token.job).to eq(job) expect(decoded_token.job).to eq(job)
end end
it 'successfully decodes the token with routable payload' do
expect(decoded_payload).to match(a_hash_including(expected_payload))
end
context 'when project belongs to a group' do
let_it_be(:job) { create(:ci_build, user: user, project: create(:project, :in_group)) }
it 'includes group id in routable payload' do
expect(decoded_payload)
.to match(a_hash_including(expected_payload.merge("g" => job.project.group.id.to_s(36))))
end
end
end end
context 'when signing key is not available' do context 'when signing key is not available' do
@ -184,17 +212,58 @@ RSpec.describe Ci::JobToken::Jwt, feature_category: :secrets_management do
let(:encoded_token) { described_class.encode(job) } let(:encoded_token) { described_class.encode(job) }
let(:decoded_token) { described_class.decode(encoded_token) } let(:decoded_token) { described_class.decode(encoded_token) }
before do
allow(Gitlab.config.cell).to receive(:id).and_return(cell_id)
end
it 'encodes the cell_id in the JWT payload' do it 'encodes the cell_id in the JWT payload' do
expect(decoded_token.cell_id).to eq(Gitlab.config.cell.id) expect(decoded_token.cell_id).to eq(cell_id)
end end
end end
describe '#organization' do describe '#organization_id' do
let(:encoded_token) { described_class.encode(job) } let(:encoded_token) { described_class.encode(job) }
let(:decoded_token) { described_class.decode(encoded_token) } let(:decoded_token) { described_class.decode(encoded_token) }
it 'encodes the organization in the JWT payload' do it 'encodes the organization_id in the JWT payload' do
expect(decoded_token.organization).to eq(job.project.organization) expect(decoded_token.organization_id).to eq(job.project.organization_id)
end
end
describe '#project_id' do
let(:encoded_token) { described_class.encode(job) }
let(:decoded_token) { described_class.decode(encoded_token) }
it 'encodes the project_id in the JWT payload' do
expect(decoded_token.project_id).to eq(job.project_id)
end
end
describe '#user_id' do
let(:encoded_token) { described_class.encode(job) }
let(:decoded_token) { described_class.decode(encoded_token) }
it 'encodes the user_id in the JWT payload' do
expect(decoded_token.user_id).to eq(job.user_id)
end
end
describe '#group_id' do
let(:encoded_token) { described_class.encode(job) }
let(:decoded_token) { described_class.decode(encoded_token) }
context 'when project belongs to a group' do
let_it_be(:job) { create(:ci_build, user: user, project: create(:project, :in_group)) }
it 'encodes the group_id in the JWT payload' do
expect(decoded_token.group_id).to eq(job.project.group.id)
end
end
context 'when project belongs to a personal namespace' do
it 'does not encode the group_id in the JWT payload' do
expect(decoded_token.group_id).to be_nil
end
end end
end end

View File

@ -93,4 +93,13 @@ RSpec.describe Gitlab::Import::UsernameMentionRewriter, feature_category: :impor
end end
end end
end end
context 'when the text contains username in the new line' do
let(:original_text) { "Hello,\n@username is mentioned here.\nThis is the next line." }
let(:expected_text) { "Hello,\n`@username` is mentioned here.\nThis is the next line." }
it 'wraps the username in backticks and it should be properly formatted in the new line' do
expect(instance.wrap_mentions_in_backticks(original_text)).to eq(expected_text)
end
end
end end

View File

@ -327,8 +327,8 @@ ci_pipelines: &pipeline_definition
- bridges - bridges
- processables - processables
- generic_commit_statuses - generic_commit_statuses
- trigger_requests
- trigger - trigger
- trigger_requests
- variables - variables
- auto_canceled_by - auto_canceled_by
- auto_canceled_pipelines - auto_canceled_pipelines
@ -420,6 +420,7 @@ builds:
- resource_group - resource_group
- metadata - metadata
- runner - runner
- trigger
- trigger_request - trigger_request
- erased_by - erased_by
- deployment - deployment
@ -495,6 +496,7 @@ bridges:
- deployment - deployment
- resource_group - resource_group
- metadata - metadata
- trigger
- trigger_request - trigger_request
- downstream_pipeline - downstream_pipeline
- upstream_pipeline - upstream_pipeline

View File

@ -7,6 +7,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
let_it_be_with_refind(:pipeline) { create(:ci_pipeline, project: project) } let_it_be_with_refind(:pipeline) { create(:ci_pipeline, project: project) }
describe 'associations' do describe 'associations' do
it { is_expected.to have_one(:trigger).through(:pipeline) }
it { is_expected.to belong_to(:trigger_request) } it { is_expected.to belong_to(:trigger_request) }
end end
@ -16,7 +17,6 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
it { is_expected.to delegate_method(:merge_request?).to(:pipeline) } it { is_expected.to delegate_method(:merge_request?).to(:pipeline) }
it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) } it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) }
it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) } it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) }
it { is_expected.to delegate_method(:trigger_short_token).to(:trigger_request) }
end end
describe '#clone' do describe '#clone' do
@ -89,7 +89,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
let(:ignore_accessors) do let(:ignore_accessors) do
%i[type namespace lock_version target_url base_tags trace_sections %i[type namespace lock_version target_url base_tags trace_sections
commit_id deployment erased_by_id project_id project_mirror commit_id deployment erased_by_id project_id project_mirror
runner_id taggings tags trigger_request_id runner_id taggings tags trigger_request_id trigger trigger_id
user_id auto_canceled_by_id retried failure_reason user_id auto_canceled_by_id retried failure_reason
sourced_pipelines sourced_pipeline artifacts_file_store artifacts_metadata_store sourced_pipelines sourced_pipeline artifacts_file_store artifacts_metadata_store
metadata runner_manager_build runner_manager runner_session trace_chunks metadata runner_manager_build runner_manager runner_session trace_chunks
@ -195,7 +195,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
Ci::Build.attribute_names.map(&:to_sym) + Ci::Build.attribute_names.map(&:to_sym) +
Ci::Build.attribute_aliases.keys.map(&:to_sym) + Ci::Build.attribute_aliases.keys.map(&:to_sym) +
Ci::Build.reflect_on_all_associations.map(&:name) + Ci::Build.reflect_on_all_associations.map(&:name) +
[:tag_list, :needs_attributes, :job_variables_attributes, :id_tokens, :interruptible] [:tag_list, :needs_attributes, :job_variables_attributes, :id_tokens, :interruptible, :trigger]
current_accessors.uniq! current_accessors.uniq!
@ -678,4 +678,26 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
end end
end end
end end
describe '#trigger_short_token' do
let_it_be(:pipeline) { create(:ci_pipeline, :triggered, project: project) }
let_it_be(:stage) { create(:ci_stage, project: project, pipeline: pipeline, name: 'test') }
let_it_be(:processable) { create(:ci_build, :triggered, stage_id: stage.id, pipeline: pipeline) }
it 'delegates to trigger' do
expect(processable.trigger).to receive(:short_token)
processable.trigger_short_token
end
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
it 'delegates to trigger_request' do
expect(processable.trigger_request).to receive(:trigger_short_token)
processable.trigger_short_token
end
end
end
end end

View File

@ -31,6 +31,50 @@ RSpec.describe Ci::Trigger, feature_category: :continuous_integration do
end end
end end
describe '#last_used' do
let_it_be(:project) { create :project }
let_it_be_with_refind(:trigger) { create(:ci_trigger, project: project) }
subject { trigger.last_used }
it { is_expected.to be_nil }
context 'when there is one pipeline' do
let_it_be(:pipeline1) { create(:ci_empty_pipeline, trigger: trigger, project: project, created_at: '2025-02-13') }
let_it_be(:build1) { create(:ci_build, pipeline: pipeline1, trigger_request: trigger_request1) }
let_it_be(:trigger_request1) { create(:ci_trigger_request, trigger: trigger, created_at: '2025-02-12') }
it { is_expected.to eq(pipeline1.reload.created_at) }
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
it { is_expected.to eq(trigger_request1.reload.created_at) }
end
context 'when there are two pipelines' do
let_it_be(:pipeline2) do
create(:ci_empty_pipeline, trigger: trigger, project: project, created_at: '2025-02-11')
end
let_it_be(:build2) { create(:ci_build, pipeline: pipeline2, trigger_request: trigger_request2) }
let_it_be(:trigger_request2) { create(:ci_trigger_request, trigger: trigger, created_at: '2025-02-10') }
it { is_expected.to eq(pipeline2.reload.created_at) }
context 'when ff ci_read_trigger_from_ci_pipeline is disabled' do
before do
stub_feature_flags(ci_read_trigger_from_ci_pipeline: false)
end
it { is_expected.to eq(trigger_request2.reload.created_at) }
end
end
end
end
describe '#short_token' do describe '#short_token' do
let(:trigger) { create(:ci_trigger) } let(:trigger) { create(:ci_trigger) }

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