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';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import { GROUP_BADGE } from '~/badges/constants';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
groupAvatar();
......@@ -15,4 +16,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
);
mountBadgeSettings(GROUP_BADGE);
projectSelect();
});
......@@ -183,23 +183,23 @@ module BlobHelper
end
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)
end
def gitignore_names(project = @project)
def gitignore_names(project)
@gitignore_names ||= template_dropdown_names(TemplateFinder.build(:gitignores, project).execute)
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)
end
def dockerfile_names(project = @project)
def dockerfile_names(project)
@dockerfile_names ||= template_dropdown_names(TemplateFinder.build(:dockerfiles, project).execute)
end
def blob_editor_paths(project = @project)
def blob_editor_paths(project)
{
'relative-url-root' => Rails.application.config.relative_url_root,
'assets-prefix' => Gitlab::Application.config.assets.prefix,
......
......@@ -37,6 +37,8 @@
.settings-content
= 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) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
......
......@@ -5,13 +5,13 @@
.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" } )
.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
= 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
= 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
= 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
%span.text-info Template applied
%button.btn.btn-sm.btn-info Undo
......@@ -24,7 +24,7 @@
= link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do
= 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 'shared/new_commit_form', placeholder: "Update #{@blob.name}"
= hidden_field_tag 'last_commit_sha', @last_commit_sha
......
......@@ -7,7 +7,7 @@
New file
= render 'template_selectors'
.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 'shared/new_commit_form', placeholder: "Add new file"
......
......@@ -1815,6 +1815,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do
t.integer "project_creation_level"
t.string "runners_token"
t.datetime_with_timezone "trial_ends_on"
t.integer "file_template_project_id"
end
add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree
......@@ -3265,6 +3266,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do
add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", 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", "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 "notes", "projects", name: "fk_99e097b079", on_delete: :cascade
add_foreign_key "notification_settings", "users", name: "fk_0c95e91db7", on_delete: :cascade
......
......@@ -37,6 +37,7 @@ GET /groups
"request_access_enabled": false,
"full_name": "Foobar Group",
"full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null
}
]
......@@ -62,6 +63,7 @@ GET /groups?statistics=true
"request_access_enabled": false,
"full_name": "Foobar Group",
"full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null,
"statistics": {
"storage_size" : 212,
......@@ -122,6 +124,7 @@ GET /groups/:id/subgroups
"request_access_enabled": false,
"full_name": "Foobar Group",
"full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": 123
}
]
......@@ -232,6 +235,7 @@ Example response:
"request_access_enabled": false,
"full_name": "Twitter",
"full_path": "twitter",
"file_template_project_id": 1,
"parent_id": null,
"shared_runners_minutes_limit": 133,
"projects": [
......@@ -387,6 +391,7 @@ Example response:
"request_access_enabled": false,
"full_name": "Twitter",
"full_path": "twitter",
"file_template_project_id": 1,
"parent_id": null
}
```
......@@ -446,6 +451,7 @@ PUT /groups/:id
| `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 |
| `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 |
```bash
......@@ -467,6 +473,7 @@ Example response:
"request_access_enabled": false,
"full_name": "Foobar Group",
"full_path": "foo-bar",
"file_template_project_id": 1,
"parent_id": null,
"projects": [
{
......
# 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)
- [Gitignore templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.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
is not limited to:
In addition to templates common to the entire instance, project-specific
templates are also available from this API endpoint.
- [Issue and Merge Request templates](../user/project/description_templates.html)
- [Group level file templates](https://gitlab.com/gitlab-org/gitlab-ee/issues/5987) **(Premium)**
Support will be added for [Issue and Merge Request templates](../user/project/description_templates.md)
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
......
......@@ -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
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
- **Projects**: view all projects within that group, add members to each project,
......
......@@ -14,6 +14,7 @@ module EE
:repository_size_limit
].tap do |params_ee|
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
......
......@@ -2,65 +2,31 @@ module EE
module LicenseTemplateFinder
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
def execute
return super unless custom_templates?
if params[:name]
custom_template || super
custom_templates.find(params[:name]) || super
else
custom_templates + super
custom_templates.all + super
end
end
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?
!popular_only? &&
::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 }
)
!popular_only? && custom_templates.enabled?
end
end
end
......@@ -11,41 +11,22 @@ module EE
attr_reader :custom_templates
private :custom_templates
def initialize(type, *args, &blk)
def initialize(type, project, *args, &blk)
super
@custom_templates = CUSTOM_TEMPLATES.fetch(type)
finder = CUSTOM_TEMPLATES.fetch(type)
@custom_templates = ::Gitlab::CustomFileTemplates.new(finder, project)
end
override :execute
def execute
return super unless custom_templates?
return super unless custom_templates.enabled?
if params[:name]
find_custom_template || super
custom_templates.find(params[:name]) || super
else
find_custom_templates + super
custom_templates.all + super
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
......@@ -19,6 +19,12 @@ module EE
# 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'
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,
numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
......@@ -26,6 +32,14 @@ module EE
joins(:ldap_group_links).where(ldap_group_links: { provider: provider })
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 :ready
state :started
......@@ -115,5 +129,23 @@ module EE
def first_non_empty_project
projects.detect { |project| !project.empty_repo? }
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
......@@ -35,10 +35,16 @@ module EE
delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset,
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_shared_runner_minutes_support
before_create :sync_membership_lock_with_parent
# Changing the plan or other details may invalidate this cache
before_save :clear_feature_available_cache
end
class_methods do
......@@ -200,6 +206,15 @@ module EE
actual_plan_name == FREE_PLAN
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
def validate_plan_name
......@@ -216,6 +231,10 @@ module EE
end
end
def clear_feature_available_cache
clear_memoization(:feature_available)
end
def load_feature_available(feature)
globally_available = License.feature_available?(feature)
......@@ -225,5 +244,12 @@ module EE
globally_available
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
......@@ -41,6 +41,7 @@ class License < ActiveRecord::Base
board_milestone_lists
cross_project_pipelines
custom_file_templates
custom_file_templates_for_namespace
email_additional_text
db_load_balancing
deploy_board
......
......@@ -5,11 +5,44 @@ module EE
override :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 }
end
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
EE::Audit::GroupChangesAuditor.new(current_user, group).execute
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
prepended do
expose :ldap_cn, :ldap_access
expose :ldap_group_links,
using: EE::API::Entities::LdapGroupLink,
if: ->(group, options) { group.ldap_group_links.any? }
using: EE::API::Entities::LdapGroupLink,
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
......
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
put :update, id: group.to_param, group: { name: 'world' }
end.to change { group.reload.name }
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
describe 'DELETE #destroy' do
......
require 'spec_helper'
describe 'Edit group settings' do
include Select2Helper
let(:user) { create(:user) }
let(:developer) { create(:user) }
let(:group) { create(:group, path: 'foo') }
......@@ -113,4 +115,56 @@ describe 'Edit group settings' do
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
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'
describe LicenseTemplateFinder do
describe '#execute' do
subject(:result) { described_class.new(nil, params).execute }
set(:project) { create(:project) }
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) }
let(:custom) { result.select { |template| template.category == :Custom } }
describe '#execute' do
subject(:result) { finder.execute }
before do
stub_ee_application_setting(file_template_project: project)
allow(Gitlab::Template::CustomLicenseTemplate)
expect(Gitlab::CustomFileTemplates)
.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)
.with(project)
.and_return([OpenStruct.new(name: "custom template")])
.and_return(custom_templates)
end
context 'custom file templates feature enabled' do
context 'custom templates enabled' do
before do
stub_licensed_features(custom_file_templates: true)
allow(fake_template_source).to receive(:enabled?).and_return(true)
end
it 'includes custom file templates' do
expect(custom.map(&:name)).to contain_exactly("custom template")
it 'returns custom templates' do
is_expected.to include(custom_template)
end
it 'skips custom file templates when only "popular" templates are requested' do
params[:popular] = true
context 'popular_only requested' do
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
context 'custom file templates feature disabled' do
it 'does not include custom file templates' do
stub_licensed_features(custom_file_templates: false)
context 'custom templates disabled' do
before do
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
......
......@@ -3,56 +3,67 @@ require 'spec_helper'
describe TemplateFinder do
using RSpec::Parameterized::TableSyntax
files = {
'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) }
set(:project) { create(:project, :custom_repo, files: files) }
let(:params) { {} }
describe '#execute' do
before do
stub_licensed_features(custom_file_templates: true)
stub_ee_application_setting(file_template_project: project)
end
let(:fake_template_source) { double(::Gitlab::CustomFileTemplates) }
let(:custom_template) { OpenStruct.new(key: 'foo', name: 'foo', category: nil, content: 'Template') }
let(:custom_templates) { [custom_template] }
subject(:finder) { described_class.build(type, project, params) }
where(:type, :custom_name, :vendored_name) do
:dockerfiles | 'custom_dockerfile' | 'Binary'
:gitignores | 'custom_gitignore' | 'Actionscript'
:gitlab_ci_ymls | 'custom_gitlab_ci' | 'Android'
describe '#execute' do
where(:type, :expected_template_finder) do
:dockerfiles | ::Gitlab::Template::CustomDockerfileTemplate
:gitignores | ::Gitlab::Template::CustomGitignoreTemplate
:gitlab_ci_ymls | ::Gitlab::Template::CustomGitlabCiYmlTemplate
end
with_them do
subject(:result) { described_class.new(type, nil, params).execute }
subject(:result) { finder.execute }
context 'specifying name' do
let(:params) { { name: custom_name } }
before do
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
before do
stub_licensed_features(custom_file_templates: false)
end
allow(fake_template_source)
.to receive(:all)
.and_return(custom_templates)
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
context 'not specifying name' do
let(:params) { {} }
it 'returns custom templates' do
is_expected.to include(custom_template)
end
it { is_expected.to include(have_attributes(name: custom_name)) }
it { is_expected.to include(have_attributes(name: vendored_name)) }
context 'a custom template is specified by name' do
let(:params) { { name: custom_template.key } }
context 'feature is disabled' do
before do
stub_licensed_features(custom_file_templates: false)
it 'returns the custom template if its name is specified' do
is_expected.to eq(custom_template)
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 { is_expected.to include(have_attributes(name: vendored_name)) }
it 'does not return any custom templates' do
is_expected.not_to include(custom_template)
end
end
end
......
......@@ -4,37 +4,59 @@ describe BlobHelper do
include TreeHelper
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(:custom) { result[:Custom] }
let(:popular) { result[:Popular] }
let(:other) { result[:Other] }
let(:by_group) { result[group_category] }
let(:by_instance) { result['Instance'] }
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
stub_licensed_features(custom_file_templates: true)
before do
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)
.to receive(:all)
.with(project)
.and_return([OpenStruct.new(key: 'name', name: 'Name')])
expect(categories).to contain_exactly(:Popular, :Other, :Custom)
expect(custom).to contain_exactly({ id: 'name', name: 'Name' })
expect(popular).to be_present
expect(other).to be_present
expect(categories).to contain_exactly(:Popular, :Other, 'Instance')
expect(by_instance).to contain_exactly({ id: 'name', name: 'Name' })
expect(by_popular).to be_present
expect(by_other).to be_present
end
it 'returns no Custom licenses when disabled' do
stub_licensed_features(custom_file_templates: false)
it 'returns no Group or Instance licenses when disabled' do
stub_licensed_features(custom_file_templates: false, custom_file_templates_for_namespace: false)
expect(categories).to contain_exactly(:Popular, :Other)
expect(custom).to be_nil
expect(popular).to be_present
expect(other).to be_present
expect(by_group).to be_nil
expect(by_instance).to be_nil
expect(by_popular).to be_present
expect(by_other).to be_present
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
describe 'associations' do
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
describe 'states' do
......@@ -128,4 +155,108 @@ describe Group do
expect(group.project_creation_level).to eq(Gitlab::CurrentSettings.default_project_creation)
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
......@@ -569,4 +569,31 @@ describe Namespace do
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
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
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)
Groups::UpdateService.new(group, user, opts).execute
end
......
......@@ -165,18 +165,25 @@ module API
optional :name, type: String, desc: 'The name of the group'
optional :path, type: String, desc: 'The path of the group'
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
put ':id' do
group = find_group!(params[:id])
authorize! :admin_group, group
# EE
# Begin EE-specific block
if params[:shared_runners_minutes_limit].present? &&
group.shared_runners_minutes_limit.to_i !=
params[:shared_runners_minutes_limit].to_i
authenticated_as_admin!
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
present group, with: Entities::GroupDetail, current_user: current_user
else
......
module Gitlab
module Template
class BaseTemplate
attr_reader :category
attr_accessor :category
def initialize(path, project = nil, category: nil)
@path = path
......
......@@ -6892,6 +6892,9 @@ msgstr ""
msgid "Search project"
msgstr ""
msgid "Search projects"
msgstr ""
msgid "Search users"
msgstr ""
......@@ -6961,6 +6964,9 @@ msgstr ""
msgid "Select a namespace to fork the project"
msgstr ""
msgid "Select a template repository"
msgstr ""
msgid "Select a timezone"
msgstr ""
......@@ -7033,6 +7039,9 @@ msgstr ""
msgid "Set a password on your account to pull or push via %{protocol}."
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."
msgstr ""
......
......@@ -10,6 +10,9 @@ describe ApplicationSettings::UpdateService do
before do
# So the caching behaves like it would in production
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
# Creating these settings first ensures they're used by other factories
application_settings
end
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