Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-17 18:08:41 +00:00
parent 37eff29d5c
commit 22a0d312ae
77 changed files with 1484 additions and 708 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 B

View File

@ -0,0 +1 @@
<svg width="100" height="32" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="#8C929D" d="M67.67 8.11h-2.06l.009 15.364h8.348v-1.9H67.68l-.01-13.465zM81.913 20.778a3.517 3.517 0 01-2.553 1.078c-1.57 0-2.203-.775-2.203-1.787 0-1.522 1.059-2.25 3.309-2.25.487.002.974.04 1.456.113v2.846h-.01zm-2.137-9.313a6.826 6.826 0 00-4.387 1.579l.728 1.267c.841-.492 1.872-.983 3.356-.983 1.693 0 2.44.87 2.44 2.326v.747a9.4 9.4 0 00-1.428-.114c-3.612 0-5.446 1.267-5.446 3.914 0 2.374 1.456 3.565 3.659 3.565 1.484 0 2.912-.68 3.404-1.787l.378 1.503h1.456v-7.866c-.01-2.487-1.087-4.151-4.16-4.151zM90.587 21.926c-.776 0-1.456-.094-1.967-.33v-7.102c.7-.586 1.57-1.011 2.676-1.011 1.995 0 2.76 1.408 2.76 3.687 0 3.234-1.238 4.756-3.47 4.756m.87-10.457a3.775 3.775 0 00-2.836 1.257V10.74l-.01-2.629h-2.013l.01 14.987c1.01.425 2.391.652 3.895.652 3.848 0 5.701-2.458 5.701-6.704-.01-3.356-1.72-5.578-4.746-5.578M45.228 9.776c1.825 0 3.006.605 3.772 1.22l.889-1.541c-1.2-1.06-2.827-1.627-4.567-1.627-4.387 0-7.46 2.676-7.46 8.075 0 5.654 3.319 7.857 7.11 7.857a12.083 12.083 0 004.577-.888L49.5 16.83v-1.9h-5.63v1.9h3.594l.047 4.586c-.473.236-1.286.425-2.392.425-3.045 0-5.087-1.92-5.087-5.957-.01-4.113 2.1-6.108 5.19-6.108M59.744 8.107H57.73l.01 2.582v8.916c0 2.487 1.078 4.15 4.15 4.15.416.002.83-.036 1.24-.113v-1.806c-.31.047-.624.07-.937.066-1.692 0-2.44-.87-2.44-2.326v-6.145h3.376v-1.683h-3.373l-.009-3.64h-.003zM52.608 23.474h2.014V11.75h-2.014zM52.608 10.133h2.014V8.119h-2.014z"/><path d="M31.864 17.907l-1.788-5.496-3.538-10.9a.612.612 0 00-1.16 0L21.84 12.406H10.085L6.547 1.512a.612.612 0 00-1.16 0L1.855 12.405.066 17.907c-.162.5.015 1.05.44 1.36L15.963 30.5l15.456-11.233a1.22 1.22 0 00.446-1.36" fill="#FC6D26"/><path d="M15.966 30.49l5.875-18.086H10.09z" fill="#E24329"/><path d="M15.962 30.49l-5.877-18.086H1.859z" fill="#FC6D26"/><path d="M1.852 12.41L.063 17.906c-.162.5.015 1.05.441 1.36L15.959 30.5 1.852 12.41z" fill="#FCA326"/><path d="M1.854 12.41h8.237L6.546 1.517a.612.612 0 00-1.16 0L1.854 12.41z" fill="#E24329"/><path d="M15.966 30.49l5.875-18.086h8.236z" fill="#FC6D26"/><path d="M30.074 12.41l1.79 5.496a1.219 1.219 0 01-.44 1.36L15.966 30.49l14.107-18.08z" fill="#FCA326"/><path d="M30.079 12.41H21.84L25.38 1.517a.612.612 0 011.16 0l3.539 10.893z" fill="#E24329"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -104,6 +104,7 @@ export default {
visibilityLevel: visibilityOptions.PUBLIC,
issuesAccessLevel: 20,
repositoryAccessLevel: 20,
forkingAccessLevel: 20,
mergeRequestsAccessLevel: 20,
buildsAccessLevel: 20,
wikiAccessLevel: 20,
@ -300,6 +301,19 @@ export default {
name="project[project_feature_attributes][merge_requests_access_level]"
/>
</project-setting-row>
<project-setting-row
:label="s__('ProjectSettings|Forks')"
:help-text="
s__('ProjectSettings|Allow users to make copies of your repository to a new project')
"
>
<project-feature-setting
v-model="forkingAccessLevel"
:options="featureAccessLevelOptions"
:disabled-input="!repositoryEnabled"
name="project[project_feature_attributes][forking_access_level]"
/>
</project-setting-row>
<project-setting-row
:label="s__('ProjectSettings|Pipelines')"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')"

View File

@ -8,7 +8,7 @@
pre {
padding: 10px 0;
border: 0;
border-radius: 0 0 $border-radius-default $border-radius-default;
border-radius: 0 0 $border-radius-default;
font-family: $monospace-font;
font-size: $code-font-size;
line-height: 1.5;

View File

@ -11,7 +11,7 @@
line-height: $code-line-height;
color: $gl-text-color;
margin: 20px;
font-weight: 200;
font-weight: $gl-font-weight-normal;
.gl-snippet-icon {
display: inline-block;
@ -34,7 +34,7 @@
.file-content.code {
border: $border-style;
border-radius: 0 0 4px 4px;
border-radius: 0 0 $border-radius-default $border-radius-default;
display: flex;
box-shadow: none;
margin: 0;
@ -45,6 +45,7 @@
overflow-x: auto;
pre {
height: 100%;
padding: 10px;
border: 0;
border-radius: 0;
@ -110,17 +111,13 @@
}
}
.gitlab-logo {
display: inline-block;
padding-left: 5px;
text-decoration: none;
color: $gl-text-color-secondary;
.gitlab-logo-wrapper {
padding-left: $gl-padding-8;
position: relative;
top: 2px;
.logo-text {
background: image_url('ext_snippet_icons/logo.png') no-repeat left center;
background-size: 18px;
font-weight: $gl-font-weight-normal;
padding-left: 24px;
.gitlab-logo {
height: 18px;
}
}
}
@ -128,7 +125,7 @@
img,
.gl-snippet-icon {
display: inline-block;
vertical-align: middle;
vertical-align: text-bottom;
}
}
@ -136,7 +133,7 @@
a.btn {
background-color: $white-light;
text-decoration: none;
padding: 7px 9px;
padding: 8px 9px;
border: $border-style;
border-right: 0;
@ -147,11 +144,11 @@
}
&:first-child {
border-radius: 3px 0 0 3px;
border-radius: $border-radius-default 0 0 $border-radius-default;
}
&:last-child {
border-radius: 0 3px 3px 0;
border-radius: 0 $border-radius-default $border-radius-default 0;
border-right: $border-style;
}
}

View File

@ -9,6 +9,7 @@ class Projects::ForksController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authenticate_user!, only: [:new, :create]
before_action :authorize_fork_project!, only: [:new, :create]
# rubocop: disable CodeReuse/ActiveRecord
def index
@ -61,6 +62,8 @@ class Projects::ForksController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
private
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335')
end

View File

@ -391,6 +391,7 @@ class ProjectsController < Projects::ApplicationController
project_feature_attributes: %i[
builds_access_level
issues_access_level
forking_access_level
merge_requests_access_level
repository_access_level
snippets_access_level

View File

@ -17,6 +17,9 @@ module Types
group.avatar_url(only_path: false)
end
field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if a group is disabled from getting mentioned'
field :parent, GroupType, null: true,
description: 'Parent group',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }

View File

@ -563,6 +563,7 @@ module ProjectsHelper
requestAccessEnabled: !!project.request_access_enabled,
issuesAccessLevel: feature.issues_access_level,
repositoryAccessLevel: feature.repository_access_level,
forkingAccessLevel: feature.forking_access_level,
mergeRequestsAccessLevel: feature.merge_requests_access_level,
buildsAccessLevel: feature.builds_access_level,
wikiAccessLevel: feature.wiki_access_level,

View File

@ -50,6 +50,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:merge_requests_access_level, value)
end
def forking_access_level=(value)
write_feature_attribute_string(:forking_access_level, value)
end
def issues_access_level=(value)
write_feature_attribute_string(:issues_access_level, value)
end

View File

@ -128,7 +128,7 @@ module ErrorTracking
# ->
# http://HOST/ORG/PROJECT
def self.extract_sentry_external_url(url)
url.sub('api/0/projects/', '')
url&.sub('api/0/projects/', '')
end
def api_host

View File

@ -317,10 +317,12 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?,
:issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :issues_access_level, :wiki_access_level,
:snippets_access_level, :builds_access_level, :repository_access_level,
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
:pages_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level,
to: :project_feature, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true

View File

@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord
ENABLED = 20
PUBLIC = 30
FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze
FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
@ -92,6 +92,7 @@ class ProjectFeature < ApplicationRecord
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
default_value_for :issues_access_level, value: ENABLED, allows_nil: false
default_value_for :forking_access_level, value: ENABLED, allows_nil: false
default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
@ -132,6 +133,10 @@ class ProjectFeature < ApplicationRecord
merge_requests_access_level > DISABLED
end
def forking_enabled?
forking_access_level > DISABLED
end
def issues_enabled?
issues_access_level > DISABLED
end

View File

@ -83,6 +83,11 @@ class ProjectPolicy < BasePolicy
project.merge_requests_allowing_push_to_user(user).any?
end
with_scope :subject
condition(:forking_allowed) do
@subject.feature_available?(:forking, @user)
end
with_scope :global
condition(:mirror_available, score: 0) do
::Gitlab::CurrentSettings.current_application_settings.mirror_available
@ -203,7 +208,6 @@ class ProjectPolicy < BasePolicy
enable :download_code
enable :read_statistics
enable :download_wiki_code
enable :fork_project
enable :create_project_snippet
enable :update_issue
enable :reopen_issue
@ -232,12 +236,15 @@ class ProjectPolicy < BasePolicy
enable :public_access
enable :guest_access
enable :fork_project
enable :build_download_code
enable :build_read_container_image
enable :request_access
end
rule { can?(:download_code) & forking_allowed }.policy do
enable :fork_project
end
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access

View File

@ -60,9 +60,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_due_date(noteable, project, author, due_date)
body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date)
end
# Called when the estimated time of a Noteable is changed
@ -80,14 +78,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
"removed time estimate"
else
"changed time estimate to #{parsed_time}"
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_estimate
end
# Called when the spent time of a Noteable is changed
@ -105,21 +96,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_time_spent(noteable, project, author)
time_spent = noteable.time_spent
if time_spent == :reset
body = "removed time spent"
else
spent_at = noteable.spent_at
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'added' : 'subtracted'
text_parts = ["#{action} #{parsed_time} of time spent"]
text_parts << "at #{spent_at}" if spent_at
body = text_parts.join(' ')
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent
end
def change_status(noteable, project, author, status, source = nil)

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
module SystemNotes
class TimeTrackingService < ::SystemNotes::BaseService
# Called when the due_date of a Noteable is changed
#
# due_date - Due date being assigned, or nil
#
# Example Note text:
#
# "removed due date"
#
# "changed due date to September 20, 2018"
#
# Returns the created Note object
def change_due_date(due_date)
body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
end
# Called when the estimated time of a Noteable is changed
#
# time_estimate - Estimated time
#
# Example Note text:
#
# "removed time estimate"
#
# "changed time estimate to 3d 5h"
#
# Returns the created Note object
def change_time_estimate
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
"removed time estimate"
else
"changed time estimate to #{parsed_time}"
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
# Called when the spent time of a Noteable is changed
#
# time_spent - Spent time
#
# Example Note text:
#
# "removed time spent"
#
# "added 2h 30m of time spent"
#
# Returns the created Note object
def change_time_spent
time_spent = noteable.time_spent
if time_spent == :reset
body = "removed time spent"
else
spent_at = noteable.spent_at
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'added' : 'subtracted'
text_parts = ["#{action} #{parsed_time} of time spent"]
text_parts << "at #{spent_at}" if spent_at
body = text_parts.join(' ')
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
end
end

View File

@ -10,10 +10,8 @@
%small
= number_to_human_size(blob.raw_size)
%a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
on &nbsp;
%span.logo-text
GitLab
%a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
%img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" }
.file-actions.d-none.d-sm-block
.btn-group{ role: "group" }<

View File

@ -0,0 +1,5 @@
---
title: Add an option to configure forking restriction
merge_request: 17988
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add ability to create an issue in an epic
merge_request: 22833
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Fix embedded snippets UI polish issues
merge_request: !22637
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Require group owner to have linked SAML before enabling Group Managed Accounts
merge_request: 21721
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Fix extracting Sentry external URL when URL is nil
merge_request: 23162
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Expose mentions_disabled value via group API
merge_request: 23070
author: Fabio Huser
type: added

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddForkingAccessLevelToProjectFeature < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :project_features, :forking_access_level, :integer
end
end

View File

@ -3167,6 +3167,7 @@ ActiveRecord::Schema.define(version: 2020_01_14_204949) do
t.datetime "updated_at"
t.integer "repository_access_level", default: 20, null: false
t.integer "pages_access_level", null: false
t.integer "forking_access_level"
t.index ["project_id"], name: "index_project_features_on_project_id", unique: true
end

View File

@ -2594,6 +2594,11 @@ type Group {
"""
lfsEnabled: Boolean
"""
Indicates if a group is disabled from getting mentioned
"""
mentionsDisabled: Boolean
"""
Name of the namespace
"""

View File

@ -3460,6 +3460,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mentionsDisabled",
"description": "Indicates if a group is disabled from getting mentioned",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the namespace",

View File

@ -393,6 +393,7 @@ Autogenerated return type of EpicTreeReorder
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
| `webUrl` | String! | Web URL of the group |
| `avatarUrl` | String | Avatar URL of the group |
| `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned |
| `parent` | Group | Parent group |
| `epicsEnabled` | Boolean | Indicates if Epics are enabled for namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace |

View File

@ -40,6 +40,7 @@ GET /groups
"auto_devops_enabled": null,
"subgroup_creation_level": "owner",
"emails_disabled": null,
"mentions_disabled": null,
"lfs_enabled": true,
"avatar_url": "http://localhost:3000/uploads/group/avatar/1/foo.jpg",
"web_url": "http://localhost:3000/groups/foo-bar",
@ -73,6 +74,7 @@ GET /groups?statistics=true
"auto_devops_enabled": null,
"subgroup_creation_level": "owner",
"emails_disabled": null,
"mentions_disabled": null,
"lfs_enabled": true,
"avatar_url": "http://localhost:3000/uploads/group/avatar/1/foo.jpg",
"web_url": "http://localhost:3000/groups/foo-bar",
@ -144,6 +146,7 @@ GET /groups/:id/subgroups
"auto_devops_enabled": null,
"subgroup_creation_level": "owner",
"emails_disabled": null,
"mentions_disabled": null,
"lfs_enabled": true,
"avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/foo.jpg",
"web_url": "http://gitlab.example.com/groups/foo-bar",
@ -486,6 +489,7 @@ Parameters:
| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. |
| `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). |
| `emails_disabled` | boolean | no | Disable email notifications |
| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned |
| `lfs_enabled` | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. |
| `request_access_enabled` | boolean | no | Allow users to request member access. |
| `parent_id` | integer | no | The parent group ID for creating nested group. |
@ -531,6 +535,7 @@ PUT /groups/:id
| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. |
| `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). |
| `emails_disabled` | boolean | no | Disable email notifications |
| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned |
| `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. |
| `request_access_enabled` | boolean | no | Allow users to request member access. |
| `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from. |

View File

@ -17,7 +17,7 @@ description: 'Learn how to contribute to GitLab.'
- [GitLab core team & GitLab Inc. contribution process](https://gitlab.com/gitlab-org/gitlab/blob/master/PROCESS.md)
- [Generate a changelog entry with `bin/changelog`](changelog.md)
- [Code review guidelines](code_review.md) for reviewing code and having code reviewed
- [Database review guidelines](database_review.md) for reviewing database-related changes and complex SQL queries
- [Database review guidelines](database_review.md) for reviewing database-related changes and complex SQL queries, and having them reviewed
- [Pipelines for the GitLab project](pipelines.md)
- [Guidelines for implementing Enterprise Edition features](ee_features.md)
- [Security process for developers](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#security-releases-critical-non-critical-as-a-developer)

View File

@ -204,6 +204,25 @@ and give all group members access to the project at once.
Alternatively, you can [lock the sharing with group feature](#share-with-group-lock).
## Sharing a group with another group
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18328) in GitLab 12.7.
Similarly to [sharing a project with a group](#sharing-a-project-with-a-group),
you can share a group with another group to give direct group members access
to the shared group. This is not valid for inherited members.
To share a given group, for example, 'Frontend' with another group, for example,
'Engineering':
1. Navigate to your 'Frontend' group page and use the left navigation menu to go
to your group **Members**.
1. Select the **Invite group** tab.
1. Add 'Engineering' with the maximum access level of your choice.
1. Click **Invite**.
All the members of the 'Engineering' group will have been added to 'Frontend'.
## Manage group memberships via LDAP
In GitLab Enterprise Edition, it is possible to manage GitLab group memberships using LDAP groups.

View File

@ -43,6 +43,7 @@ Use the switches to enable or disable the following features:
| **Issues** | ✓ | Activates the GitLab issues tracker |
| **Repository** | ✓ | Enables [repository](../repository/) functionality |
| **Merge Requests** | ✓ | Enables [merge request](../merge_requests/) functionality; also see [Merge request settings](#merge-request-settings) |
| **Forks** | ✓ | Enables [forking](../index.md#fork-a-project) functionality |
| **Pipelines** | ✓ | Enables [CI/CD](../../../ci/README.md) functionality |
| **Container Registry** | | Activates a [registry](../../packages/container_registry/) for your docker images |
| **Git Large File Storage** | | Enables the use of [large files](../../../administration/lfs/manage_large_binaries_with_git_lfs.md#git-lfs) |

View File

@ -414,6 +414,7 @@ module API
expose :auto_devops_enabled
expose :subgroup_creation_level_str, as: :subgroup_creation_level
expose :emails_disabled
expose :mentions_disabled
expose :lfs_enabled?, as: :lfs_enabled
expose :avatar_url do |group, options|
group.avatar_url(only_path: false)

View File

@ -18,6 +18,7 @@ module API
optional :auto_devops_enabled, type: Boolean, desc: 'Default to Auto DevOps pipeline for all projects within this group'
optional :subgroup_creation_level, type: String, values: ::Gitlab::Access.subgroup_creation_string_values, desc: 'Allowed to create subgroups', as: :subgroup_creation_level_str
optional :emails_disabled, type: Boolean, desc: 'Disable email notifications'
optional :mentions_disabled, type: Boolean, desc: 'Disable a group from getting mentioned'
optional :lfs_enabled, type: Boolean, desc: 'Enable/disable LFS for the projects in this group'
optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access'
end

View File

@ -35,6 +35,12 @@ module Gitlab
message: 'key may not be used with `rules`'
},
if: :has_rules?
validates :config,
disallowed_keys: {
in: %i[release],
message: 'release features are not enabled'
},
unless: -> { Feature.enabled?(:ci_release_generation, default_enabled: false) }
with_options allow_nil: true do
validates :allow_failure, boolean: true

View File

@ -3,7 +3,14 @@
module Gitlab
module ImportExport
class AttributeCleaner
ALLOWED_REFERENCES = RelationFactory::PROJECT_REFERENCES + RelationFactory::USER_REFERENCES + %w[group_id commit_id discussion_id custom_attributes]
ALLOWED_REFERENCES = [
*ProjectRelationFactory::PROJECT_REFERENCES,
*ProjectRelationFactory::USER_REFERENCES,
'group_id',
'commit_id',
'discussion_id',
'custom_attributes'
].freeze
PROHIBITED_REFERENCES = Regexp.union(/\Acached_markdown_version\Z/, /_id\Z/, /_ids\Z/, /_html\Z/, /attributes/).freeze
def self.clean(*args)

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
# Base class for Group & Project Object Builders.
# This class is not intended to be used on its own but
# rather inherited from.
#
# Cache keeps 1000 entries at most, 1000 is chosen based on:
# - one cache entry uses around 0.5K memory, 1000 items uses around 500K.
# (leave some buffer it should be less than 1M). It is afforable cost for project import.
# - for projects in Gitlab.com, it seems 1000 entries for labels/milestones is enough.
# For example, gitlab has ~970 labels and 26 milestones.
LRU_CACHE_SIZE = 1000
class BaseObjectBuilder
def self.build(*args)
new(*args).find
end
def initialize(klass, attributes)
@klass = klass.ancestors.include?(Label) ? Label : klass
@attributes = attributes
if Gitlab::SafeRequestStore.active?
@lru_cache = cache_from_request_store
@cache_key = [klass, attributes]
end
end
def find
find_with_cache do
find_object || klass.create(prepare_attributes)
end
end
protected
def where_clauses
raise NotImplementedError
end
# attributes wrapped in a method to be
# adjusted in sub-class if needed
def prepare_attributes
attributes
end
private
attr_reader :klass, :attributes, :lru_cache, :cache_key
def find_with_cache
return yield unless lru_cache && cache_key
lru_cache[cache_key] ||= yield
end
def cache_from_request_store
Gitlab::SafeRequestStore[:lru_cache] ||= LruRedux::Cache.new(LRU_CACHE_SIZE)
end
def find_object
klass.where(where_clause).first
end
def where_clause
where_clauses.reduce(:and)
end
def table
@table ||= klass.arel_table
end
# Returns Arel clause:
# `"{table_name}"."{attrs.keys[0]}" = '{attrs.values[0]} AND {table_name}"."{attrs.keys[1]}" = '{attrs.values[1]}"`
# from the given Hash of attributes.
def attrs_to_arel(attrs)
attrs.map do |key, value|
table[key].eq(value)
end.reduce(:and)
end
# Returns Arel clause `"{table_name}"."title" = '{attributes['title']}'`
# if attributes has 'title key, otherwise `nil`.
def where_clause_for_title
attrs_to_arel(attributes.slice('title'))
end
# Returns Arel clause `"{table_name}"."description" = '{attributes['description']}'`
# if attributes has 'description key, otherwise `nil`.
def where_clause_for_description
attrs_to_arel(attributes.slice('description'))
end
# Returns Arel clause `"{table_name}"."created_at" = '{attributes['created_at']}'`
# if attributes has 'created_at key, otherwise `nil`.
def where_clause_for_created_at
attrs_to_arel(attributes.slice('created_at'))
end
end
end
end

View File

@ -0,0 +1,306 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
class BaseRelationFactory
include Gitlab::Utils::StrongMemoize
IMPORTED_OBJECT_MAX_RETRIES = 5.freeze
OVERRIDES = {}.freeze
EXISTING_OBJECT_RELATIONS = %i[].freeze
# This represents all relations that have unique key on `project_id` or `group_id`
UNIQUE_RELATIONS = %i[].freeze
USER_REFERENCES = %w[
author_id
assignee_id
updated_by_id
merged_by_id
latest_closed_by_id
user_id
created_by_id
last_edited_by_id
merge_user_id
resolved_by_id
closed_by_id owner_id
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
def self.create(*args)
new(*args).create
end
def self.relation_class(relation_name)
# There are scenarios where the model is pluralized (e.g.
# MergeRequest::Metrics), and we don't want to force it to singular
# with #classify.
relation_name.to_s.classify.constantize
rescue NameError
relation_name.to_s.constantize
end
def initialize(relation_sym:, relation_hash:, members_mapper:, object_builder:, merge_requests_mapping: nil, user:, importable:, excluded_keys: [])
@relation_name = self.class.overrides[relation_sym]&.to_sym || relation_sym
@relation_hash = relation_hash.except('noteable_id')
@members_mapper = members_mapper
@object_builder = object_builder
@merge_requests_mapping = merge_requests_mapping
@user = user
@importable = importable
@imported_object_retries = 0
@relation_hash[importable_column_name] = @importable.id
# Remove excluded keys from relation_hash
# We don't do this in the parsed_relation_hash because of the 'transformed attributes'
# For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then,
# in the create method that attribute is renamed to diff. And because diff is an excluded key,
# if we clean the excluded keys in the parsed_relation_hash, it will be removed
# from the object attributes and the export will fail.
@relation_hash.except!(*excluded_keys)
end
# Creates an object from an actual model with name "relation_sym" with params from
# the relation_hash, updating references with new object IDs, mapping users using
# the "members_mapper" object, also updating notes if required.
def create
return if invalid_relation?
setup_base_models
setup_models
generate_imported_object
end
def self.overrides
self::OVERRIDES
end
def self.existing_object_relations
self::EXISTING_OBJECT_RELATIONS
end
private
def invalid_relation?
false
end
def setup_models
raise NotImplementedError
end
def unique_relations
# define in sub-class if any
self.class::UNIQUE_RELATIONS
end
def setup_base_models
update_user_references
remove_duplicate_assignees
reset_tokens!
remove_encrypted_attributes!
end
def update_user_references
self.class::USER_REFERENCES.each do |reference|
if @relation_hash[reference]
@relation_hash[reference] = @members_mapper.map[@relation_hash[reference]]
end
end
end
def remove_duplicate_assignees
return unless @relation_hash['issue_assignees']
# When an assignee did not exist in the members mapper, the importer is
# assigned. We only need to assign each user once.
@relation_hash['issue_assignees'].uniq!(&:user_id)
end
def generate_imported_object
imported_object
end
def reset_tokens!
return unless Gitlab::ImportExport.reset_tokens? && self.class::TOKEN_RESET_MODELS.include?(@relation_name)
# If we import/export to the same instance, tokens will have to be reset.
# We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across.
relation_class.attribute_names.select { |name| name.include?('token') }.each do |token|
@relation_hash[token] = nil
end
end
def remove_encrypted_attributes!
return unless relation_class.respond_to?(:encrypted_attributes) && relation_class.encrypted_attributes.any?
relation_class.encrypted_attributes.each_key do |key|
@relation_hash[key.to_s] = nil
end
end
def relation_class
@relation_class ||= self.class.relation_class(@relation_name)
end
def importable_column_name
importable_class_name.concat('_id')
end
def importable_class_name
@importable.class.to_s.downcase
end
def imported_object
if existing_or_new_object.respond_to?(:importing)
existing_or_new_object.importing = true
end
existing_or_new_object
rescue ActiveRecord::RecordNotUnique
# as the operation is not atomic, retry in the unlikely scenario an INSERT is
# performed on the same object between the SELECT and the INSERT
@imported_object_retries += 1
retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES
end
def parsed_relation_hash
@parsed_relation_hash ||= Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash,
relation_class: relation_class)
end
def existing_or_new_object
# Only find existing records to avoid mapping tables such as milestones
# Otherwise always create the record, skipping the extra SELECT clause.
@existing_or_new_object ||= begin
if existing_object?
attribute_hash = attribute_hash_for(['events'])
existing_object.assign_attributes(attribute_hash) if attribute_hash.any?
existing_object
else
# Because of single-type inheritance, we need to be careful to use the `type` field
# See https://gitlab.com/gitlab-org/gitlab/issues/34860#note_235321497
inheritance_column = relation_class.try(:inheritance_column)
inheritance_attributes = parsed_relation_hash.slice(inheritance_column)
object = relation_class.new(inheritance_attributes)
object.assign_attributes(parsed_relation_hash)
object
end
end
end
def attribute_hash_for(attributes)
attributes.each_with_object({}) do |hash, value|
hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value]
hash
end
end
def existing_object
@existing_object ||= find_or_create_object!
end
def unique_relation_object
unique_relation_object = relation_class.find_or_create_by(importable_column_name => @importable.id)
unique_relation_object.assign_attributes(parsed_relation_hash)
unique_relation_object
end
def find_or_create_object!
return unique_relation_object if unique_relation?
# Can't use IDs as validation exists calling `group` or `project` attributes
finder_hash = parsed_relation_hash.tap do |hash|
if relation_class.attribute_method?('group_id') && @importable.is_a?(Project)
hash['group'] = @importable.group
end
hash[importable_class_name] = @importable if relation_class.reflect_on_association(importable_class_name.to_sym)
hash.delete(importable_column_name)
end
@object_builder.build(relation_class, finder_hash)
end
def setup_note
set_note_author
# attachment is deprecated and note uploads are handled by Markdown uploader
@relation_hash['attachment'] = nil
end
# Sets the author for a note. If the user importing the project
# has admin access, an actual mapping with new project members
# will be used. Otherwise, a note stating the original author name
# is left.
def set_note_author
old_author_id = @relation_hash['author_id']
author = @relation_hash.delete('author')
update_note_for_missing_author(author['name']) unless has_author?(old_author_id)
end
def has_author?(old_author_id)
admin_user? && @members_mapper.include?(old_author_id)
end
def missing_author_note(updated_at, author_name)
timestamp = updated_at.split('.').first
"\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*"
end
def update_note_for_missing_author(author_name)
@relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank?
@relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}"
end
def admin_user?
@user.admin?
end
def existing_object?
strong_memoize(:_existing_object) do
self.class.existing_object_relations.include?(@relation_name) || unique_relation?
end
end
def unique_relation?
strong_memoize(:unique_relation) do
importable_foreign_key.present? &&
(has_unique_index_on_importable_fk? || uses_importable_fk_as_primary_key?)
end
end
def has_unique_index_on_importable_fk?
cache = cached_has_unique_index_on_importable_fk
table_name = relation_class.table_name
return cache[table_name] if cache.has_key?(table_name)
index_exists =
ActiveRecord::Base.connection.index_exists?(
relation_class.table_name,
importable_foreign_key,
unique: true)
cache[table_name] = index_exists
end
# Avoid unnecessary DB requests
def cached_has_unique_index_on_importable_fk
Thread.current[:cached_has_unique_index_on_importable_fk] ||= {}
end
def uses_importable_fk_as_primary_key?
relation_class.primary_key == importable_foreign_key
end
def importable_foreign_key
relation_class.reflect_on_association(importable_class_name.to_sym)&.foreign_key
end
end
end
end

View File

@ -11,61 +11,29 @@ module Gitlab
# finds or initializes a label with the given attributes.
#
# It also adds some logic around Group Labels/Milestones for edge cases.
class GroupProjectObjectBuilder
# Cache keeps 1000 entries at most, 1000 is chosen based on:
# - one cache entry uses around 0.5K memory, 1000 items uses around 500K.
# (leave some buffer it should be less than 1M). It is afforable cost for project import.
# - for projects in Gitlab.com, it seems 1000 entries for labels/milestones is enough.
# For example, gitlab has ~970 labels and 26 milestones.
LRU_CACHE_SIZE = 1000
class GroupProjectObjectBuilder < BaseObjectBuilder
def self.build(*args)
Project.transaction do
new(*args).find
super
end
end
def initialize(klass, attributes)
@klass = klass < Label ? Label : klass
@attributes = attributes
super
@group = @attributes['group']
@project = @attributes['project']
if Gitlab::SafeRequestStore.active?
@lru_cache = cache_from_request_store
@cache_key = [klass, attributes]
end
end
def find
return if epic? && group.nil?
find_with_cache do
find_object || klass.create(project_attributes)
end
super
end
private
attr_reader :klass, :attributes, :group, :project, :lru_cache, :cache_key
def find_with_cache
return yield unless lru_cache && cache_key
lru_cache[cache_key] ||= yield
end
def cache_from_request_store
Gitlab::SafeRequestStore[:lru_cache] ||= LruRedux::Cache.new(LRU_CACHE_SIZE)
end
def find_object
klass.where(where_clause).first
end
def where_clause
where_clauses.reduce(:and)
end
attr_reader :group, :project
def where_clauses
[
@ -86,26 +54,12 @@ module Gitlab
end.reduce(:or)
end
# Returns Arel clause `"{table_name}"."title" = '{attributes['title']}'`
# if attributes has 'title key, otherwise `nil`.
def where_clause_for_title
attrs_to_arel(attributes.slice('title'))
# Returns Arel clause for a particular model or `nil`.
def where_clause_for_klass
attrs_to_arel(attributes.slice('iid')) if merge_request?
end
# Returns Arel clause:
# `"{table_name}"."{attrs.keys[0]}" = '{attrs.values[0]} AND {table_name}"."{attrs.keys[1]}" = '{attrs.values[1]}"`
# from the given Hash of attributes.
def attrs_to_arel(attrs)
attrs.map do |key, value|
table[key].eq(value)
end.reduce(:and)
end
def table
@table ||= klass.arel_table
end
def project_attributes
def prepare_attributes
attributes.except('group').tap do |atts|
if label?
atts['type'] = 'ProjectLabel' # Always create project labels
@ -154,13 +108,6 @@ module Gitlab
milestone.ensure_project_iid!
milestone.save!
end
protected
# Returns Arel clause for a particular model or `nil`.
def where_clause_for_klass
return attrs_to_arel(attributes.slice('iid')) if merge_request?
end
end
end
end

View File

@ -0,0 +1,184 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
class ProjectRelationFactory < BaseRelationFactory
prepend_if_ee('::EE::Gitlab::ImportExport::ProjectRelationFactory') # rubocop: disable Cop/InjectEnterpriseEditionModule
OVERRIDES = { snippets: :project_snippets,
ci_pipelines: 'Ci::Pipeline',
pipelines: 'Ci::Pipeline',
stages: 'Ci::Stage',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
pipeline_schedules: 'Ci::PipelineSchedule',
builds: 'Ci::Build',
runners: 'Ci::Runner',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
push_access_levels: 'ProtectedBranch::PushAccessLevel',
create_access_levels: 'ProtectedTag::CreateAccessLevel',
labels: :project_labels,
priorities: :label_priorities,
auto_devops: :project_auto_devops,
label: :project_label,
custom_attributes: 'ProjectCustomAttribute',
project_badges: 'Badge',
metrics: 'MergeRequest::Metrics',
ci_cd_settings: 'ProjectCiCdSetting',
error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting',
links: 'Releases::Link',
metrics_setting: 'ProjectMetricsSetting' }.freeze
BUILD_MODELS = %i[Ci::Build commit_status].freeze
GROUP_REFERENCES = %w[group_id].freeze
PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze
EXISTING_OBJECT_RELATIONS = %i[
milestone
milestones
label
labels
project_label
project_labels
group_label
group_labels
project_feature
merge_request
epic
ProjectCiCdSetting
container_expiration_policy
].freeze
def create
@object = super
# We preload the project, user, and group to re-use objects
@object = preload_keys(@object, PROJECT_REFERENCES, @importable)
@object = preload_keys(@object, GROUP_REFERENCES, @importable.group)
@object = preload_keys(@object, USER_REFERENCES, @user)
end
private
def invalid_relation?
# Do not create relation if it is:
# - An unknown service
# - A legacy trigger
unknown_service? ||
(!Feature.enabled?(:use_legacy_pipeline_triggers, @importable) && legacy_trigger?)
end
def setup_models
case @relation_name
when :merge_request_diff_files then setup_diff
when :notes then setup_note
when :'Ci::Pipeline' then setup_pipeline
when *BUILD_MODELS then setup_build
end
update_project_references
update_group_references
end
def generate_imported_object
if @relation_name == :merge_requests
MergeRequestParser.new(@importable, @relation_hash.delete('diff_head_sha'), super, @relation_hash).parse!
else
super
end
end
def update_project_references
# If source and target are the same, populate them with the new project ID.
if @relation_hash['source_project_id']
@relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID
end
@relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id']
end
def same_source_and_target?
@relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id']
end
def update_group_references
return unless existing_object?
return unless @relation_hash['group_id']
@relation_hash['group_id'] = @importable.namespace_id
end
# This code is a workaround for broken project exports that don't
# export merge requests with CI pipelines (i.e. exports that were
# generated from
# https://gitlab.com/gitlab-org/gitlab/merge_requests/17844).
# This method can be removed in GitLab 12.6.
def update_merge_request_references
# If a merge request was properly created, we don't need to fix
# up this export.
return if @relation_hash['merge_request']
merge_request_id = @relation_hash['merge_request_id']
return unless merge_request_id
new_merge_request_id = @merge_requests_mapping[merge_request_id]
return unless new_merge_request_id
@relation_hash['merge_request_id'] = new_merge_request_id
parsed_relation_hash['merge_request_id'] = new_merge_request_id
end
def setup_build
@relation_hash.delete('trace') # old export files have trace
@relation_hash.delete('token')
@relation_hash.delete('commands')
@relation_hash.delete('artifacts_file_store')
@relation_hash.delete('artifacts_metadata_store')
@relation_hash.delete('artifacts_size')
end
def setup_diff
@relation_hash['diff'] = @relation_hash.delete('utf8_diff')
end
def setup_pipeline
update_merge_request_references
@relation_hash.fetch('stages', []).each do |stage|
stage.statuses.each do |status|
status.pipeline = imported_object
end
end
end
def unknown_service?
@relation_name == :services && parsed_relation_hash['type'] &&
!Object.const_defined?(parsed_relation_hash['type'])
end
def legacy_trigger?
@relation_name == :'Ci::Trigger' && @relation_hash['owner_id'].nil?
end
def preload_keys(object, references, value)
return object unless value
references.each do |key|
attribute = "#{key.delete_suffix('_id')}=".to_sym
next unless object.respond_to?(key) && object.respond_to?(attribute)
if object.read_attribute(key) == value&.id
object.public_send(attribute, value) # rubocop:disable GitlabSecurity/PublicSend
end
end
object
end
end
end
end

View File

@ -48,6 +48,7 @@ module Gitlab
shared: @shared,
importable: @project,
tree_hash: @tree_hash,
object_builder: object_builder,
members_mapper: members_mapper,
relation_factory: relation_factory,
reader: reader
@ -60,8 +61,12 @@ module Gitlab
importable: @project)
end
def object_builder
Gitlab::ImportExport::GroupProjectObjectBuilder
end
def relation_factory
Gitlab::ImportExport::RelationFactory
Gitlab::ImportExport::ProjectRelationFactory
end
def reader

View File

@ -1,418 +0,0 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
class RelationFactory
include Gitlab::Utils::StrongMemoize
prepend_if_ee('::EE::Gitlab::ImportExport::RelationFactory') # rubocop: disable Cop/InjectEnterpriseEditionModule
OVERRIDES = { snippets: :project_snippets,
ci_pipelines: 'Ci::Pipeline',
pipelines: 'Ci::Pipeline',
stages: 'Ci::Stage',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
pipeline_schedules: 'Ci::PipelineSchedule',
builds: 'Ci::Build',
runners: 'Ci::Runner',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
push_access_levels: 'ProtectedBranch::PushAccessLevel',
create_access_levels: 'ProtectedTag::CreateAccessLevel',
labels: :project_labels,
priorities: :label_priorities,
auto_devops: :project_auto_devops,
label: :project_label,
custom_attributes: 'ProjectCustomAttribute',
project_badges: 'Badge',
metrics: 'MergeRequest::Metrics',
ci_cd_settings: 'ProjectCiCdSetting',
error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting',
links: 'Releases::Link',
metrics_setting: 'ProjectMetricsSetting' }.freeze
USER_REFERENCES = %w[author_id assignee_id updated_by_id merged_by_id latest_closed_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id owner_id].freeze
PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze
GROUP_REFERENCES = %w[group_id].freeze
BUILD_MODELS = %i[Ci::Build commit_status].freeze
IMPORTED_OBJECT_MAX_RETRIES = 5.freeze
EXISTING_OBJECT_RELATIONS = %i[
milestone
milestones
label
labels
project_label
project_labels
group_label
group_labels
project_feature
merge_request
epic
ProjectCiCdSetting
container_expiration_policy
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
def self.create(*args)
new(*args).create
end
def self.relation_class(relation_name)
# There are scenarios where the model is pluralized (e.g.
# MergeRequest::Metrics), and we don't want to force it to singular
# with #classify.
relation_name.to_s.classify.constantize
rescue NameError
relation_name.to_s.constantize
end
def initialize(relation_sym:, relation_hash:, members_mapper:, merge_requests_mapping:, user:, project:, excluded_keys: [])
@relation_name = self.class.overrides[relation_sym]&.to_sym || relation_sym
@relation_hash = relation_hash.except('noteable_id')
@members_mapper = members_mapper
@merge_requests_mapping = merge_requests_mapping
@user = user
@project = project
@imported_object_retries = 0
@relation_hash['project_id'] = @project.id
# Remove excluded keys from relation_hash
# We don't do this in the parsed_relation_hash because of the 'transformed attributes'
# For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then,
# in the create method that attribute is renamed to diff. And because diff is an excluded key,
# if we clean the excluded keys in the parsed_relation_hash, it will be removed
# from the object attributes and the export will fail.
@relation_hash.except!(*excluded_keys)
end
# Creates an object from an actual model with name "relation_sym" with params from
# the relation_hash, updating references with new object IDs, mapping users using
# the "members_mapper" object, also updating notes if required.
def create
return if unknown_service?
setup_models
object = generate_imported_object
# We preload the project, user, and group to re-use objects
object = preload_keys(object, PROJECT_REFERENCES, @project)
object = preload_keys(object, GROUP_REFERENCES, @project.group)
object = preload_keys(object, USER_REFERENCES, @user)
object
end
def self.overrides
OVERRIDES
end
def self.existing_object_relations
EXISTING_OBJECT_RELATIONS
end
private
def existing_object?
strong_memoize(:_existing_object) do
self.class.existing_object_relations.include?(@relation_name) || unique_relation?
end
end
def setup_models
case @relation_name
when :merge_request_diff_files then setup_diff
when :notes then setup_note
end
update_user_references
update_project_references
update_group_references
remove_duplicate_assignees
if @relation_name == :'Ci::Pipeline'
update_merge_request_references
setup_pipeline
end
reset_tokens!
remove_encrypted_attributes!
end
def preload_keys(object, references, value)
return object unless value
references.each do |key|
attribute = "#{key.delete_suffix('_id')}=".to_sym
next unless object.respond_to?(key) && object.respond_to?(attribute)
if object.read_attribute(key) == value&.id
object.public_send(attribute, value) # rubocop:disable GitlabSecurity/PublicSend
end
end
object
end
def update_user_references
USER_REFERENCES.each do |reference|
if @relation_hash[reference]
@relation_hash[reference] = @members_mapper.map[@relation_hash[reference]]
end
end
end
def remove_duplicate_assignees
return unless @relation_hash['issue_assignees']
# When an assignee did not exist in the members mapper, the importer is
# assigned. We only need to assign each user once.
@relation_hash['issue_assignees'].uniq!(&:user_id)
end
def setup_note
set_note_author
# attachment is deprecated and note uploads are handled by Markdown uploader
@relation_hash['attachment'] = nil
end
# Sets the author for a note. If the user importing the project
# has admin access, an actual mapping with new project members
# will be used. Otherwise, a note stating the original author name
# is left.
def set_note_author
old_author_id = @relation_hash['author_id']
author = @relation_hash.delete('author')
update_note_for_missing_author(author['name']) unless has_author?(old_author_id)
end
def has_author?(old_author_id)
admin_user? && @members_mapper.include?(old_author_id)
end
def missing_author_note(updated_at, author_name)
timestamp = updated_at.split('.').first
"\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*"
end
def generate_imported_object
if BUILD_MODELS.include?(@relation_name)
@relation_hash.delete('trace') # old export files have trace
@relation_hash.delete('token')
@relation_hash.delete('commands')
@relation_hash.delete('artifacts_file_store')
@relation_hash.delete('artifacts_metadata_store')
@relation_hash.delete('artifacts_size')
imported_object
elsif @relation_name == :merge_requests
MergeRequestParser.new(@project, @relation_hash.delete('diff_head_sha'), imported_object, @relation_hash).parse!
else
imported_object
end
end
def update_project_references
# If source and target are the same, populate them with the new project ID.
if @relation_hash['source_project_id']
@relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID
end
@relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id']
end
def same_source_and_target?
@relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id']
end
def update_group_references
return unless existing_object?
return unless @relation_hash['group_id']
@relation_hash['group_id'] = @project.namespace_id
end
# This code is a workaround for broken project exports that don't
# export merge requests with CI pipelines (i.e. exports that were
# generated from
# https://gitlab.com/gitlab-org/gitlab/merge_requests/17844).
# This method can be removed in GitLab 12.6.
def update_merge_request_references
# If a merge request was properly created, we don't need to fix
# up this export.
return if @relation_hash['merge_request']
merge_request_id = @relation_hash['merge_request_id']
return unless merge_request_id
new_merge_request_id = @merge_requests_mapping[merge_request_id]
return unless new_merge_request_id
@relation_hash['merge_request_id'] = new_merge_request_id
parsed_relation_hash['merge_request_id'] = new_merge_request_id
end
def reset_tokens!
return unless Gitlab::ImportExport.reset_tokens? && TOKEN_RESET_MODELS.include?(@relation_name)
# If we import/export a project to the same instance, tokens will have to be reset.
# We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across.
relation_class.attribute_names.select { |name| name.include?('token') }.each do |token|
@relation_hash[token] = nil
end
end
def remove_encrypted_attributes!
return unless relation_class.respond_to?(:encrypted_attributes) && relation_class.encrypted_attributes.any?
relation_class.encrypted_attributes.each_key do |key|
@relation_hash[key.to_s] = nil
end
end
def relation_class
@relation_class ||= self.class.relation_class(@relation_name)
end
def imported_object
if existing_or_new_object.respond_to?(:importing)
existing_or_new_object.importing = true
end
existing_or_new_object
rescue ActiveRecord::RecordNotUnique
# as the operation is not atomic, retry in the unlikely scenario an INSERT is
# performed on the same object between the SELECT and the INSERT
@imported_object_retries += 1
retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES
end
def update_note_for_missing_author(author_name)
@relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank?
@relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}"
end
def admin_user?
@user.admin?
end
def parsed_relation_hash
@parsed_relation_hash ||= Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash,
relation_class: relation_class)
end
def setup_diff
@relation_hash['diff'] = @relation_hash.delete('utf8_diff')
end
def setup_pipeline
@relation_hash.fetch('stages', []).each do |stage|
stage.statuses.each do |status|
status.pipeline = imported_object
end
end
end
def existing_or_new_object
# Only find existing records to avoid mapping tables such as milestones
# Otherwise always create the record, skipping the extra SELECT clause.
@existing_or_new_object ||= begin
if existing_object?
attribute_hash = attribute_hash_for(['events'])
existing_object.assign_attributes(attribute_hash) if attribute_hash.any?
existing_object
else
# Because of single-type inheritance, we need to be careful to use the `type` field
# See https://gitlab.com/gitlab-org/gitlab/issues/34860#note_235321497
inheritance_column = relation_class.try(:inheritance_column)
inheritance_attributes = parsed_relation_hash.slice(inheritance_column)
object = relation_class.new(inheritance_attributes)
object.assign_attributes(parsed_relation_hash)
object
end
end
end
def attribute_hash_for(attributes)
attributes.inject({}) do |hash, value|
hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value]
hash
end
end
def existing_object
@existing_object ||= find_or_create_object!
end
def unknown_service?
@relation_name == :services && parsed_relation_hash['type'] &&
!Object.const_defined?(parsed_relation_hash['type'])
end
def unique_relation?
strong_memoize(:unique_relation) do
project_foreign_key.present? &&
(has_unique_index_on_project_fk? || uses_project_fk_as_primary_key?)
end
end
def has_unique_index_on_project_fk?
cache = cached_has_unique_index_on_project_fk
table_name = relation_class.table_name
return cache[table_name] if cache.has_key?(table_name)
index_exists =
ActiveRecord::Base.connection.index_exists?(
relation_class.table_name,
project_foreign_key,
unique: true)
cache[table_name] = index_exists
end
# Avoid unnecessary DB requests
def cached_has_unique_index_on_project_fk
Thread.current[:cached_has_unique_index_on_project_fk] ||= {}
end
def uses_project_fk_as_primary_key?
relation_class.primary_key == project_foreign_key
end
# Should be `:project_id` for most of the cases, but this is more general
def project_foreign_key
relation_class.reflect_on_association(:project)&.foreign_key
end
def find_or_create_object!
if unique_relation?
unique_relation_object = relation_class.find_or_create_by(project_id: @project.id)
unique_relation_object.assign_attributes(parsed_relation_hash)
return unique_relation_object
end
# Can't use IDs as validation exists calling `group` or `project` attributes
finder_hash = parsed_relation_hash.tap do |hash|
hash['group'] = @project.group if relation_class.attribute_method?('group_id')
hash['project'] = @project if relation_class.reflect_on_association(:project)
hash.delete('project_id')
end
GroupProjectObjectBuilder.build(relation_class, finder_hash)
end
end
end
end

View File

@ -11,12 +11,13 @@ module Gitlab
attr_reader :importable
attr_reader :tree_hash
def initialize(user:, shared:, importable:, tree_hash:, members_mapper:, relation_factory:, reader:)
def initialize(user:, shared:, importable:, tree_hash:, members_mapper:, object_builder:, relation_factory:, reader:)
@user = user
@shared = shared
@importable = importable
@tree_hash = tree_hash
@members_mapper = members_mapper
@object_builder = object_builder
@relation_factory = relation_factory
@reader = reader
end
@ -221,15 +222,16 @@ module Gitlab
def relation_factory_params(relation_key, data_hash)
base_params = {
relation_sym: relation_key.to_sym,
relation_hash: data_hash,
relation_sym: relation_key.to_sym,
relation_hash: data_hash,
importable: @importable,
members_mapper: @members_mapper,
user: @user,
excluded_keys: excluded_keys_for_relation(relation_key)
object_builder: @object_builder,
user: @user,
excluded_keys: excluded_keys_for_relation(relation_key)
}
base_params[:merge_requests_mapping] = merge_requests_mapping if importable_class == Project
base_params[importable_class_sym] = @importable
base_params
end
end

View File

@ -9235,6 +9235,9 @@ msgstr ""
msgid "Group ID: %{group_id}"
msgstr ""
msgid "Group Owner must have signed in with SAML before enabling Group Managed Accounts"
msgstr ""
msgid "Group Runners"
msgstr ""
@ -14360,6 +14363,9 @@ msgstr ""
msgid "ProjectSettings|All discussions must be resolved"
msgstr ""
msgid "ProjectSettings|Allow users to make copies of your repository to a new project"
msgstr ""
msgid "ProjectSettings|Allow users to request access"
msgstr ""
@ -14420,6 +14426,9 @@ msgstr ""
msgid "ProjectSettings|Fast-forward merges only"
msgstr ""
msgid "ProjectSettings|Forks"
msgstr ""
msgid "ProjectSettings|Git Large File Storage"
msgstr ""

View File

@ -15,6 +15,10 @@ module QA
CAPYBARA_MAX_WAIT_TIME = 10
class << self
attr_accessor :rspec_configured, :capybara_configured
end
def initialize
self.class.configure!
end
@ -45,11 +49,40 @@ module QA
end
def self.configure!
configure_rspec!
configure_capybara!
end
def self.configure_rspec!
# We don't want to enter this infinite loop:
# Runtime::Release.perform_before_hooks -> `QA::Runtime::Browser.visit` -> configure! -> configure_rspec! -> Runtime::Release.perform_before_hooks
# So we make sure this method is called only once.
return if self.rspec_configured
browser = self
RSpec.configure do |config|
config.define_derived_metadata(file_path: %r{/qa/specs/features/}) do |metadata|
metadata[:type] = :feature
end
config.before do
unless browser.rspec_configured
browser.rspec_configured = true
##
# Perform before hooks, which are different for CE and EE
#
Runtime::Release.perform_before_hooks
end
end
end
end
def self.configure_capybara!
return if self.capybara_configured
self.capybara_configured = true
Capybara.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i

View File

@ -14,11 +14,9 @@ module QA
attr_writer :logger
def logger
return @logger if @logger
@logger = ::Logger.new Runtime::Env.log_destination
@logger.level = Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::ERROR
@logger
@logger ||= ::Logger.new(Runtime::Env.log_destination).tap do |logger|
logger.level = Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::ERROR
end
end
end
end

View File

@ -23,11 +23,6 @@ module QA
def perform(options, *args)
extract_address(:gitlab_address, options, args)
##
# Perform before hooks, which are different for CE and EE
#
Runtime::Release.perform_before_hooks
Runtime::Feature.enable(options[:enable_feature]) if options.key?(:enable_feature)
Specs::Runner.perform do |specs|

View File

@ -20,11 +20,6 @@ module QA
def self.do_perform(address, *rspec_options)
Runtime::Scenario.define(:gitlab_address, address)
##
# Perform before hooks, which are different for CE and EE
#
Runtime::Release.perform_before_hooks
Specs::Runner.perform do |specs|
specs.tty = true
specs.options = rspec_options if rspec_options.any?

View File

@ -16,7 +16,7 @@ module QA
super
end
def wait_until(max_duration: 60, sleep_interval: 0.1, reload: true)
def wait_until(max_duration: 60, sleep_interval: 0.1, reload: true, raise_on_failure: false)
log("next wait uses reload: #{reload}")
# Logging of wait start/end/duration is handled by QA::Support::Waiter

View File

@ -62,7 +62,7 @@ describe QA::Page::Base do
end
end
describe '#wait' do
describe '#wait_until' do
subject { Class.new(described_class).new }
context 'when the condition is true' do

View File

@ -12,9 +12,9 @@ QA::Runtime::Browser.configure!
QA::Runtime::Scenario.from_env(QA::Runtime::Env.runtime_scenario_attributes) if QA::Runtime::Env.runtime_scenario_attributes
%w[helpers shared_examples].each do |d|
Dir[::File.join(__dir__, d, '**', '*.rb')].each { |f| require f }
end
Dir[::File.join(__dir__, "support/helpers/*.rb")].each { |f| require f }
Dir[::File.join(__dir__, "support/shared_contexts/*.rb")].each { |f| require f }
Dir[::File.join(__dir__, "support/shared_examples/*.rb")].each { |f| require f }
RSpec.configure do |config|
QA::Specs::Helpers::Quarantine.configure_rspec

View File

@ -31,12 +31,6 @@ shared_examples 'a QA scenario class' do
expect(attributes).to have_received(:define).with(:gitlab_address, 'http://gitlab_address').at_least(:once)
end
it 'performs before hooks' do
subject.perform(args)
expect(release).to have_received(:perform_before_hooks)
end
it 'sets tags on runner' do
subject.perform(args)

View File

@ -12,6 +12,21 @@ describe Projects::ForksController do
group.add_owner(user)
end
shared_examples 'forking disabled' do
let(:project) { create(:project, :private, :repository, :forking_disabled) }
before do
project.add_developer(user)
sign_in(user)
end
it 'returns with 404' do
subject
expect(response).to have_gitlab_http_status(404)
end
end
describe 'GET index' do
def get_forks(search: nil)
get :index,
@ -138,19 +153,19 @@ describe Projects::ForksController do
end
describe 'GET new' do
def get_new
subject do
get :new,
params: {
namespace_id: project.namespace,
project_id: project
}
params: {
namespace_id: project.namespace,
project_id: project
}
end
context 'when user is signed in' do
it 'responds with status 200' do
sign_in(user)
get_new
subject
expect(response).to have_gitlab_http_status(200)
end
@ -160,21 +175,26 @@ describe Projects::ForksController do
it 'redirects to the sign-in page' do
sign_out(user)
get_new
subject
expect(response).to redirect_to(new_user_session_path)
end
end
it_behaves_like 'forking disabled'
end
describe 'POST create' do
def post_create(params = {})
post :create,
params: {
namespace_id: project.namespace,
project_id: project,
namespace_key: user.namespace.id
}.merge(params)
let(:params) do
{
namespace_id: project.namespace,
project_id: project,
namespace_key: user.namespace.id
}
end
subject do
post :create, params: params
end
context 'when user is signed in' do
@ -183,18 +203,34 @@ describe Projects::ForksController do
end
it 'responds with status 302' do
post_create
subject
expect(response).to have_gitlab_http_status(302)
expect(response).to redirect_to(namespace_project_import_path(user.namespace, project))
end
it 'passes continue params to the redirect' do
continue_params = { to: '/-/ide/project/path', notice: 'message' }
post_create continue: continue_params
context 'continue params' do
let(:params) do
{
namespace_id: project.namespace,
project_id: project,
namespace_key: user.namespace.id,
continue: continue_params
}
end
let(:continue_params) do
{
to: '/-/ide/project/path',
notice: 'message'
}
end
expect(response).to have_gitlab_http_status(302)
expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params))
it 'passes continue params to the redirect' do
subject
expect(response).to have_gitlab_http_status(302)
expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params))
end
end
end
@ -202,10 +238,12 @@ describe Projects::ForksController do
it 'redirects to the sign-in page' do
sign_out(user)
post_create
subject
expect(response).to redirect_to(new_user_session_path)
end
end
it_behaves_like 'forking disabled'
end
end

View File

@ -25,6 +25,7 @@ FactoryBot.define do
builds_access_level { ProjectFeature::ENABLED }
snippets_access_level { ProjectFeature::ENABLED }
issues_access_level { ProjectFeature::ENABLED }
forking_access_level { ProjectFeature::ENABLED }
merge_requests_access_level { ProjectFeature::ENABLED }
repository_access_level { ProjectFeature::ENABLED }
pages_access_level do
@ -48,6 +49,7 @@ FactoryBot.define do
builds_access_level: builds_access_level,
snippets_access_level: evaluator.snippets_access_level,
issues_access_level: evaluator.issues_access_level,
forking_access_level: evaluator.forking_access_level,
merge_requests_access_level: merge_requests_access_level,
repository_access_level: evaluator.repository_access_level
}
@ -264,6 +266,9 @@ FactoryBot.define do
trait(:issues_disabled) { issues_access_level { ProjectFeature::DISABLED } }
trait(:issues_enabled) { issues_access_level { ProjectFeature::ENABLED } }
trait(:issues_private) { issues_access_level { ProjectFeature::PRIVATE } }
trait(:forking_disabled) { forking_access_level { ProjectFeature::DISABLED } }
trait(:forking_enabled) { forking_access_level { ProjectFeature::ENABLED } }
trait(:forking_private) { forking_access_level { ProjectFeature::PRIVATE } }
trait(:merge_requests_enabled) { merge_requests_access_level { ProjectFeature::ENABLED } }
trait(:merge_requests_disabled) { merge_requests_access_level { ProjectFeature::DISABLED } }
trait(:merge_requests_private) { merge_requests_access_level { ProjectFeature::PRIVATE } }

View File

@ -186,7 +186,7 @@ describe 'Edit Project Settings' do
click_button "Save changes"
end
expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 2)
expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 3)
end
it "shows empty features project homepage" do

View File

@ -27,6 +27,89 @@ describe 'Project fork' do
expect(page).to have_css('a.disabled', text: 'Fork')
end
context 'forking enabled / disabled in project settings' do
before do
project.project_feature.update_attribute(
:forking_access_level, forking_access_level)
end
context 'forking is enabled' do
let(:forking_access_level) { ProjectFeature::ENABLED }
it 'enables fork button' do
visit project_path(project)
expect(page).to have_css('a', text: 'Fork')
expect(page).not_to have_css('a.disabled', text: 'Fork')
end
it 'renders new project fork page' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(200)
expect(page).to have_text(' Select a namespace to fork the project ')
end
end
context 'forking is disabled' do
let(:forking_access_level) { ProjectFeature::DISABLED }
it 'does not render fork button' do
visit project_path(project)
expect(page).not_to have_css('a', text: 'Fork')
end
it 'does not render new project fork page' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(404)
end
end
context 'forking is private' do
let(:forking_access_level) { ProjectFeature::PRIVATE }
before do
project.update(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
context 'user is not a team member' do
it 'does not render fork button' do
visit project_path(project)
expect(page).not_to have_css('a', text: 'Fork')
end
it 'does not render new project fork page' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(404)
end
end
context 'user is a team member' do
before do
project.add_developer(user)
end
it 'enables fork button' do
visit project_path(project)
expect(page).to have_css('a', text: 'Fork')
expect(page).not_to have_css('a.disabled', text: 'Fork')
end
it 'renders new project fork page' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(200)
expect(page).to have_text(' Select a namespace to fork the project ')
end
end
end
end
it 'forks the project', :sidekiq_might_not_need_inline do
visit project_path(project)

View File

@ -34,6 +34,26 @@ describe 'Projects settings' do
expect_toggle_state(:expanded)
end
context 'forking enabled', :js do
it 'toggles forking enabled / disabled' do
visit edit_project_path(project)
forking_enabled_input = find('input[name="project[project_feature_attributes][forking_access_level]"]', visible: :hidden)
forking_enabled_button = find('input[name="project[project_feature_attributes][forking_access_level]"] + label > button')
expect(forking_enabled_input.value).to eq('20')
# disable by clicking toggle
forking_enabled_button.click
page.within('.sharing-permissions') do
find('input[value="Save changes"]').click
end
wait_for_requests
expect(forking_enabled_input.value).to eq('0')
end
end
def expect_toggle_state(state)
is_collapsed = state == :collapsed

View File

@ -15,7 +15,6 @@ describe('Assignee component', () => {
const createWrapper = (propsData = getDefaultProps()) => {
wrapper = mount(Assignee, {
propsData,
attachToDocument: true,
});
};

View File

@ -23,7 +23,6 @@ describe('AssigneeAvatarLink component', () => {
};
wrapper = shallowMount(AssigneeAvatarLink, {
attachToDocument: true,
propsData,
});
}

View File

@ -16,7 +16,6 @@ describe('CollapsedAssigneeList component', () => {
};
wrapper = shallowMount(CollapsedAssigneeList, {
attachToDocument: true,
propsData,
});
}

View File

@ -18,7 +18,6 @@ describe('UncollapsedAssigneeList component', () => {
};
wrapper = mount(UncollapsedAssigneeList, {
attachToDocument: true,
propsData,
});
}

View File

@ -8,4 +8,10 @@ describe GitlabSchema.types['Group'] do
it { expect(described_class.graphql_name).to eq('Group') }
it { expect(described_class).to require_graphql_authorizations(:read_group) }
it 'has the expected fields' do
expected_fields = %w[web_url avatar_url mentions_disabled parent]
is_expected.to include_graphql_fields(*expected_fields)
end
end

View File

@ -1331,9 +1331,9 @@ module Gitlab
stub_feature_flags(ci_release_generation: false)
end
it "returns release info" do
expect(processor.stage_builds_attributes('release').first[:options].include?(config[:release]))
.to be false
it 'raises error' do
expect { processor }.to raise_error(
'jobs:release config release features are not enabled: release')
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::BaseObjectBuilder do
let(:project) do
create(:project, :repository,
:builds_disabled,
:issues_disabled,
name: 'project',
path: 'project')
end
let(:klass) { Milestone }
let(:attributes) { { 'title' => 'Test BaseObjectBuilder Milestone', 'project' => project } }
subject { described_class.build(klass, attributes) }
describe '#build' do
context 'when object exists' do
context 'when where_clauses are implemented' do
before do
allow_next_instance_of(described_class) do |object_builder|
allow(object_builder).to receive(:where_clauses).and_return([klass.arel_table['title'].eq(attributes['title'])])
end
end
let!(:milestone) { create(:milestone, title: attributes['title'], project: project) }
it 'finds existing object instead of creating one' do
expect(subject).to eq(milestone)
end
end
context 'when where_clauses are not implemented' do
it 'raises NotImplementedError' do
expect { subject }.to raise_error(NotImplementedError)
end
end
end
context 'when object does not exist' do
before do
allow_next_instance_of(described_class) do |object_builder|
allow(object_builder).to receive(:find_object).and_return(nil)
end
end
it 'creates new object' do
expect { subject }.to change { Milestone.count }.from(0).to(1)
end
end
end
end

View File

@ -0,0 +1,145 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::BaseRelationFactory do
let(:user) { create(:admin) }
let(:project) { create(:project) }
let(:members_mapper) { double('members_mapper').as_null_object }
let(:relation_sym) { :project_snippets }
let(:merge_requests_mapping) { {} }
let(:relation_hash) { {} }
let(:excluded_keys) { [] }
subject do
described_class.create(relation_sym: relation_sym,
relation_hash: relation_hash,
object_builder: Gitlab::ImportExport::GroupProjectObjectBuilder,
members_mapper: members_mapper,
merge_requests_mapping: merge_requests_mapping,
user: user,
importable: project,
excluded_keys: excluded_keys)
end
describe '#create' do
context 'when relation is invalid' do
before do
expect_next_instance_of(described_class) do |relation_factory|
expect(relation_factory).to receive(:invalid_relation?).and_return(true)
end
end
it 'returns without creating new relations' do
expect(subject).to be_nil
end
end
context 'when #setup_models is not implemented' do
it 'raises NotImplementedError' do
expect { subject }.to raise_error(NotImplementedError)
end
end
context 'when #setup_models is implemented' do
let(:relation_sym) { :notes }
let(:relation_hash) do
{
"id" => 4947,
"note" => "merged",
"noteable_type" => "MergeRequest",
"author_id" => 999,
"created_at" => "2016-11-18T09:29:42.634Z",
"updated_at" => "2016-11-18T09:29:42.634Z",
"project_id" => 1,
"attachment" => {
"url" => nil
},
"noteable_id" => 377,
"system" => true,
"events" => []
}
end
before do
expect_next_instance_of(described_class) do |relation_factory|
expect(relation_factory).to receive(:setup_models).and_return(true)
end
end
it 'creates imported object' do
expect(subject).to be_instance_of(Note)
end
context 'when relation contains user references' do
let(:new_user) { create(:user) }
let(:exported_member) do
{
"id" => 111,
"access_level" => 30,
"source_id" => 1,
"source_type" => "Project",
"user_id" => 3,
"notification_level" => 3,
"created_at" => "2016-11-18T09:29:42.634Z",
"updated_at" => "2016-11-18T09:29:42.634Z",
"user" => {
"id" => 999,
"email" => new_user.email,
"username" => new_user.username
}
}
end
let(:members_mapper) do
Gitlab::ImportExport::MembersMapper.new(
exported_members: [exported_member],
user: user,
importable: project)
end
it 'maps the right author to the imported note' do
expect(subject.author).to eq(new_user)
end
end
context 'when relation contains token attributes' do
let(:relation_sym) { 'ProjectHook' }
let(:relation_hash) { { token: 'secret' } }
it 'removes token attributes' do
expect(subject.token).to be_nil
end
end
context 'when relation contains encrypted attributes' do
let(:relation_sym) { 'Ci::Variable' }
let(:relation_hash) do
create(:ci_variable).as_json
end
it 'removes encrypted attributes' do
expect(subject.value).to be_nil
end
end
end
end
describe '.relation_class' do
context 'when relation name is pluralized' do
let(:relation_name) { 'MergeRequest::Metrics' }
it 'returns constantized class' do
expect(described_class.relation_class(relation_name)).to eq(MergeRequest::Metrics)
end
end
context 'when relation name is singularized' do
let(:relation_name) { 'Badge' }
it 'returns constantized class' do
expect(described_class.relation_class(relation_name)).to eq(Badge)
end
end
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Gitlab::ImportExport::RelationFactory do
describe Gitlab::ImportExport::ProjectRelationFactory do
let(:group) { create(:group) }
let(:project) { create(:project, :repository, group: group) }
let(:members_mapper) { double('members_mapper').as_null_object }
@ -12,10 +12,11 @@ describe Gitlab::ImportExport::RelationFactory do
let(:created_object) do
described_class.create(relation_sym: relation_sym,
relation_hash: relation_hash,
object_builder: Gitlab::ImportExport::GroupProjectObjectBuilder,
members_mapper: members_mapper,
merge_requests_mapping: merge_requests_mapping,
user: user,
project: project,
importable: project,
excluded_keys: excluded_keys)
end
@ -97,7 +98,7 @@ describe Gitlab::ImportExport::RelationFactory do
end
end
context 'merge_requset object' do
context 'merge_request object' do
let(:relation_sym) { :merge_requests }
let(:exported_member) do
@ -244,11 +245,11 @@ describe Gitlab::ImportExport::RelationFactory do
context 'Project references' do
let(:relation_sym) { :project_foo_model }
let(:relation_hash) do
Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES.map { |ref| { ref => 99 } }.inject(:merge)
Gitlab::ImportExport::ProjectRelationFactory::PROJECT_REFERENCES.map { |ref| { ref => 99 } }.inject(:merge)
end
class ProjectFooModel < FooModel
attr_accessor(*Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES)
attr_accessor(*Gitlab::ImportExport::ProjectRelationFactory::PROJECT_REFERENCES)
end
before do

View File

@ -27,6 +27,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
shared: shared,
tree_hash: tree_hash,
importable: importable,
object_builder: object_builder,
members_mapper: members_mapper,
relation_factory: relation_factory,
reader: reader
@ -38,7 +39,8 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
context 'when restoring a project' do
let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' }
let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') }
let(:relation_factory) { Gitlab::ImportExport::RelationFactory }
let(:object_builder) { Gitlab::ImportExport::GroupProjectObjectBuilder }
let(:relation_factory) { Gitlab::ImportExport::ProjectRelationFactory }
let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) }
let(:tree_hash) { importable_hash }

View File

@ -545,6 +545,7 @@ ProjectFeature:
- id
- project_id
- merge_requests_access_level
- forking_access_level
- issues_access_level
- wiki_access_level
- snippets_access_level

View File

@ -64,6 +64,22 @@ describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
describe '.extract_sentry_external_url' do
subject { described_class.extract_sentry_external_url(sentry_url) }
describe 'when passing a URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
it { is_expected.to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project') }
end
describe 'when passing nil' do
let(:sentry_url) { nil }
it { is_expected.to be_nil }
end
end
describe '#sentry_external_url' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }

View File

@ -358,6 +358,7 @@ describe API::Groups do
expect(json_response['two_factor_grace_period']).to eq(group1.two_factor_grace_period)
expect(json_response['auto_devops_enabled']).to eq(group1.auto_devops_enabled)
expect(json_response['emails_disabled']).to eq(group1.emails_disabled)
expect(json_response['mentions_disabled']).to eq(group1.mentions_disabled)
expect(json_response['project_creation_level']).to eq('maintainer')
expect(json_response['subgroup_creation_level']).to eq('maintainer')
expect(json_response['web_url']).to eq(group1.web_url)
@ -556,6 +557,7 @@ describe API::Groups do
expect(json_response['two_factor_grace_period']).to eq(48)
expect(json_response['auto_devops_enabled']).to eq(nil)
expect(json_response['emails_disabled']).to eq(nil)
expect(json_response['mentions_disabled']).to eq(nil)
expect(json_response['project_creation_level']).to eq("noone")
expect(json_response['subgroup_creation_level']).to eq("maintainer")
expect(json_response['request_access_enabled']).to eq(true)

View File

@ -2858,6 +2858,20 @@ describe API::Projects do
expect(json_response['message']).to eq('401 Unauthorized')
end
end
context 'forking disabled' do
before do
project.project_feature.update_attribute(
:forking_access_level, ProjectFeature::DISABLED)
end
it 'denies project to be forked' do
post api("/projects/#{project.id}/fork", admin)
expect(response).to have_gitlab_http_status(409)
expect(json_response['message']['forked_from_project_id']).to eq(['is forbidden'])
end
end
end
describe 'POST /projects/:id/housekeeping' do

View File

@ -224,6 +224,19 @@ describe Projects::ForkService do
end
end
end
context 'when forking is disabled' do
before do
@from_project.project_feature.update_attribute(
:forking_access_level, ProjectFeature::DISABLED)
end
it 'fails' do
to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace)
expect(to_project.errors[:forked_from_project_id]).to eq(['is forbidden'])
end
end
end
describe 'fork to namespace' do

View File

@ -76,28 +76,14 @@ describe SystemNoteService do
end
describe '.change_due_date' do
subject { described_class.change_due_date(noteable, project, author, due_date) }
let(:due_date) { double }
let(:due_date) { Date.today }
it_behaves_like 'a note with overridable created_at'
it_behaves_like 'a system note' do
let(:action) { 'due_date' }
end
context 'when due date added' do
it 'sets the note text' do
expect(subject.note).to eq "changed due date to #{Date.today.to_s(:long)}"
it 'calls TimeTrackingService' do
expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service|
expect(service).to receive(:change_due_date).with(due_date)
end
end
context 'when due date removed' do
let(:due_date) { nil }
it 'sets the note text' do
expect(subject.note).to eq 'removed due date'
end
described_class.change_due_date(noteable, project, author, due_date)
end
end
@ -488,36 +474,12 @@ describe SystemNoteService do
end
describe '.change_time_estimate' do
subject { described_class.change_time_estimate(noteable, project, author) }
it_behaves_like 'a system note' do
let(:action) { 'time_tracking' }
end
context 'with a time estimate' do
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "changed time estimate to 1w 4d 5h"
it 'calls TimeTrackingService' do
expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service|
expect(service).to receive(:change_time_estimate)
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "changed time estimate to 77h"
end
end
end
context 'without a time estimate' do
it 'sets the note text' do
expect(subject.note).to eq "removed time estimate"
end
described_class.change_time_estimate(noteable, project, author)
end
end
@ -548,62 +510,13 @@ describe SystemNoteService do
end
describe '.change_time_spent' do
# We need a custom noteable in order to the shared examples to be green.
let(:noteable) do
mr = create(:merge_request, source_project: project)
mr.spend_time(duration: 360000, user_id: author.id)
mr.save!
mr
end
it 'calls TimeTrackingService' do
expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service|
expect(service).to receive(:change_time_spent)
end
subject do
described_class.change_time_spent(noteable, project, author)
end
it_behaves_like 'a system note' do
let(:action) { 'time_tracking' }
end
context 'when time was added' do
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "added 1w 4d 5h of time spent"
end
end
context 'when time was subtracted' do
it 'sets the note text' do
spend_time!(-277200)
expect(subject.note).to eq "subtracted 1w 4d 5h of time spent"
end
end
context 'when time was removed' do
it 'sets the note text' do
spend_time!(:reset)
expect(subject.note).to eq "removed time spent"
end
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "added 77h of time spent"
end
end
def spend_time!(seconds)
noteable.spend_time(duration: seconds, user_id: author.id)
noteable.save!
end
end
describe '.handle_merge_request_wip' do

View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
require 'spec_helper'
describe ::SystemNotes::TimeTrackingService do
let_it_be(:author) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let(:noteable) { create(:issue, project: project) }
describe '#change_due_date' do
subject { described_class.new(noteable: noteable, project: project, author: author).change_due_date(due_date) }
let(:due_date) { Date.today }
it_behaves_like 'a note with overridable created_at'
it_behaves_like 'a system note' do
let(:action) { 'due_date' }
end
context 'when due date added' do
it 'sets the note text' do
expect(subject.note).to eq "changed due date to #{due_date.to_s(:long)}"
end
end
context 'when due date removed' do
let(:due_date) { nil }
it 'sets the note text' do
expect(subject.note).to eq 'removed due date'
end
end
end
describe '.change_time_estimate' do
subject { described_class.new(noteable: noteable, project: project, author: author).change_time_estimate }
it_behaves_like 'a system note' do
let(:action) { 'time_tracking' }
end
context 'with a time estimate' do
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "changed time estimate to 1w 4d 5h"
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "changed time estimate to 77h"
end
end
end
context 'without a time estimate' do
it 'sets the note text' do
expect(subject.note).to eq "removed time estimate"
end
end
end
describe '.change_time_spent' do
# We need a custom noteable in order to the shared examples to be green.
let(:noteable) do
mr = create(:merge_request, source_project: project)
mr.spend_time(duration: 360000, user_id: author.id)
mr.save!
mr
end
subject do
described_class.new(noteable: noteable, project: project, author: author).change_time_spent
end
it_behaves_like 'a system note' do
let(:action) { 'time_tracking' }
end
context 'when time was added' do
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "added 1w 4d 5h of time spent"
end
end
context 'when time was subtracted' do
it 'sets the note text' do
spend_time!(-277200)
expect(subject.note).to eq "subtracted 1w 4d 5h of time spent"
end
end
context 'when time was removed' do
it 'sets the note text' do
spend_time!(:reset)
expect(subject.note).to eq "removed time spent"
end
end
context 'when time_tracking_limit_to_hours setting is true' do
before do
stub_application_setting(time_tracking_limit_to_hours: true)
end
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "added 77h of time spent"
end
end
def spend_time!(seconds)
noteable.spend_time(duration: seconds, user_id: author.id)
noteable.save!
end
end
end

View File

@ -36,8 +36,8 @@ module ConfigurationHelper
end
def relation_class_for_name(relation_name)
relation_name = Gitlab::ImportExport::RelationFactory.overrides[relation_name.to_sym] || relation_name
Gitlab::ImportExport::RelationFactory.relation_class(relation_name)
relation_name = Gitlab::ImportExport::ProjectRelationFactory.overrides[relation_name.to_sym] || relation_name
Gitlab::ImportExport::ProjectRelationFactory.relation_class(relation_name)
end
def parsed_attributes(relation_name, attributes, config: Gitlab::ImportExport.config_file)