Commit 4a84ae4e authored by Robert Speicher's avatar Robert Speicher

Merge branch '5987-group-file-templates' into 'master'

Implement group file templates

Closes #5987

See merge request gitlab-org/gitlab-ee!7391
parents e445f1f9 7626500e
...@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels'; ...@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import { GROUP_BADGE } from '~/badges/constants'; import { GROUP_BADGE } from '~/badges/constants';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
groupAvatar(); groupAvatar();
...@@ -15,4 +16,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -15,4 +16,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
); );
mountBadgeSettings(GROUP_BADGE); mountBadgeSettings(GROUP_BADGE);
projectSelect();
}); });
...@@ -183,23 +183,23 @@ module BlobHelper ...@@ -183,23 +183,23 @@ module BlobHelper
end end
private :template_dropdown_names private :template_dropdown_names
def licenses_for_select(project = @project) def licenses_for_select(project)
@licenses_for_select ||= template_dropdown_names(TemplateFinder.build(:licenses, project).execute) @licenses_for_select ||= template_dropdown_names(TemplateFinder.build(:licenses, project).execute)
end end
def gitignore_names(project = @project) def gitignore_names(project)
@gitignore_names ||= template_dropdown_names(TemplateFinder.build(:gitignores, project).execute) @gitignore_names ||= template_dropdown_names(TemplateFinder.build(:gitignores, project).execute)
end end
def gitlab_ci_ymls(project = @project) def gitlab_ci_ymls(project)
@gitlab_ci_ymls ||= template_dropdown_names(TemplateFinder.build(:gitlab_ci_ymls, project).execute) @gitlab_ci_ymls ||= template_dropdown_names(TemplateFinder.build(:gitlab_ci_ymls, project).execute)
end end
def dockerfile_names(project = @project) def dockerfile_names(project)
@dockerfile_names ||= template_dropdown_names(TemplateFinder.build(:dockerfiles, project).execute) @dockerfile_names ||= template_dropdown_names(TemplateFinder.build(:dockerfiles, project).execute)
end end
def blob_editor_paths(project = @project) def blob_editor_paths(project)
{ {
'relative-url-root' => Rails.application.config.relative_url_root, 'relative-url-root' => Rails.application.config.relative_url_root,
'assets-prefix' => Gitlab::Application.config.assets.prefix, 'assets-prefix' => Gitlab::Application.config.assets.prefix,
......
...@@ -37,6 +37,8 @@ ...@@ -37,6 +37,8 @@
.settings-content .settings-content
= render 'shared/badges/badge_settings' = render 'shared/badges/badge_settings'
= render_if_exists 'groups/templates_setting', expanded: expanded
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
.settings-header .settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
......
...@@ -5,13 +5,13 @@ ...@@ -5,13 +5,13 @@
.template-type-selector.js-template-type-selector-wrap.hidden .template-type-selector.js-template-type-selector-wrap.hidden
= dropdown_tag("Choose type", options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', title: "Choose a template type" } ) = dropdown_tag("Choose type", options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', title: "Choose a template type" } )
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag("Apply a license template", options: { toggle_class: 'js-license-selector qa-license-dropdown', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } ) = dropdown_tag("Apply a license template", options: { toggle_class: 'js-license-selector qa-license-dropdown', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } )
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } )
.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } )
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } )
.template-selectors-undo-menu.hidden .template-selectors-undo-menu.hidden
%span.text-info Template applied %span.text-info Template applied
%button.btn.btn-sm.btn-info Undo %button.btn.btn-sm.btn-info Undo
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
= link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do
= editing_preview_title(@blob.name) = editing_preview_title(@blob.name)
= form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do
= render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data
= render 'shared/new_commit_form', placeholder: "Update #{@blob.name}" = render 'shared/new_commit_form', placeholder: "Update #{@blob.name}"
= hidden_field_tag 'last_commit_sha', @last_commit_sha = hidden_field_tag 'last_commit_sha', @last_commit_sha
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
New file New file
= render 'template_selectors' = render 'template_selectors'
.file-editor .file-editor
= form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do
= render 'projects/blob/editor', ref: @ref = render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file" = render 'shared/new_commit_form', placeholder: "Add new file"
......
...@@ -1815,6 +1815,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do ...@@ -1815,6 +1815,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do
t.integer "project_creation_level" t.integer "project_creation_level"
t.string "runners_token" t.string "runners_token"
t.datetime_with_timezone "trial_ends_on" t.datetime_with_timezone "trial_ends_on"
t.integer "file_template_project_id"
end end
add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree
...@@ -3265,6 +3266,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do ...@@ -3265,6 +3266,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do
add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade
add_foreign_key "namespace_statistics", "namespaces", on_delete: :cascade add_foreign_key "namespace_statistics", "namespaces", on_delete: :cascade
add_foreign_key "namespaces", "plans", name: "fk_fdd12e5b80", on_delete: :nullify add_foreign_key "namespaces", "plans", name: "fk_fdd12e5b80", on_delete: :nullify
add_foreign_key "namespaces", "projects", column: "file_template_project_id", name: "fk_319256d87a", on_delete: :nullify
add_foreign_key "note_diff_files", "notes", column: "diff_note_id", on_delete: :cascade add_foreign_key "note_diff_files", "notes", column: "diff_note_id", on_delete: :cascade
add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade
add_foreign_key "notification_settings", "users", name: "fk_0c95e91db7", on_delete: :cascade add_foreign_key "notification_settings", "users", name: "fk_0c95e91db7", on_delete: :cascade
......
...@@ -37,6 +37,7 @@ GET /groups ...@@ -37,6 +37,7 @@ GET /groups
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null "parent_id": null
} }
] ]
...@@ -62,6 +63,7 @@ GET /groups?statistics=true ...@@ -62,6 +63,7 @@ GET /groups?statistics=true
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"statistics": { "statistics": {
"storage_size" : 212, "storage_size" : 212,
...@@ -122,6 +124,7 @@ GET /groups/:id/subgroups ...@@ -122,6 +124,7 @@ GET /groups/:id/subgroups
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": 123 "parent_id": 123
} }
] ]
...@@ -232,6 +235,7 @@ Example response: ...@@ -232,6 +235,7 @@ Example response:
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Twitter", "full_name": "Twitter",
"full_path": "twitter", "full_path": "twitter",
"file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"shared_runners_minutes_limit": 133, "shared_runners_minutes_limit": 133,
"projects": [ "projects": [
...@@ -387,6 +391,7 @@ Example response: ...@@ -387,6 +391,7 @@ Example response:
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Twitter", "full_name": "Twitter",
"full_path": "twitter", "full_path": "twitter",
"file_template_project_id": 1,
"parent_id": null "parent_id": null
} }
``` ```
...@@ -446,6 +451,7 @@ PUT /groups/:id ...@@ -446,6 +451,7 @@ PUT /groups/:id
| `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. | | `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. |
| `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group | | `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. | | `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 |
| `shared_runners_minutes_limit` | integer | no | (admin-only) Pipeline minutes quota for this group | | `shared_runners_minutes_limit` | integer | no | (admin-only) Pipeline minutes quota for this group |
```bash ```bash
...@@ -467,6 +473,7 @@ Example response: ...@@ -467,6 +473,7 @@ Example response:
"request_access_enabled": false, "request_access_enabled": false,
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"projects": [ "projects": [
{ {
......
# Project templates API # Project templates API
This API is a project-specific implementation of these endpoints: This API is a project-specific version of these endpoints:
- [Dockerfile templates](templates/dockerfiles.md) - [Dockerfile templates](templates/dockerfiles.md)
- [Gitignore templates](templates/gitignores.md) - [Gitignore templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Open source license templates](templates/licenses.md) - [Open source license templates](templates/licenses.md)
It deprecates those endpoints, which will be removed for API version 5. It deprecates these endpoints, which will be removed for API version 5.
Project-specific templates will be added to this API in time. This includes, but In addition to templates common to the entire instance, project-specific
is not limited to: templates are also available from this API endpoint.
- [Issue and Merge Request templates](../user/project/description_templates.html) Support will be added for [Issue and Merge Request templates](../user/project/description_templates.md)
- [Group level file templates](https://gitlab.com/gitlab-org/gitlab-ee/issues/5987) **(Premium)** in a future release.
Support for [Group-level file templates](../user/group/index.md#group-level-file-templates-premium)
**[PREMIUM]** was [added](https://gitlab.com/gitlab-org/gitlab-ee/issues/5987)
in GitLab 11.5
## Get all templates of a particular type ## Get all templates of a particular type
......
...@@ -294,6 +294,30 @@ This will disable the option for all users who previously had permissions to ...@@ -294,6 +294,30 @@ This will disable the option for all users who previously had permissions to
operate project memberships so no new users can be added. Furthermore, any operate project memberships so no new users can be added. Furthermore, any
request to add new user to project through API will not be possible. request to add new user to project through API will not be possible.
#### Group-level file templates **[PREMIUM]**
Group-level file templates allow you to share a set of templates for common file
types with every project in a group. It is analogous to the
[instance-level template repository](../admin_area/settings/instance_template_repository.md)
feature, and the selected project should follow the same naming conventions as
are documented on that page.
Only projects that are in the group may be chosen as the source of templates.
This includes projects shared with the group, but **excludes** projects in
subgroups or parent groups of the group being configured.
This feature may be configured for subgroups as well as parent groups. A project
in a subgroup will have access to the templates for that subgroup, as well as
any parent groups.
![Group-level file template dropdown](img/group_file_template_dropdown.png)
To enable this feature, navigate to the group settings page, expand the
**Templates** section, choose a project to act as the template repository, and
**Save group**.
![Group-level file template settings](img/group_file_template_settings.png)
### Advanced settings ### Advanced settings
- **Projects**: view all projects within that group, add members to each project, - **Projects**: view all projects within that group, add members to each project,
......
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
:repository_size_limit :repository_size_limit
].tap do |params_ee| ].tap do |params_ee|
params_ee << :project_creation_level if current_group&.feature_available?(:project_creation_level) params_ee << :project_creation_level if current_group&.feature_available?(:project_creation_level)
params_ee << :file_template_project_id if current_group&.feature_available?(:custom_file_templates_for_namespace)
end end
end end
......
...@@ -2,65 +2,31 @@ module EE ...@@ -2,65 +2,31 @@ module EE
module LicenseTemplateFinder module LicenseTemplateFinder
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
attr_reader :custom_templates
private :custom_templates
def initialize(project, *args, &blk)
super
@custom_templates =
::Gitlab::CustomFileTemplates.new(::Gitlab::Template::CustomLicenseTemplate, project)
end
override :execute override :execute
def execute def execute
return super unless custom_templates? return super unless custom_templates?
if params[:name] if params[:name]
custom_template || super custom_templates.find(params[:name]) || super
else else
custom_templates + super custom_templates.all + super
end end
end end
private private
def custom_templates
templates_for(template_project).map do |template|
translate(template, category: :Custom)
end
end
def custom_template
template = template_for(template_project, params[:name])
translate(template, category: :Custom)
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end
def custom_templates? def custom_templates?
!popular_only? && !popular_only? && custom_templates.enabled?
::License.feature_available?(:custom_file_templates) &&
template_project.present?
end
def template_project
strong_memoize(:template_project) { ::Gitlab::CurrentSettings.file_template_project }
end
def templates_for(project)
return [] unless project
::Gitlab::Template::CustomLicenseTemplate.all(project)
end
def template_for(project, name)
return unless project
::Gitlab::Template::CustomLicenseTemplate.find(name, project)
end
def translate(template, category:)
return unless template
LicenseTemplate.new(
key: template.key,
name: template.name,
nickname: template.name,
category: category,
content: -> { template.content }
)
end end
end end
end end
...@@ -11,41 +11,22 @@ module EE ...@@ -11,41 +11,22 @@ module EE
attr_reader :custom_templates attr_reader :custom_templates
private :custom_templates private :custom_templates
def initialize(type, *args, &blk) def initialize(type, project, *args, &blk)
super super
@custom_templates = CUSTOM_TEMPLATES.fetch(type) finder = CUSTOM_TEMPLATES.fetch(type)
@custom_templates = ::Gitlab::CustomFileTemplates.new(finder, project)
end end
override :execute override :execute
def execute def execute
return super unless custom_templates? return super unless custom_templates.enabled?
if params[:name] if params[:name]
find_custom_template || super custom_templates.find(params[:name]) || super
else else
find_custom_templates + super custom_templates.all + super
end end
end end
private
def find_custom_template
custom_templates.find(params[:name], template_project)
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end
def find_custom_templates
custom_templates.all(template_project)
end
def custom_templates?
::License.feature_available?(:custom_file_templates) && template_project.present?
end
def template_project
strong_memoize(:template_project) { ::Gitlab::CurrentSettings.file_template_project }
end
end end
end end
...@@ -19,6 +19,12 @@ module EE ...@@ -19,6 +19,12 @@ module EE
# here since Group inherits from Namespace, the entity_type would be set to `Namespace`. # here since Group inherits from Namespace, the entity_type would be set to `Namespace`.
has_many :audit_events, -> { where(entity_type: ::Group) }, foreign_key: 'entity_id' has_many :audit_events, -> { where(entity_type: ::Group) }, foreign_key: 'entity_id'
belongs_to :file_template_project, class_name: "Project"
# Use +checked_file_template_project+ instead, which implements important
# visibility checks
private :file_template_project
validates :repository_size_limit, validates :repository_size_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true } numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
...@@ -26,6 +32,14 @@ module EE ...@@ -26,6 +32,14 @@ module EE
joins(:ldap_group_links).where(ldap_group_links: { provider: provider }) joins(:ldap_group_links).where(ldap_group_links: { provider: provider })
end end
scope :with_custom_file_templates, -> do
preload(
file_template_project: :route,
projects: :route,
shared_projects: :route
).where.not(file_template_project_id: nil)
end
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
state :ready state :ready
state :started state :started
...@@ -115,5 +129,23 @@ module EE ...@@ -115,5 +129,23 @@ module EE
def first_non_empty_project def first_non_empty_project
projects.detect { |project| !project.empty_repo? } projects.detect { |project| !project.empty_repo? }
end end
# Overrides a method defined in `::EE::Namespace`
override :checked_file_template_project
def checked_file_template_project(*args, &blk)
project = file_template_project(*args, &blk)
return nil unless project && (
project_ids.include?(project.id) || shared_project_ids.include?(project.id))
# The license check would normally be the cheapest to perform, so would
# come first. In this case, the method is carefully designed to perform
# no SQL at all, but `feature_available?` will cause an ApplicationSetting
# to be created if it doesn't already exist! This is mostly a problem in
# the specs, but best avoided in any case.
return nil unless feature_available?(:custom_file_templates_for_namespace)
project
end
end end
end end
...@@ -35,10 +35,16 @@ module EE ...@@ -35,10 +35,16 @@ module EE
delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset, delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset,
to: :namespace_statistics, allow_nil: true to: :namespace_statistics, allow_nil: true
# Opportunistically clear the +file_template_project_id+ if invalid
before_validation :clear_file_template_project_id
validate :validate_plan_name validate :validate_plan_name
validate :validate_shared_runner_minutes_support validate :validate_shared_runner_minutes_support
before_create :sync_membership_lock_with_parent before_create :sync_membership_lock_with_parent
# Changing the plan or other details may invalidate this cache
before_save :clear_feature_available_cache
end end
class_methods do class_methods do
...@@ -200,6 +206,15 @@ module EE ...@@ -200,6 +206,15 @@ module EE
actual_plan_name == FREE_PLAN actual_plan_name == FREE_PLAN
end end
# A namespace may not have a file template project
def checked_file_template_project
nil
end
def checked_file_template_project_id
checked_file_template_project&.id
end
private private
def validate_plan_name def validate_plan_name
...@@ -216,6 +231,10 @@ module EE ...@@ -216,6 +231,10 @@ module EE
end end
end end
def clear_feature_available_cache
clear_memoization(:feature_available)
end
def load_feature_available(feature) def load_feature_available(feature)
globally_available = License.feature_available?(feature) globally_available = License.feature_available?(feature)
...@@ -225,5 +244,12 @@ module EE ...@@ -225,5 +244,12 @@ module EE
globally_available globally_available
end end
end end
def clear_file_template_project_id
return unless has_attribute?(:file_template_project_id)
return if checked_file_template_project_id.present?
self.file_template_project_id = nil
end
end end
end end
...@@ -41,6 +41,7 @@ class License < ActiveRecord::Base ...@@ -41,6 +41,7 @@ class License < ActiveRecord::Base
board_milestone_lists board_milestone_lists
cross_project_pipelines cross_project_pipelines
custom_file_templates custom_file_templates
custom_file_templates_for_namespace
email_additional_text email_additional_text
db_load_balancing db_load_balancing
deploy_board deploy_board
......
...@@ -5,11 +5,44 @@ module EE ...@@ -5,11 +5,44 @@ module EE
override :execute override :execute
def execute def execute
if changes_file_template_project_id?
check_file_template_project_id_change!
return false if group.errors.present?
end
super.tap { |success| log_audit_event if success } super.tap { |success| log_audit_event if success }
end end
private private
def changes_file_template_project_id?
return false unless params.key?(:file_template_project_id)
params[:file_template_project_id] != group.checked_file_template_project_id
end
def check_file_template_project_id_change!
unless can?(current_user, :admin_group, group)
group.errors.add(:file_template_project_id, 'cannot be changed by you')
return
end
# Clearing the current value is always permitted if you can admin the group
return unless params[:file_template_project_id].present?
# Ensure the user can see the new project, avoiding information disclosures
return if file_template_project_visible?
group.errors.add(:file_template_project_id, 'is invalid')
end
def file_template_project_visible?
ProjectsFinder.new(
current_user: current_user,
project_ids_relation: [params[:file_template_project_id]]
).execute.exists?
end
def log_audit_event def log_audit_event
EE::Audit::GroupChangesAuditor.new(current_user, group).execute EE::Audit::GroupChangesAuditor.new(current_user, group).execute
end end
......
- return unless @group.feature_available?(:custom_file_templates_for_namespace)
%section.settings.gs-advanced.no-animate#js-templates{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Templates')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set a template repository for projects in this group')
.settings-content
= form_for @group, url: group_path, html: { class: 'fieldset-form' } do |f|
= form_errors(@group)
%fieldset
.form-group
= f.label :file_template_project_id, class: 'label-light' do
.form-text.text-muted
= _('Select a template repository')
= link_to icon('question-circle'), help_page_path('user/group/index.md', anchor: 'group-level-file-templates-premium'), target: '_blank'
= project_select_tag('group[file_template_project_id]', class: 'project-item-select hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: _('Search projects'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true}, value: @group.checked_file_template_project_id)
= f.submit _('Save changes'), class: "btn btn-success"
---
title: Group-level file templates
merge_request: 7391
author:
type: added
class AddNamespaceFileTemplateProjectId < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column :namespaces, :file_template_project_id, :integer
add_concurrent_foreign_key :namespaces, :projects, column: :file_template_project_id, on_delete: :nullify
end
def down
remove_foreign_key :namespaces, column: :file_template_project_id
remove_column :namespaces, :file_template_project_id, :integer
end
end
...@@ -32,8 +32,12 @@ module EE ...@@ -32,8 +32,12 @@ module EE
prepended do prepended do
expose :ldap_cn, :ldap_access expose :ldap_cn, :ldap_access
expose :ldap_group_links, expose :ldap_group_links,
using: EE::API::Entities::LdapGroupLink, using: EE::API::Entities::LdapGroupLink,
if: ->(group, options) { group.ldap_group_links.any? } if: ->(group, options) { group.ldap_group_links.any? }
expose :checked_file_template_project_id,
as: :file_template_project_id,
if: ->(group, options) { group.feature_available?(:custom_file_templates_for_namespace) }
end end
end end
......
module Gitlab
class CustomFileTemplates
include ::Gitlab::Utils::StrongMemoize
attr_reader :finder, :project
def initialize(finder, project)
@finder = finder
@project = project
end
def enabled?
instance_enabled? || namespace_enabled?
end
def all
by_namespace = namespace_template_projects_hash.flat_map do |namespace, project|
templates_for(project, category_for(namespace))
end
by_instance =
if instance_enabled?
templates_for(instance_template_project, 'Instance')
else
[]
end
by_namespace + by_instance
end
def find(name)
namespace_template_projects_hash.each do |namespace, project|
found = template_for(project, name, category_for(namespace))
return found if found
end
template_for(instance_template_project, name, 'Instance')
end
private
def instance_enabled?
instance_template_project.present?
end
def namespace_enabled?
namespace_template_projects_hash.present?
end
def instance_template_project
strong_memoize(:instance_template_project) do
if ::License.feature_available?(:custom_file_templates)
::Gitlab::CurrentSettings.file_template_project
end
end
end
def category_for(namespace)
"Group #{namespace.full_name}"
end
# Template projects referenced by each group are included here. They are
# ordered from most-specific to least-specific
def namespace_template_projects_hash
strong_memoize(:namespace_template_projects_hash) do
next [] unless project.present?
project
.ancestors_upto(nil)
.with_custom_file_templates
.select { |namespace| namespace.checked_file_template_project }
.map { |namespace| [namespace, namespace.checked_file_template_project] }
.to_h
end
end
def templates_for(project, category)
return [] unless project
finder.all(project).map { |template| translate(template, category: category) }
end
def template_for(project, name, category)
return unless project
translate(finder.find(name, project), category: category)
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end
def translate(template, category:)
return unless template
template.category = category
# License templates require special handling as the "vendored" licenses
# are actually in a gem, not on disk like the rest of the templates. So,
# all license templates use a shim that presents a unified interface.
return template unless license_templates?
LicenseTemplate.new(
key: template.key,
name: template.name,
nickname: template.name,
category: template.category,
content: -> { template.content }
)
end
def license_templates?
finder == ::Gitlab::Template::CustomLicenseTemplate
end
end
end
...@@ -69,6 +69,30 @@ describe GroupsController do ...@@ -69,6 +69,30 @@ describe GroupsController do
put :update, id: group.to_param, group: { name: 'world' } put :update, id: group.to_param, group: { name: 'world' }
end.to change { group.reload.name } end.to change { group.reload.name }
end end
context 'no license' do
it 'does not update the file_template_project_id successfully' do
project = create(:project, group: group)
stub_licensed_features(custom_file_templates_for_namespace: false)
expect do
post :update, id: group.to_param, group: { file_template_project_id: project.id }
end.not_to change { group.reload.file_template_project_id }
end
end
context 'with license' do
it 'updates the file_template_project_id successfully' do
project = create(:project, group: group)
stub_licensed_features(custom_file_templates_for_namespace: true)
expect do
post :update, id: group.to_param, group: { file_template_project_id: project.id }
end.to change { group.reload.file_template_project_id }.to(project.id)
end
end
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
......
require 'spec_helper' require 'spec_helper'
describe 'Edit group settings' do describe 'Edit group settings' do
include Select2Helper
let(:user) { create(:user) } let(:user) { create(:user) }
let(:developer) { create(:user) } let(:developer) { create(:user) }
let(:group) { create(:group, path: 'foo') } let(:group) { create(:group, path: 'foo') }
...@@ -113,4 +115,56 @@ describe 'Edit group settings' do ...@@ -113,4 +115,56 @@ describe 'Edit group settings' do
end end
end end
end end
describe 'Group file templates setting' do
context 'without a license key' do
before do
stub_licensed_features(custom_file_templates_for_namespace: false)
end
it 'is not visible' do
visit edit_group_path(group)
expect(page).not_to have_content('Select a template repository')
end
end
context 'with a license key' do
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
end
it 'is visible' do
visit edit_group_path(group)
expect(page).to have_content('Select a template repository')
end
it 'allows a project to be selected', :js do
project = create(:project, namespace: group, name: 'known project')
visit edit_group_path(group)
page.within('section#js-templates') do |page|
select2(project.id, from: '#group_file_template_project_id')
click_button 'Save changes'
wait_for_requests
expect(group.reload.checked_file_template_project).to eq(project)
end
end
context 'when current user is not the Owner' do
before do
sign_in(developer)
end
it 'is not visible' do
visit edit_group_path(group)
expect(page).not_to have_content('Select a template repository')
end
end
end
end
end end
require 'spec_helper'
describe 'Project', :js do
let(:template_text) { 'Custom license template content' }
let(:group) { create(:group) }
let(:template_project) { create(:project, :custom_repo, namespace: group, files: { 'LICENSE/custom.txt' => template_text }) }
let(:project) { create(:project, :empty_repo, namespace: group) }
let(:developer) { create(:user) }
describe 'Custom file templates' do
before do
project.add_developer(developer)
gitlab_sign_in(developer)
end
it 'allows file creation from an instance template' do
stub_licensed_features(custom_file_templates: true)
stub_ee_application_setting(file_template_project: template_project)
visit project_new_blob_path(project, 'master', file_name: 'LICENSE.txt')
select_template_type('LICENSE')
select_template('license', 'custom')
wait_for_requests
expect(page).to have_content(template_text)
end
it 'allows file creation from a group template' do
stub_licensed_features(custom_file_templates_for_namespace: true)
group.update_columns(file_template_project_id: template_project.id)
visit project_new_blob_path(project, 'master', file_name: 'LICENSE.txt')
select_template_type('LICENSE')
select_template('license', 'custom')
wait_for_requests
expect(page).to have_content(template_text)
end
end
def select_template_type(template_type)
find('.js-template-type-selector').click
find('.dropdown-content li', text: template_type).click
end
def select_template(type, name)
find(".js-#{type}-selector").click
find('.dropdown-content li', text: name).click
end
end
require 'spec_helper' require 'spec_helper'
describe LicenseTemplateFinder do describe LicenseTemplateFinder do
describe '#execute' do set(:project) { create(:project) }
subject(:result) { described_class.new(nil, params).execute }
let(:params) { {} }
let(:fake_template_source) { double(::Gitlab::CustomFileTemplates) }
let(:custom_template) { ::LicenseTemplate.new(key: 'foo', name: 'foo', category: nil, content: 'Template') }
let(:custom_templates) { [custom_template] }
let(:params) { {} } subject(:finder) { described_class.new(project, params) }
let(:project) { create(:project) } describe '#execute' do
let(:custom) { result.select { |template| template.category == :Custom } } subject(:result) { finder.execute }
before do before do
stub_ee_application_setting(file_template_project: project) expect(Gitlab::CustomFileTemplates)
allow(Gitlab::Template::CustomLicenseTemplate) .to receive(:new)
.with(::Gitlab::Template::CustomLicenseTemplate, project)
.and_return(fake_template_source)
allow(fake_template_source)
.to receive(:find)
.with(custom_template.key)
.and_return(custom_template)
allow(fake_template_source)
.to receive(:all) .to receive(:all)
.with(project) .and_return(custom_templates)
.and_return([OpenStruct.new(name: "custom template")])
end end
context 'custom file templates feature enabled' do context 'custom templates enabled' do
before do before do
stub_licensed_features(custom_file_templates: true) allow(fake_template_source).to receive(:enabled?).and_return(true)
end end
it 'includes custom file templates' do it 'returns custom templates' do
expect(custom.map(&:name)).to contain_exactly("custom template") is_expected.to include(custom_template)
end end
it 'skips custom file templates when only "popular" templates are requested' do context 'popular_only requested' do
params[:popular] = true let(:params) { { popular: true } }
expect(custom).to be_empty it 'does not return any custom templates' do
is_expected.not_to include(custom_template)
end
end
context 'a custom template is specified by name' do
let(:params) { { name: custom_template.key } }
it 'returns the custom template if its name is specified' do
is_expected.to eq(custom_template)
end
end end
end end
context 'custom file templates feature disabled' do context 'custom templates disabled' do
it 'does not include custom file templates' do before do
stub_licensed_features(custom_file_templates: false) allow(fake_template_source).to receive(:enabled?).and_return(false)
end
expect(custom).to be_empty it 'does not return any custom templates' do
is_expected.not_to include(custom_template)
end end
end end
end end
......
...@@ -3,56 +3,67 @@ require 'spec_helper' ...@@ -3,56 +3,67 @@ require 'spec_helper'
describe TemplateFinder do describe TemplateFinder do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
files = { set(:project) { create(:project) }
'Dockerfile/custom_dockerfile.dockerfile' => 'Custom Dockerfile',
'gitignore/custom_gitignore.gitignore' => 'Custom .gitignore',
'gitlab-ci/custom_gitlab_ci.yml' => 'Custom gitlab-ci.yml'
}
set(:project) { create(:project, :custom_repo, files: files) } let(:params) { {} }
describe '#execute' do let(:fake_template_source) { double(::Gitlab::CustomFileTemplates) }
before do let(:custom_template) { OpenStruct.new(key: 'foo', name: 'foo', category: nil, content: 'Template') }
stub_licensed_features(custom_file_templates: true) let(:custom_templates) { [custom_template] }
stub_ee_application_setting(file_template_project: project)
end subject(:finder) { described_class.build(type, project, params) }
where(:type, :custom_name, :vendored_name) do describe '#execute' do
:dockerfiles | 'custom_dockerfile' | 'Binary' where(:type, :expected_template_finder) do
:gitignores | 'custom_gitignore' | 'Actionscript' :dockerfiles | ::Gitlab::Template::CustomDockerfileTemplate
:gitlab_ci_ymls | 'custom_gitlab_ci' | 'Android' :gitignores | ::Gitlab::Template::CustomGitignoreTemplate
:gitlab_ci_ymls | ::Gitlab::Template::CustomGitlabCiYmlTemplate
end end
with_them do with_them do
subject(:result) { described_class.new(type, nil, params).execute } subject(:result) { finder.execute }
context 'specifying name' do before do
let(:params) { { name: custom_name } } expect(Gitlab::CustomFileTemplates)
.to receive(:new)
.with(expected_template_finder, project)
.and_return(fake_template_source)
it { is_expected.to have_attributes(name: custom_name) } allow(fake_template_source)
.to receive(:find)
.with(custom_template.key)
.and_return(custom_template)
context 'feature is disabled' do allow(fake_template_source)
before do .to receive(:all)
stub_licensed_features(custom_file_templates: false) .and_return(custom_templates)
end end
it { is_expected.to be_nil } context 'custom templates enabled' do
before do
allow(fake_template_source).to receive(:enabled?).and_return(true)
end end
end
context 'not specifying name' do it 'returns custom templates' do
let(:params) { {} } is_expected.to include(custom_template)
end
it { is_expected.to include(have_attributes(name: custom_name)) } context 'a custom template is specified by name' do
it { is_expected.to include(have_attributes(name: vendored_name)) } let(:params) { { name: custom_template.key } }
context 'feature is disabled' do it 'returns the custom template if its name is specified' do
before do is_expected.to eq(custom_template)
stub_licensed_features(custom_file_templates: false)
end end
end
end
context 'custom templates disabled' do
before do
allow(fake_template_source).to receive(:enabled?).and_return(false)
end
it { is_expected.not_to include(have_attributes(name: custom_name)) } it 'does not return any custom templates' do
it { is_expected.to include(have_attributes(name: vendored_name)) } is_expected.not_to include(custom_template)
end end
end end
end end
......
...@@ -4,37 +4,59 @@ describe BlobHelper do ...@@ -4,37 +4,59 @@ describe BlobHelper do
include TreeHelper include TreeHelper
describe '#licenses_for_select' do describe '#licenses_for_select' do
subject(:result) { helper.licenses_for_select } let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:group_category) { "Group #{group.full_name}" }
let(:categories) { result.keys } let(:categories) { result.keys }
let(:custom) { result[:Custom] } let(:by_group) { result[group_category] }
let(:popular) { result[:Popular] } let(:by_instance) { result['Instance'] }
let(:other) { result[:Other] } let(:by_popular) { result[:Popular] }
let(:by_other) { result[:Other] }
let(:project) { create(:project) } subject(:result) { helper.licenses_for_select(project) }
it 'returns Custom licenses when enabled' do before do
stub_licensed_features(custom_file_templates: true)
stub_ee_application_setting(file_template_project: project) stub_ee_application_setting(file_template_project: project)
group.update_columns(file_template_project_id: project.id)
end
it 'returns Group licenses when enabled' do
stub_licensed_features(custom_file_templates: false, custom_file_templates_for_namespace: true)
expect(Gitlab::Template::CustomLicenseTemplate)
.to receive(:all)
.with(project)
.and_return([OpenStruct.new(key: 'name', name: 'Name')])
expect(categories).to contain_exactly(:Popular, :Other, group_category)
expect(by_group).to contain_exactly({ id: 'name', name: 'Name' })
expect(by_popular).to be_present
expect(by_other).to be_present
end
it 'returns Instance licenses when enabled' do
stub_licensed_features(custom_file_templates: true, custom_file_templates_for_namespace: false)
expect(Gitlab::Template::CustomLicenseTemplate) expect(Gitlab::Template::CustomLicenseTemplate)
.to receive(:all) .to receive(:all)
.with(project) .with(project)
.and_return([OpenStruct.new(key: 'name', name: 'Name')]) .and_return([OpenStruct.new(key: 'name', name: 'Name')])
expect(categories).to contain_exactly(:Popular, :Other, :Custom) expect(categories).to contain_exactly(:Popular, :Other, 'Instance')
expect(custom).to contain_exactly({ id: 'name', name: 'Name' }) expect(by_instance).to contain_exactly({ id: 'name', name: 'Name' })
expect(popular).to be_present expect(by_popular).to be_present
expect(other).to be_present expect(by_other).to be_present
end end
it 'returns no Custom licenses when disabled' do it 'returns no Group or Instance licenses when disabled' do
stub_licensed_features(custom_file_templates: false) stub_licensed_features(custom_file_templates: false, custom_file_templates_for_namespace: false)
expect(categories).to contain_exactly(:Popular, :Other) expect(categories).to contain_exactly(:Popular, :Other)
expect(custom).to be_nil expect(by_group).to be_nil
expect(popular).to be_present expect(by_instance).to be_nil
expect(other).to be_present expect(by_popular).to be_present
expect(by_other).to be_present
end end
end end
end end
require 'spec_helper'
describe Gitlab::CustomFileTemplates do
using RSpec::Parameterized::TableSyntax
set(:instance_template_project) { create(:project, :custom_repo, files: template_files('instance')) }
set(:group) { create(:group) }
set(:project) { create(:project, namespace: group) }
set(:group_template_project) { create(:project, :custom_repo, namespace: group, files: template_files('group')) }
subject(:templates) { described_class.new(template_finder, target_project) }
def template_files(prefix)
{
"Dockerfile/#{prefix}_dockerfile.dockerfile" => "#{prefix}_dockerfile content",
"gitignore/#{prefix}_gitignore.gitignore" => "#{prefix}_gitignore content",
"gitlab-ci/#{prefix}_gitlab_ci_yml.yml" => "#{prefix}_gitlab_ci_yml content",
"LICENSE/#{prefix}_license.txt" => "#{prefix}_license content"
}
end
describe '#enabled?' do
where(
instance_licensed: [false, true],
namespace_licensed: [false, true],
instance_enabled: [false, true],
namespace_enabled: [false, true]
)
with_them do
let(:target_project) { project }
let(:template_finder) { double('template-finder') }
let(:expected_result) { (instance_licensed && instance_enabled) || (namespace_licensed && namespace_enabled) }
subject { templates.enabled? }
before do
stub_licensed_features(
custom_file_templates: instance_licensed,
custom_file_templates_for_namespace: namespace_licensed
)
stub_ee_application_setting(file_template_project: instance_template_project) if instance_enabled
group.update_columns(file_template_project_id: group_template_project.id) if namespace_enabled
end
it { is_expected.to eq(expected_result) }
end
end
describe '#all' do
where(:template_finder, :type) do
Gitlab::Template::CustomDockerfileTemplate | :dockerfile
Gitlab::Template::CustomGitignoreTemplate | :gitignore
Gitlab::Template::CustomGitlabCiYmlTemplate | :gitlab_ci_yml
Gitlab::Template::CustomLicenseTemplate | :license
end
with_them do
subject(:result) { templates.all }
before do
stub_ee_application_setting(file_template_project: instance_template_project)
group.update_columns(file_template_project_id: group_template_project.id)
end
context 'unlicensed' do
let(:target_project) { project }
it { expect(result).to be_empty }
end
context 'licensed' do
before do
stub_licensed_features(custom_file_templates: true, custom_file_templates_for_namespace: true)
end
context 'in a toplevel group' do
let(:target_project) { project }
it 'orders results from most specific to least specific' do
expect(result.map(&:key)).to eq(["group_#{type}", "instance_#{type}"])
end
end
context 'in a subgroup', :nested_groups do
set(:subgroup) { create(:group, parent: group) }
set(:subproject) { create(:project, namespace: subgroup) }
set(:subgroup_template_project) { create(:project, :custom_repo, namespace: subgroup, files: template_files('subgroup')) }
let(:target_project) { subproject }
before do
subgroup.update_columns(file_template_project_id: subgroup_template_project.id)
end
it 'orders results from most specific to least specific' do
expect(result.map(&:key)).to eq(["subgroup_#{type}", "group_#{type}", "instance_#{type}"])
end
end
end
end
end
describe '#find' do
def be_template(key, category)
have_attributes(key: key, name: key, category: category, content: "#{key} content")
end
where(:template_finder, :type) do
Gitlab::Template::CustomDockerfileTemplate | :dockerfile
Gitlab::Template::CustomGitignoreTemplate | :gitignore
Gitlab::Template::CustomGitlabCiYmlTemplate | :gitlab_ci_yml
Gitlab::Template::CustomLicenseTemplate | :license
end
with_them do
let(:group_key) { "group_#{type}" }
let(:instance_key) { "instance_#{type}" }
before do
stub_ee_application_setting(file_template_project: instance_template_project)
group.update_columns(file_template_project_id: group_template_project.id)
end
context 'unlicensed' do
let(:target_project) { project }
it { expect(templates.find(group_key)).to be_nil }
it { expect(templates.find(instance_key)).to be_nil }
end
context 'licensed' do
before do
stub_licensed_features(custom_file_templates: true, custom_file_templates_for_namespace: true)
end
context 'in a toplevel group' do
let(:target_project) { project }
it 'finds a group template' do
expect(templates.find(group_key)).to be_template(group_key, "Group #{group.full_name}")
end
it 'finds an instance template' do
expect(templates.find(instance_key)).to be_template(instance_key, 'Instance')
end
it 'returns nil for an unknown key' do
expect(templates.find('unknown')).to be_nil
end
end
context 'in a subgroup', :nested_groups do
let(:subgroup) { create(:group, parent: group) }
let(:subproject) { create(:project, namespace: subgroup) }
let(:subgroup_template_project) { create(:project, :custom_repo, namespace: subgroup, files: template_files('subgroup')) }
let(:target_project) { subproject }
let(:subgroup_key) { "subgroup_#{type}" }
before do
subgroup.update!(file_template_project: subgroup_template_project)
end
it 'finds a template from the subgroup' do
expect(templates.find(subgroup_key)).to be_template(subgroup_key, "Group #{subgroup.full_name}")
end
it 'finds a template from the parent group' do
expect(templates.find(group_key)).to be_template(group_key, "Group #{group.full_name}")
end
it 'finds an instance template' do
expect(templates.find(instance_key)).to be_template(instance_key, 'Instance')
end
it 'returns nil for an unknown key' do
expect(templates.find('unknown')).to be_nil
end
end
end
end
end
end
...@@ -7,6 +7,33 @@ describe Group do ...@@ -7,6 +7,33 @@ describe Group do
describe 'associations' do describe 'associations' do
it { is_expected.to have_many(:audit_events).dependent(false) } it { is_expected.to have_many(:audit_events).dependent(false) }
it { is_expected.to belong_to(:file_template_project) }
end
describe 'scopes' do
describe '.with_custom_file_templates' do
let!(:excluded_group) { create(:group) }
let(:included_group) { create(:group) }
let(:project) { create(:project, namespace: included_group) }
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
included_group.update!(file_template_project: project)
end
subject(:relation) { described_class.with_custom_file_templates }
it { is_expected.to contain_exactly(included_group) }
it 'preloads everything needed to show a valid checked_file_template_project' do
group = relation.first
expect { group.checked_file_template_project }.not_to exceed_query_limit(0)
expect(group.checked_file_template_project).to be_present
end
end
end end
describe 'states' do describe 'states' do
...@@ -128,4 +155,108 @@ describe Group do ...@@ -128,4 +155,108 @@ describe Group do
expect(group.project_creation_level).to eq(Gitlab::CurrentSettings.default_project_creation) expect(group.project_creation_level).to eq(Gitlab::CurrentSettings.default_project_creation)
end end
end end
describe '#file_template_project' do
it { expect(group.private_methods).to include(:file_template_project) }
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
end
it { expect(group.private_methods).to include(:file_template_project) }
context 'validation' do
let(:project) { create(:project, namespace: group) }
it 'is cleared if invalid' do
invalid_project = create(:project)
group.file_template_project_id = invalid_project.id
expect(group).to be_valid
expect(group.file_template_project_id).to be_nil
end
it 'is permitted if valid' do
valid_project = create(:project, namespace: group)
group.file_template_project_id = valid_project.id
expect(group).to be_valid
expect(group.file_template_project_id).to eq(valid_project.id)
end
end
end
describe '#checked_file_template_project' do
let(:valid_project) { create(:project, namespace: group) }
subject { group.checked_file_template_project }
context 'licensed' do
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
end
it 'returns nil for an invalid project' do
group.file_template_project = create(:project)
is_expected.to be_nil
end
it 'returns a valid project' do
group.file_template_project = valid_project
is_expected.to eq(valid_project)
end
end
context 'unlicensed' do
before do
stub_licensed_features(custom_file_templates_for_namespace: false)
end
it 'returns nil for a valid project' do
group.file_template_project = valid_project
is_expected.to be_nil
end
end
end
describe '#checked_file_template_project_id' do
let(:valid_project) { create(:project, namespace: group) }
subject { group.checked_file_template_project_id }
context 'licensed' do
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
end
it 'returns nil for an invalid project' do
group.file_template_project = create(:project)
is_expected.to be_nil
end
it 'returns the ID for a valid project' do
group.file_template_project = valid_project
is_expected.to eq(valid_project.id)
end
context 'unlicensed' do
before do
stub_licensed_features(custom_file_templates_for_namespace: false)
end
it 'returns nil for a valid project' do
group.file_template_project = valid_project
is_expected.to be_nil
end
end
end
end
end end
...@@ -569,4 +569,31 @@ describe Namespace do ...@@ -569,4 +569,31 @@ describe Namespace do
end end
end end
end end
describe '#file_template_project_id' do
it 'is cleared before validation' do
project = create(:project, namespace: namespace)
namespace.file_template_project_id = project.id
expect(namespace).to be_valid
expect(namespace.file_template_project_id).to be_nil
end
end
describe '#checked_file_template_project' do
it 'is always nil' do
namespace.file_template_project_id = create(:project, namespace: namespace).id
expect(namespace.checked_file_template_project).to be_nil
end
end
describe '#checked_file_template_project_id' do
it 'is always nil' do
namespace.file_template_project_id = create(:project, namespace: namespace).id
expect(namespace.checked_file_template_project_id).to be_nil
end
end
end end
require 'spec_helper'
describe API::Groups do
set(:group) { create(:group) }
set(:project) { create(:project, group: group) }
set(:user) { create(:user) }
describe 'PUT /groups/:id' do
before do
group.add_owner(user)
end
subject(:do_it) { put api("/groups/#{group.id}", user), file_template_project_id: project.id }
it 'does not update file_template_project_id if unlicensed' do
stub_licensed_features(custom_file_templates_for_namespace: false)
expect { do_it }.not_to change { group.reload.file_template_project_id }
expect(response).to have_gitlab_http_status(200)
expect(json_response).not_to have_key('file_template_project_id')
end
it 'updates file_template_project_id if licensed' do
stub_licensed_features(custom_file_templates_for_namespace: true)
expect { do_it }.to change { group.reload.file_template_project_id }.to(project.id)
expect(response).to have_gitlab_http_status(200)
expect(json_response['file_template_project_id']).to eq(project.id)
end
end
end
...@@ -45,6 +45,91 @@ describe Groups::UpdateService, '#execute' do ...@@ -45,6 +45,91 @@ describe Groups::UpdateService, '#execute' do
end end
end end
describe 'changing file_template_project_id' do
let(:group) { create(:group) }
let(:valid_project) { create(:project, namespace: group) }
let(:user) { create(:user) }
def update_file_template_project_id(id)
update_group(group, user, file_template_project_id: id)
end
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
end
context 'as a group maintainer' do
before do
group.add_maintainer(user)
end
it 'does not allow a project to be removed' do
group.update_columns(file_template_project_id: valid_project.id)
expect(update_file_template_project_id(nil)).to be_falsy
expect(group.errors[:file_template_project_id]).to include('cannot be changed by you')
end
it 'does not allow a project to be set' do
expect(update_file_template_project_id(valid_project.id)).to be_falsy
expect(group.errors[:file_template_project_id]).to include('cannot be changed by you')
end
end
context 'as a group owner' do
before do
group.add_owner(user)
end
it 'allows a project to be removed' do
group.update_columns(file_template_project_id: valid_project.id)
expect(update_file_template_project_id(nil)).to be_truthy
expect(group.reload.file_template_project_id).to be_nil
end
it 'allows a valid project to be set' do
expect(update_file_template_project_id(valid_project.id)).to be_truthy
expect(group.reload.file_template_project_id).to eq(valid_project.id)
end
it 'does not allow a project outwith the group to be set' do
invalid_project = create(:project)
expect(update_file_template_project_id(invalid_project.id)).to be_falsy
expect(group.errors[:file_template_project_id]).to include('is invalid')
end
it 'does not allow a non-existent project to be set' do
invalid_project = create(:project)
invalid_project.destroy!
expect(update_file_template_project_id(invalid_project.id)).to be_falsy
expect(group.errors[:file_template_project_id]).to include('is invalid')
end
context 'in a subgroup', :nested_groups do
let(:parent_group) { create(:group) }
let(:hidden_project) { create(:project, :private, namespace: parent_group) }
let(:group) { create(:group, parent: parent_group) }
before do
group.update!(parent: parent_group)
end
it 'does not allow a project the group owner cannot see to be set' do
expect(update_file_template_project_id(hidden_project.id)).to be_falsy
expect(group.reload.file_template_project_id).to be_nil
end
it 'allows a project in the subgroup to be set' do
expect(update_file_template_project_id(valid_project.id)).to be_truthy
expect(group.reload.file_template_project_id).to eq(valid_project.id)
end
end
end
end
def update_group(group, user, opts) def update_group(group, user, opts)
Groups::UpdateService.new(group, user, opts).execute Groups::UpdateService.new(group, user, opts).execute
end end
......
...@@ -165,18 +165,25 @@ module API ...@@ -165,18 +165,25 @@ module API
optional :name, type: String, desc: 'The name of the group' optional :name, type: String, desc: 'The name of the group'
optional :path, type: String, desc: 'The path of the group' optional :path, type: String, desc: 'The path of the group'
use :optional_params use :optional_params
# EE
optional :file_template_project_id, type: Integer, desc: 'The ID of a project to use for custom templates in this group'
end end
put ':id' do put ':id' do
group = find_group!(params[:id]) group = find_group!(params[:id])
authorize! :admin_group, group authorize! :admin_group, group
# EE # Begin EE-specific block
if params[:shared_runners_minutes_limit].present? && if params[:shared_runners_minutes_limit].present? &&
group.shared_runners_minutes_limit.to_i != group.shared_runners_minutes_limit.to_i !=
params[:shared_runners_minutes_limit].to_i params[:shared_runners_minutes_limit].to_i
authenticated_as_admin! authenticated_as_admin!
end end
params.delete(:file_template_project_id) unless
group.feature_available?(:custom_file_templates_for_namespace)
# End EE-specific block
if ::Groups::UpdateService.new(group, current_user, declared_params(include_missing: false)).execute if ::Groups::UpdateService.new(group, current_user, declared_params(include_missing: false)).execute
present group, with: Entities::GroupDetail, current_user: current_user present group, with: Entities::GroupDetail, current_user: current_user
else else
......
module Gitlab module Gitlab
module Template module Template
class BaseTemplate class BaseTemplate
attr_reader :category attr_accessor :category
def initialize(path, project = nil, category: nil) def initialize(path, project = nil, category: nil)
@path = path @path = path
......
...@@ -6892,6 +6892,9 @@ msgstr "" ...@@ -6892,6 +6892,9 @@ msgstr ""
msgid "Search project" msgid "Search project"
msgstr "" msgstr ""
msgid "Search projects"
msgstr ""
msgid "Search users" msgid "Search users"
msgstr "" msgstr ""
...@@ -6961,6 +6964,9 @@ msgstr "" ...@@ -6961,6 +6964,9 @@ msgstr ""
msgid "Select a namespace to fork the project" msgid "Select a namespace to fork the project"
msgstr "" msgstr ""
msgid "Select a template repository"
msgstr ""
msgid "Select a timezone" msgid "Select a timezone"
msgstr "" msgstr ""
...@@ -7033,6 +7039,9 @@ msgstr "" ...@@ -7033,6 +7039,9 @@ msgstr ""
msgid "Set a password on your account to pull or push via %{protocol}." msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "" msgstr ""
msgid "Set a template repository for projects in this group"
msgstr ""
msgid "Set default and restrict visibility levels. Configure import sources and git access protocol." msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
msgstr "" msgstr ""
......
...@@ -10,6 +10,9 @@ describe ApplicationSettings::UpdateService do ...@@ -10,6 +10,9 @@ describe ApplicationSettings::UpdateService do
before do before do
# So the caching behaves like it would in production # So the caching behaves like it would in production
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
# Creating these settings first ensures they're used by other factories
application_settings
end end
describe 'updating terms' do describe 'updating terms' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment