Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-07-21 03:10:13 +00:00
parent a558e38674
commit 3c0d15f2f1
30 changed files with 910 additions and 79 deletions

View File

@ -83,9 +83,14 @@
"related": "ee/app/validators/ee/{}.rb",
"type": "source"
},
"app/views/*.rb": {
"alternate": "spec/app/views/{}_spec.rb",
"related": "ee/app/views/ee/{}.rb",
"app/views/*.erb": {
"alternate": "spec/views/{}.erb_spec.rb",
"related": "ee/app/views/ee/{}.erb",
"type": "source"
},
"app/views/*.haml": {
"alternate": "spec/views/{}.haml_spec.rb",
"related": "ee/app/views/ee/{}.haml",
"type": "source"
},
"app/workers/*.rb": {
@ -113,6 +118,16 @@
"alternate": "lib/api/{}.rb",
"type": "test"
},
"spec/views/*.erb_spec.rb": {
"alternate": "app/views/{}.erb",
"related": "ee/app/views/ee/{}.erb",
"type": "test"
},
"spec/views/*.haml_spec.rb": {
"alternate": "app/views/{}.haml",
"related": "ee/app/views/ee/{}.haml",
"type": "test"
},
"rubocop/cop/*.rb": {
"alternate": "spec/rubocop/cop/{}_spec.rb",
"type": "source"
@ -189,9 +204,14 @@
"related": "app/validators/{}.rb",
"type": "source"
},
"ee/app/views/ee/*.rb": {
"alternate": "spec/app/views/{}_spec.rb",
"related": "app/views/{}.rb",
"ee/app/views/ee/*.erb": {
"alternate": "ee/spec/views/ee/{}.erb_spec.rb",
"related": "app/views/{}.erb",
"type": "source"
},
"ee/app/views/ee/*.haml": {
"alternate": "ee/spec/views/ee/{}.haml_spec.rb",
"related": "app/views/{}.haml",
"type": "source"
},
"ee/app/workers/ee/*.rb": {
@ -247,6 +267,16 @@
"alternate": ["ee/app/assets/javascripts/{}.vue", "ee/app/assets/javascripts/{}.js"],
"type": "test"
},
"ee/spec/views/ee/*.erb_spec.rb": {
"alternate": "ee/app/views/ee/{}.erb",
"related": "spec/views/{}.erb_spec.rb",
"type": "test"
},
"ee/spec/views/ee/*.haml_spec.rb": {
"alternate": "ee/app/views/ee/{}.haml",
"related": "spec/views/{}.haml_spec.rb",
"type": "test"
},
"*.rb": { "dispatch": "bundle exec rubocop {file}" },
"*_spec.rb": { "dispatch": "bundle exec rspec {file}" }
}

View File

@ -28,6 +28,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import { readFileAsDataURL } from '~/lib/utils/file_utility';
import {
leftSidebarViews,
@ -40,7 +41,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
import { getPathParent, registerSchema, isTextFile } from '../utils';
import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';

View File

@ -123,19 +123,6 @@ export function getPathParent(path) {
return getPathParents(path, 1)[0];
}
/**
* Takes a file object and returns a data uri of its contents.
*
* @param {File} file
*/
export function readFileAsDataURL(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
reader.readAsDataURL(file);
});
}
export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}

View File

@ -0,0 +1,12 @@
/**
* Takes a file object and returns a data uri of its contents.
*
* @param {File} file
*/
export function readFileAsDataURL(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
reader.readAsDataURL(file);
});
}

View File

@ -1,10 +1,107 @@
<script>
export default {};
import { nextTick } from 'vue';
import { GlForm, GlButton } from '@gitlab/ui';
import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { readFileAsDataURL } from '~/lib/utils/file_utility';
import { i18n } from '../constants';
import UserAvatar from './user_avatar.vue';
export default {
components: {
UserAvatar,
GlForm,
GlButton,
},
props: {
profilePath: {
type: String,
required: true,
},
userPath: {
type: String,
required: true,
},
},
data() {
return {
uploadingProfile: false,
avatarBlob: null,
};
},
methods: {
async onSubmit() {
// TODO: Do validation before organizing data.
this.uploadingProfile = true;
const formData = new FormData();
if (this.avatarBlob) {
formData.append('user[avatar]', this.avatarBlob, 'avatar.png');
}
try {
const { data } = await axios.put(this.profilePath, formData);
if (this.avatarBlob) {
this.syncHeaderAvatars();
}
createAlert({
message: data.message,
variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO,
});
nextTick(() => {
window.scrollTo(0, 0);
this.uploadingProfile = false;
});
} catch (e) {
createAlert({
message: e.message,
variant: VARIANT_DANGER,
});
this.updateProfileSettings = false;
}
},
async syncHeaderAvatars() {
const dataURL = await readFileAsDataURL(this.avatarBlob);
// TODO: implement sync for super sidebar
['.header-user-avatar', '.js-sidebar-user-avatar'].forEach((selector) => {
const node = document.querySelector(selector);
if (!node) return;
node.setAttribute('src', dataURL);
node.setAttribute('srcset', dataURL);
});
},
onBlobChange(blob) {
this.avatarBlob = blob;
},
},
i18n,
};
</script>
<template>
<!-- This is left empty intensionally -->
<!-- It will be implemented in the upcoming MRs -->
<!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
<div></div>
<gl-form @submit.prevent="onSubmit">
<user-avatar @blob-change="onBlobChange" />
<!-- TODO: to implement profile editing form fields -->
<!-- It will be implemented in the upcoming MRs -->
<!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
<div class="js-hide-when-nothing-matches-search gl-border-t gl-py-6">
<gl-button
variant="confirm"
type="submit"
class="gl-mr-3 js-password-prompt-btn"
:disabled="uploadingProfile"
>
{{ $options.i18n.updateProfileSettings }}
</gl-button>
<gl-button :href="userPath" data-testid="cancel-edit-button">
{{ $options.i18n.cancel }}
</gl-button>
</div>
</gl-form>
</template>

View File

@ -0,0 +1,177 @@
<script>
import $ from 'jquery';
import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { loadCSSFile } from '~/lib/utils/css_utils';
import SafeHtmlDirective from '~/vue_shared/directives/safe_html';
import { avatarI18n } from '../constants';
export default {
name: 'EditProfileUserAvatar',
components: {
GlAvatar,
GlAvatarLink,
GlButton,
GlLink,
GlSprintf,
},
directives: {
SafeHtml: SafeHtmlDirective,
},
inject: [
'avatarUrl',
'brandProfileImageGuidelines',
'cropperCssPath',
'hasAvatar',
'gravatarEnabled',
'gravatarLink',
'profileAvatarPath',
],
computed: {
avatarHelpText() {
const { changeOrRemoveAvatar, changeAvatar, uploadOrChangeAvatar, uploadAvatar } = avatarI18n;
if (this.hasAvatar) {
return this.gravatarEnabled ? changeOrRemoveAvatar : changeAvatar;
}
return this.gravatarEnabled ? uploadOrChangeAvatar : uploadAvatar;
},
},
mounted() {
this.initializeCropper();
loadCSSFile(this.cropperCssPath);
},
methods: {
initializeCropper() {
const cropOpts = {
filename: '.js-avatar-filename',
previewImage: '.avatar-image .gl-avatar',
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
modalCropImg: '.modal-profile-crop-image',
onBlobChange: this.onBlobChange,
};
// This has to be used with jQuery, considering migrate that from jQuery to Vue in the future.
$('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
},
onBlobChange(blob) {
this.$emit('blob-change', blob);
},
},
i18n: avatarI18n,
};
</script>
<template>
<div class="js-search-settings-section gl-pb-6">
<div class="profile-settings-sidebar">
<h4 class="gl-my-0">
{{ $options.i18n.publicAvatar }}
</h4>
<p class="gl-text-secondary">
<gl-sprintf :message="avatarHelpText">
<template #gravatar_link>
<gl-link :href="gravatarLink.url" target="__blank">
{{ gravatarLink.hostname }}
</gl-link>
</template>
</gl-sprintf>
</p>
<div
v-if="brandProfileImageGuidelines"
v-safe-html="brandProfileImageGuidelines"
class="md gl-mb-5"
data-testid="brand-profile-image-guidelines"
></div>
</div>
<div class="gl-display-flex">
<div class="avatar-image">
<gl-avatar-link :href="avatarUrl" target="blank">
<gl-avatar class="gl-mr-5" :src="avatarUrl" :size="96" shape="circle" />
</gl-avatar-link>
</div>
<div class="gl-flex-grow-1">
<h5 class="gl-mt-0">
{{ $options.i18n.uploadNewAvatar }}
</h5>
<div class="gl-display-flex gl-align-items-center gl-my-3">
<gl-button
class="js-choose-user-avatar-button"
data-testid="select-avatar-trigger-button"
>
{{ $options.i18n.chooseFile }}
</gl-button>
<span class="gl-ml-3 js-avatar-filename">{{ $options.i18n.noFileChosen }}</span>
<input
id="user_avatar"
class="js-user-avatar-input hidden"
accept="image/*"
type="file"
name="user[avatar]"
/>
</div>
<p class="gl-mb-0 gl-text-gray-500">{{ $options.i18n.maximumFileSize }}</p>
<gl-button
v-if="hasAvatar"
class="gl-mt-3"
category="secondary"
variant="danger"
data-method="delete"
rel="nofollow"
data-testid="remove-avatar-button"
:data-confirm="$options.i18n.removeAvatarConfirmation"
:href="profileAvatarPath"
>
{{ $options.i18n.removeAvatar }}
</gl-button>
</div>
</div>
<!-- For bs.modal to take over -->
<div class="modal modal-profile-crop" :data-cropper-css-path="cropperCssPath">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">
{{ $options.i18n.cropAvatarTitle }}
</h4>
<gl-button
category="tertiary"
icon="close"
class="close"
data-dismiss="modal"
:aria-label="__('Close')"
/>
</div>
<div class="modal-body">
<div class="profile-crop-image-container">
<img :alt="$options.i18n.cropAvatarImageAltText" class="modal-profile-crop-image" />
</div>
<div class="gl-text-center gl-mt-4">
<div class="btn-group">
<gl-button
:aria-label="__('Zoom out')"
icon="search-minus"
data-method="zoom"
data-option="-0.1"
/>
<gl-button
:aria-label="__('Zoom in')"
icon="search-plus"
data-method="zoom"
data-option="0.1"
/>
</div>
</div>
</div>
<div class="modal-footer">
<gl-button class="js-upload-user-avatar" variant="confirm">{{
$options.i18n.cropAvatarSetAsNewAvatar
}}</gl-button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,27 @@
import { s__, __ } from '~/locale';
export const avatarI18n = {
publicAvatar: s__('Profiles|Public avatar'),
changeOrRemoveAvatar: s__(
'Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}',
),
changeAvatar: s__('Profiles|You can change your avatar here'),
uploadOrChangeAvatar: s__(
'Profiles|You can upload your avatar here or change it at %{gravatar_link}',
),
uploadAvatar: s__('Profiles|You can upload your avatar here'),
uploadNewAvatar: s__('Profiles|Upload new avatar'),
chooseFile: s__('Profiles|Choose file...'),
noFileChosen: s__('Profiles|No file chosen.'),
maximumFileSize: s__('Profiles|The maximum file size allowed is 200KB.'),
removeAvatar: s__('Profiles|Remove avatar'),
removeAvatarConfirmation: s__('Profiles|Avatar will be removed. Are you sure?'),
cropAvatarTitle: s__('Profiles|Position and size your new avatar'),
cropAvatarImageAltText: s__('Profiles|Avatar cropper'),
cropAvatarSetAsNewAvatar: s__('Profiles|Set new profile picture'),
};
export const i18n = {
updateProfileSettings: s__('Profiles|Update profile settings'),
cancel: __('Cancel'),
};

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ProfileEditApp from './components/profile_edit_app.vue';
export const initProfileEdit = () => {
@ -6,11 +7,24 @@ export const initProfileEdit = () => {
if (!mountEl) return false;
const { profilePath, userPath, ...provides } = mountEl.dataset;
return new Vue({
el: mountEl,
name: 'ProfileEditRoot',
provide: {
...provides,
hasAvatar: parseBoolean(provides.hasAvatar),
gravatarEnabled: parseBoolean(provides.gravatarEnabled),
gravatarLink: JSON.parse(provides.gravatarLink),
},
render(createElement) {
return createElement(ProfileEditApp);
return createElement(ProfileEditApp, {
props: {
profilePath,
userPath,
},
});
},
});
};

View File

@ -25,6 +25,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
exportHeight = 200,
cropBoxWidth = 200,
cropBoxHeight = 200,
onBlobChange = () => {},
} = {},
) {
this.onUploadImageBtnClick = this.onUploadImageBtnClick.bind(this);
@ -54,6 +55,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
this.uploadImageBtn = isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn;
this.modalCropImg = isString(modalCropImg) ? $(modalCropImg) : modalCropImg;
this.cropActionsBtn = this.modalCrop.find('[data-method]');
this.onBlobChange = onBlobChange;
this.bindEvents();
}
@ -75,6 +77,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
const btn = this;
return _this.onActionBtnClick(btn);
});
this.onBlobChange(null);
return (this.croppedImageBlob = null);
}
@ -187,7 +190,10 @@ import { loadCSSFile } from '../lib/utils/css_utils';
height: 200,
})
.toDataURL('image/png');
return (this.croppedImageBlob = this.dataURLtoBlob(this.dataURL));
this.croppedImageBlob = this.dataURLtoBlob(this.dataURL);
this.onBlobChange(this.croppedImageBlob);
return this.croppedImageBlob;
}
getBlob() {

View File

@ -73,6 +73,20 @@ module ProfilesHelper
def prevent_delete_account?
false
end
def user_profile_data(user)
{
profile_path: profile_path,
profile_avatar_path: profile_avatar_path,
avatar_url: avatar_icon_for_user(user, current_user: current_user),
has_avatar: user.avatar?.to_s,
gravatar_enabled: gravatar_enabled?.to_s,
gravatar_link: { hostname: Gitlab.config.gravatar.host, url: "https://#{Gitlab.config.gravatar.host}" }.to_json,
brand_profile_image_guidelines: current_appearance&.profile_image_guidelines? ? brand_profile_image_guidelines : '',
cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'),
user_path: user_path(current_user)
}
end
end
ProfilesHelper.prepend_mod

View File

@ -5,7 +5,7 @@
- @force_desktop_expanded_sidebar = true
- if Feature.enabled?(:edit_user_profile_vue, current_user)
.js-user-profile
.js-user-profile{ data: user_profile_data(@user) }
- else
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.settings-section.js-search-settings-section

View File

@ -0,0 +1,6 @@
---
migration_job_name: BackfillDismissalReasonInVulnerabilityReads
description: Backfill `dismissal_reason` for rows with `state` of `dismissed` in `vulnerability_reads` table
feature_category: vulnerability_management
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412667
milestone: 16.1

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class QueueBackfillDismissalReasonInVulnerabilityReads < Gitlab::Database::Migration[2.1]
MIGRATION = "BackfillDismissalReasonInVulnerabilityReads"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 5000
SUB_BATCH_SIZE = 500
restrict_gitlab_migration gitlab_schema: :gitlab_main
def up
queue_batched_background_migration(
MIGRATION,
:vulnerability_reads,
:id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(MIGRATION, :vulnerability_reads, :id, [])
end
end

View File

@ -0,0 +1 @@
cb34e35ebabd6e7f1c5ac0796ab92d1323c88d222ae5a5b38686383b365dca46

View File

@ -29,6 +29,7 @@ If successful, returns [`200`](rest/index.md#status-codes) and the following res
| `[].group_id` | integer | The ID of the group that the member role belongs to. |
| `[].base_access_level` | integer | Base access level for member role. |
| `[].read_code` | boolean | Permission to read code. |
| `[].read_dependency` | boolean | Permission to read project dependencies. |
Example request:
@ -70,6 +71,7 @@ To add a member role to a group, the group must be at root-level (have no parent
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) owned by the authenticated user. |
| `base_access_level` | integer | yes | Base access level for configured role. |
| `read_code` | boolean | no | Permission to read code. |
| `read_dependency` | boolean | no | Permission to read project dependencies. |
If successful, returns [`201`](rest/index.md#status-codes) and the following attributes:
@ -79,6 +81,7 @@ If successful, returns [`201`](rest/index.md#status-codes) and the following att
| `group_id` | integer | The ID of the group that the member role belongs to. |
| `base_access_level` | integer | Base access level for member role. |
| `read_code` | boolean | Permission to read code. |
| `read_dependency` | boolean | Permission to read project dependencies. |
Example request:
@ -93,7 +96,8 @@ Example response:
"id": 3,
"group_id": 84,
"base_access_level": 10,
"read_code": true
"read_code": true,
"read_dependency": false
}
```

View File

@ -234,7 +234,7 @@ Non-Users are external to the Organization and can only access the public resour
Organizations will have an Owner role. Compared to Users, they can perform the following actions:
| Action | Owner | User |
| Action | Owner | User |
| ------ | ------ | ----- |
| View Organization settings | :white_check_mark: | :x: |
| Edit Organization settings | :white_check_mark: | :x: |
@ -266,10 +266,11 @@ The following iteration plan outlines how we intend to arrive at the Organizatio
### Iteration 1: Organization Prototype (FY24Q2)
In iteration 1, we introduce the concept of an Organization as a way to Group top-level Groups together. Support for Organizations does not require any [Cells](../cells/index.md) work, but having them will make all subsequent iterations of Cells simpler. The goal of iteration 1 will be to generate a prototype that can be used by GitLab teams to test moving functionality to the Organization. It contains everything that is necessary to move an Organization to a Cell:
In iteration 1, we introduce the concept of an Organization as a way to group top-level Groups together. Support for Organizations does not require any [Cells](../cells/index.md) work, but having them will make all subsequent iterations of Cells simpler. The goal of iteration 1 will be to generate a prototype that can be used by GitLab teams to test moving functionality to the Organization. It contains everything that is necessary to move an Organization to a Cell:
- The Organization can be named, has an ID and an avatar.
- Only a Non-Enterprise User can be part of an Organization.
- Both Enterprise and Non-Enterprise Users can be part of an Organization.
- Enterprise Users are still managed by top-level Groups.
- A User can be part of multiple Organizations.
- A single Organization Owner can be assigned.
- Groups can be created in an Organization. Groups are listed in the Groups overview.
@ -288,7 +289,8 @@ In iteration 2, an Organization MVC Experiment will be released. We will test th
In iteration 3, the Organization MVC Beta will be released.
- Multiple Organization Owners can be assigned.
- Organization Owners can change the visibility of an organization between `public` and `private`. A Non-User of a specific Organization will not see private Organizations in the explore section.
- Organization Owners can create, edit and delete Groups from the Groups overview.
- Organization Owners can create, edit and delete Projects from the Projects overview.
### Iteration 4: Organization MVC GA (FY25Q1)
@ -320,7 +322,7 @@ We propose the following steps to successfully roll out Organizations:
- Phase 1: Rollout
- Organizations will be rolled out using the concept of a `default Organization`. All existing top-level groups on GitLab.com are already part of this `default Organization`. The Organization UI is feature flagged and can be enabled for a specific set of users initially, and the global user pool at the end of this phase. This way, users will already become familiar with the concept of an Organization and the Organization UI. No features would be impacted by enabling the `default Organization`. See issue [#418225](https://gitlab.com/gitlab-org/gitlab/-/issues/418225) for more details.
- Phase 2: Migrations
- Phase 2: Migrations
- GitLab, the organization, will be the first one to bud off into a separate Organization. We move all top-level groups that belong to GitLab into the new GitLab Organization, including the `gitLab-org` and `gitLab-com` top-level Groups. See issue [#418228](https://gitlab.com/gitlab-org/gitlab/-/issues/418228) for more details.
- Existing customers can create their own Organization. Creation of an Organization remains optional.
- Phase 3: Onboarding changes

View File

@ -314,4 +314,4 @@ To get job information from the GraphQL API:
}
```
If the status is not `running` or `pending`, open a new issue.
If the status is not `running` or `pending`, [open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new) and [contact support](https://about.gitlab.com/support/#contact-support) so they can apply the correct labels to the issue.

View File

@ -48,6 +48,9 @@ At the project level, the Vulnerability Report also contains:
- The number of failures that occurred in the most recent pipeline. Select the failure
notification to view the **Failed jobs** tab of the pipeline's page.
When vulnerabilities originate from a multi-project pipeline setup,
this page displays the vulnerabilities that originate from the selected project.
### View the project-level vulnerability report
To view the project-level vulnerability report:

View File

@ -64,6 +64,53 @@ groups are in the same GitLab instance. Transferring groups is a faster and more
See [epic 6629](https://gitlab.com/groups/gitlab-org/-/epics/6629) for a list of known issues for migrating by direct
transfer.
### Estimating migration duration
Estimating the duration of migration by direct transfer is difficult. The following factors affect migration duration:
- Hardware and database resources available on the source and destination GitLab instances. More resources on the source and destination instances can result in
shorter migration duration because:
- The source instance receives API requests, and extracts and serializes the entities to export.
- The destination instance runs the jobs and creates the entities in its database.
- Complexity and size of data to be exported. For example, imagine you want to migrate two different projects with 1000 merge requests each. The two projects can take
very different amounts of time to migrate if one of the projects has a lot more attachments, comments, and other items on the merge requests. Therefore, the number
of merge requests on a project is a poor predictor of how long a project will take to migrate.
Theres no exact formula to reliably estimate a migration. However, the average durations of each pipeline worker importing a project relation can help you to get an idea of how long importing your projects might take:
| Project resource type | Average time (in seconds) to import a record |
|:----------------------------|:---------------------------------------------|
| Empty Project | 2.4 |
| Repository | 20 |
| Project Attributes | 1.5 |
| Members | 0.2 |
| Labels | 0.1 |
| Milestones | 0.07 |
| Badges | 0.1 |
| Issues | 0.1 |
| Snippets | 0.05 |
| Snippet Repositories | 0.5 |
| Boards | 0.1 |
| Merge Requests | 1 |
| External Pull Requests | 0.5 |
| Protected Branches | 0.1 |
| Project Feature | 0.3 |
| Container Expiration Policy | 0.3 |
| Service Desk Setting | 0.3 |
| Releases | 0.1 |
| CI Pipelines | 0.2 |
| Commit Notes | 0.05 |
| Wiki | 10 |
| Uploads | 0.5 |
| LFS Objects | 0.5 |
| Design | 0.1 |
| Auto DevOps | 0.1 |
| Pipeline Schedules | 0.5 |
| References | 5 |
| Push Rule | 0.1 |
If you are migrating large projects and encounter problems with timeouts or duration of the migration, see [Reducing migration duration](#reducing-migration-duration).
### Limits
Hardcoded limits apply on migration by direct transfer.
@ -428,6 +475,24 @@ You can receive other `404` errors when importing a group, for example:
This error indicates a problem transferring from the _source_ instance. To solve this, check that you have met the [prerequisites](#prerequisites) on the source
instance.
#### Reducing migration duration
A single direct transfer migration runs 5 entities (groups or projects) per import at a time, independent of the number of workers available on the destination instance.
That said, having more workers on the destination instance speeds up migration by decreasing the time it takes to import each entity.
Increasing the number of workers on the destination instance helps reduce the migration duration until the source instance hardware resources are saturated. Exporting and importing relations in batches (proposed in [epic 9036](https://gitlab.com/groups/gitlab-org/-/epics/9036)) will make having enough available workers on
the destination instance even more useful.
The number of workers on the source instance should be enough to export the 5 concurrent entities in parallel (for each running import). Otherwise, there can be
delays and potential timeouts as the destination is waiting for exported data to become available.
Distributing projects in different groups helps to avoid timeouts. If several large projects are in the same group, you can:
1. Move large projects to different groups or subgroups.
1. Start separate migrations each group and subgroup.
The GitLab UI can only migrate top-level groups. Using the API, you can also migrate subgroups.
## Migrate groups by uploading an export file (deprecated)
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2888) in GitLab 13.0 as an experimental feature. May change in future releases.

View File

@ -103,16 +103,16 @@ to apply to every repository's [default branch](#default-branch)
at the [instance level](#instance-level-default-branch-protection) and
[group level](#group-level-default-branch-protection) with one of the following options:
- **Not protected** - Both developers and maintainers can push new commits
and force push.
- **Fully protected** - Default value. Developers cannot push new commits, but maintainers can.
No one can force push.
- **Fully protected after initial push** - Developers can push the initial commit
to a repository, but none afterward. Maintainers can always push. No one can force push.
- **Protected against pushes** - Developers cannot push new commits, but are
allowed to accept merge requests to the branch. Maintainers can push to the branch.
- **Partially protected** - Both developers and maintainers can push new commits,
but cannot force push.
- **Fully protected** - Developers cannot push new commits, but maintainers can.
No one can force push.
- **Fully protected after initial push** - Developers can push the initial commit
to a repository, but none afterward. Maintainers can always push. No one can force push.
- **Not protected** - Both developers and maintainers can push new commits
and force push.
### Instance-level default branch protection **(FREE SELF)**

View File

@ -23,6 +23,7 @@ pre-push:
markdownlint:
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: yarn markdownlint {files}
yamllint:
@ -58,6 +59,7 @@ pre-push:
vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/testing.html#install-linters
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: 'if [ $VALE_WARNINGS ]; then minWarnings=warning; else minWarnings=error; fi; if command -v vale > /dev/null 2>&1; then if ! vale --config .vale.ini --minAlertLevel $minWarnings {files}; then echo "ERROR: Fix any linting errors and make sure you are using the latest version of Vale."; exit 1; fi; else echo "ERROR: Vale not found. For more information, see https://docs.errata.ai/vale/install."; exit 1; fi'
gettext:
@ -73,6 +75,7 @@ pre-push:
docs-trailing_spaces: # Not enforced in CI/CD pipelines, but reduces the amount of required cleanup: https://gitlab.com/gitlab-org/technical-writing/-/blob/main/.gitlab/issue_templates/tw-monthly-tasks.md#remote-tasks
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: yarn markdownlint:no-trailing-spaces {files}
docs-deprecations:

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# This batched background migration is EE-only,
# see ee/lib/ee/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb for the actual
# migration code.
#
# This batched background migration will backfill `dismissal_reason` field in `vulnerability_reads` table for
# records with `state: 2` and `dismissal_reason: null`.
class BackfillDismissalReasonInVulnerabilityReads < BatchedMigrationJob
feature_category :vulnerability_management
def perform; end
end
end
end
Gitlab::BackgroundMigration::BackfillDismissalReasonInVulnerabilityReads.prepend_mod

View File

@ -41872,6 +41872,9 @@ msgstr ""
msgid "SecurityOrchestration|Vulnerabilities are %{vulnerabilityStates}."
msgstr ""
msgid "SecurityOrchestration|Vulnerability age requires previously existing vulnerability states (detected, confirmed, resolved, or dismissed)"
msgstr ""
msgid "SecurityOrchestration|When %{scanners} %{vulnerabilitiesAllowed} %{vulnerability} in an open merge request %{targeting}%{branches}%{criteriaApply}"
msgstr ""
@ -53862,12 +53865,18 @@ msgstr ""
msgid "ZentaoIntegration|ZenTao issues"
msgstr ""
msgid "Zoom in"
msgstr ""
msgid "Zoom meeting added"
msgstr ""
msgid "Zoom meeting removed"
msgstr ""
msgid "Zoom out"
msgstr ""
msgid "[No reason]"
msgstr ""

View File

@ -6,34 +6,63 @@ RSpec.describe 'User uploads avatar to profile', feature_category: :user_profile
let!(:user) { create(:user) }
let(:avatar_file_path) { Rails.root.join('spec', 'fixtures', 'dk.png') }
before do
stub_feature_flags(edit_user_profile_vue: false)
shared_examples 'upload avatar' do
it 'shows the new avatar immediately in the header and setting sidebar', :js do
expect(page.find('.avatar-image .gl-avatar')['src']).not_to include(
"/uploads/-/system/user/avatar/#{user.id}/avatar.png"
)
find('.js-user-avatar-input', visible: false).set(avatar_file_path)
click_button 'Set new profile picture'
click_button 'Update profile settings'
wait_for_all_requests
data_uri = find('.avatar-image .gl-avatar')['src']
expect(page.find('.header-user-avatar')['src']).to eq data_uri
expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri
visit profile_path
expect(page.find('.avatar-image .gl-avatar')['src']).to include(
"/uploads/-/system/user/avatar/#{user.id}/avatar.png"
)
end
end
context 'with "edit_user_profile_vue" turned on' do
before do
sign_in_and_visit_profile
end
it_behaves_like 'upload avatar'
end
context 'with "edit_user_profile_vue" turned off' do
before do
stub_feature_flags(edit_user_profile_vue: false)
sign_in_and_visit_profile
end
it 'they see their new avatar on their profile' do
attach_file('user_avatar', avatar_file_path, visible: false)
click_button 'Update profile settings'
visit user_path(user)
expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=96"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
end
it_behaves_like 'upload avatar'
end
private
def sign_in_and_visit_profile
sign_in user
visit profile_path
end
it 'they see their new avatar on their profile' do
attach_file('user_avatar', avatar_file_path, visible: false)
click_button 'Update profile settings'
visit user_path(user)
expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=96"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
end
it 'their new avatar is immediately visible in the header and setting sidebar', :js do
find('.js-user-avatar-input', visible: false).set(avatar_file_path)
click_button 'Set new profile picture'
click_button 'Update profile settings'
wait_for_all_requests
data_uri = find('.avatar-image .gl-avatar')['src']
expect(page.find('.header-user-avatar')['src']).to eq data_uri
expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri
end
end

View File

@ -8,7 +8,6 @@ import {
trimTrailingWhitespace,
getPathParents,
getPathParent,
readFileAsDataURL,
addNumericSuffix,
} from '~/ide/utils';
@ -267,16 +266,6 @@ describe('WebIDE utils', () => {
});
});
describe('readFileAsDataURL', () => {
it('reads a file and returns its output as a data url', () => {
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
return readFileAsDataURL(file).then((contents) => {
expect(contents).toBe('data:image/png;base64,Zm9v');
});
});
});
/*
* hello-2425 -> hello-2425
* hello.md -> hello-1.md

View File

@ -0,0 +1,13 @@
import { readFileAsDataURL } from '~/lib/utils/file_utility';
describe('File utilities', () => {
describe('readFileAsDataURL', () => {
it('reads a file and returns its output as a data url', () => {
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
return readFileAsDataURL(file).then((contents) => {
expect(contents).toBe('data:image/png;base64,Zm9v');
});
});
});
});

View File

@ -0,0 +1,111 @@
import { GlButton, GlForm } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { readFileAsDataURL } from '~/lib/utils/file_utility';
import axios from '~/lib/utils/axios_utils';
import ProfileEditApp from '~/profile/edit/components/profile_edit_app.vue';
import UserAvatar from '~/profile/edit/components/user_avatar.vue';
import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
jest.mock('~/alert');
jest.mock('~/lib/utils/file_utility', () => ({
readFileAsDataURL: jest.fn().mockResolvedValue(),
}));
describe('Profile Edit App', () => {
let wrapper;
let mockAxios;
const mockAvatarBlob = new Blob([''], { type: 'image/png' });
const mockAvatarFile = new File([mockAvatarBlob], 'avatar.png', { type: mockAvatarBlob.type });
const stubbedProfilePath = '/profile/edit';
const stubbedUserPath = '/user/test';
const successMessage = 'Profile was successfully updated.';
const createComponent = () => {
wrapper = shallowMountExtended(ProfileEditApp, {
propsData: {
profilePath: stubbedProfilePath,
userPath: stubbedUserPath,
},
});
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
createComponent();
});
const findForm = () => wrapper.findComponent(GlForm);
const findButtons = () => wrapper.findAllComponents(GlButton);
const findAvatar = () => wrapper.findComponent(UserAvatar);
const submitForm = () => findForm().vm.$emit('submit', new Event('submit'));
const setAvatar = () => findAvatar().vm.$emit('blob-change', mockAvatarFile);
it('renders the form for users to interact with', () => {
const form = findForm();
const buttons = findButtons();
expect(form.exists()).toBe(true);
expect(buttons).toHaveLength(2);
expect(wrapper.findByTestId('cancel-edit-button').attributes('href')).toBe(stubbedUserPath);
});
describe('when form submit request is successful', () => {
it('shows success alert', async () => {
mockAxios.onPut(stubbedProfilePath).reply(200, {
message: successMessage,
});
submitForm();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({ message: successMessage, variant: VARIANT_INFO });
});
it('syncs header avatars', async () => {
mockAxios.onPut(stubbedProfilePath).reply(200, {
message: successMessage,
});
setAvatar();
submitForm();
await waitForPromises();
expect(readFileAsDataURL).toHaveBeenCalledWith(mockAvatarFile);
});
});
describe('when form submit request is not successful', () => {
it('shows error alert', async () => {
mockAxios.onPut(stubbedProfilePath).reply(500);
submitForm();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({ variant: VARIANT_DANGER }),
);
});
});
it('submits API request with avatar file', async () => {
mockAxios.onPut(stubbedProfilePath).reply(200);
setAvatar();
submitForm();
await waitForPromises();
const axiosRequestData = mockAxios.history.put[0].data;
expect(axiosRequestData.get('user[avatar]')).toEqual(mockAvatarFile);
});
});

View File

@ -0,0 +1,139 @@
import { nextTick } from 'vue';
import jQuery from 'jquery';
import { GlAvatar, GlAvatarLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { avatarI18n } from '~/profile/edit/constants';
import { loadCSSFile } from '~/lib/utils/css_utils';
import UserAvatar from '~/profile/edit/components/user_avatar.vue';
const glCropDataMock = jest.fn().mockImplementation(() => ({
getBlob: jest.fn(),
}));
const jQueryMock = {
glCrop: jest.fn().mockReturnValue({
data: glCropDataMock,
}),
};
jest.mock(`~/lib/utils/css_utils`);
jest.mock('jquery');
describe('Edit User Avatar', () => {
let wrapper;
beforeEach(() => {
jQuery.mockImplementation(() => jQueryMock);
});
const defaultProvides = {
avatarUrl: '/-/profile/avatarUrl',
brandProfileImageGuidelines: '',
cropperCssPath: '',
hasAvatar: true,
gravatarEnabled: true,
gravatarLink: {
hostname: 'gravatar.com',
url: 'gravatar.com',
},
profileAvatarPath: '/profile/avatar',
};
const createComponent = (provides = {}) => {
wrapper = shallowMountExtended(UserAvatar, {
provide: {
...defaultProvides,
...provides,
},
});
};
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findHelpText = () => wrapper.findComponent(GlSprintf).attributes('message');
const findRemoveAvatarButton = () => wrapper.findByTestId('remove-avatar-button');
describe('renders correctly', () => {
it('under default condition', async () => {
createComponent();
await nextTick();
expect(jQueryMock.glCrop).toHaveBeenCalledWith({
filename: '.js-avatar-filename',
previewImage: '.avatar-image .gl-avatar',
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
modalCropImg: '.modal-profile-crop-image',
onBlobChange: expect.any(Function),
});
expect(glCropDataMock).toHaveBeenCalledWith('glcrop');
expect(loadCSSFile).toHaveBeenCalledWith(defaultProvides.cropperCssPath);
const avatar = findAvatar();
expect(avatar.exists()).toBe(true);
expect(avatar.attributes('src')).toBe(defaultProvides.avatarUrl);
expect(findAvatarLink().attributes('href')).toBe(defaultProvides.avatarUrl);
const removeAvatarButton = findRemoveAvatarButton();
expect(removeAvatarButton.exists()).toBe(true);
expect(removeAvatarButton.attributes('href')).toBe(defaultProvides.profileAvatarPath);
});
describe('when user has avatar', () => {
describe('while gravatar is enabled', () => {
it('shows help text for change or remove avatar', () => {
createComponent({
gravatarEnabled: true,
});
expect(findHelpText()).toBe(avatarI18n.changeOrRemoveAvatar);
});
});
describe('while gravatar is disabled', () => {
it('shows help text for change avatar', () => {
createComponent({
gravatarEnabled: false,
});
expect(findHelpText()).toBe(avatarI18n.changeAvatar);
});
});
});
describe('when user does not have an avatar', () => {
describe('while gravatar is enabled', () => {
it('shows help text for upload or change avatar', () => {
createComponent({
gravatarEnabled: true,
hasAvatar: false,
});
expect(findHelpText()).toBe(avatarI18n.uploadOrChangeAvatar);
});
});
describe('while gravatar is disabled', () => {
it('shows help text for upload avatar', () => {
createComponent({
gravatarEnabled: false,
hasAvatar: false,
});
expect(findHelpText()).toBe(avatarI18n.uploadAvatar);
expect(findRemoveAvatarButton().exists()).toBe(false);
});
});
});
});
it('can render profile image guidelines', () => {
const brandProfileImageGuidelines = 'brandProfileImageGuidelines';
createComponent({
brandProfileImageGuidelines,
});
expect(wrapper.findByTestId('brand-profile-image-guidelines').text()).toBe(
brandProfileImageGuidelines,
);
});
});

View File

@ -124,6 +124,28 @@ RSpec.describe ProfilesHelper do
end
end
describe '#user_profile_data' do
let(:user) { build_stubbed(:user) }
before do
allow(helper).to receive(:current_user).and_return(user)
end
it 'returns user profile data' do
data = helper.user_profile_data(user)
expect(data[:profile_path]).to be_a(String)
expect(data[:profile_avatar_path]).to be_a(String)
expect(data[:avatar_url]).to be_http_url
expect(data[:has_avatar]).to be_a(String)
expect(data[:gravatar_enabled]).to be_a(String)
expect(Gitlab::Json.parse(data[:gravatar_link])).to match(hash_including('hostname' => Gitlab.config.gravatar.host, 'url' => a_valid_url))
expect(data[:brand_profile_image_guidelines]).to be_a(String)
expect(data[:cropper_css_path]).to eq(ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'))
expect(data[:user_path]).to be_a(String)
end
end
def stub_auth0_omniauth_provider
provider = OpenStruct.new(
'name' => example_omniauth_provider,

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe QueueBackfillDismissalReasonInVulnerabilityReads, feature_category: :vulnerability_management do
let!(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
table_name: :vulnerability_reads,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE
)
}
end
end
end