Commit f7e08ad0 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'issue_18656' into 'master'

Load issue templates from repository

part of #18656 


## Does this MR meet the acceptance criteria?

- [x] [CHANGELOG](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CHANGELOG) entry added
- [x] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)
- [x] API support added
- Tests
  - [x] Added for this feature/bug
  - [x] All builds are passing
- [x] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides)
- [x] Branch has no merge conflicts with `master` (if you do - rebase it please)
- [x] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits)

See merge request !4981
parents 7edc37d1 28726729
...@@ -40,6 +40,7 @@ v 8.11.0 (unreleased) ...@@ -40,6 +40,7 @@ v 8.11.0 (unreleased)
- Various redundant database indexes have been removed - Various redundant database indexes have been removed
- Update `timeago` plugin to use multiple string/locale settings - Update `timeago` plugin to use multiple string/locale settings
- Remove unused images (ClemMakesApps) - Remove unused images (ClemMakesApps)
- Get issue and merge request description templates from repositories
- Limit git rev-list output count to one in forced push check - Limit git rev-list output count to one in forced push check
- Show deployment status on merge requests with external URLs - Show deployment status on merge requests with external URLs
- Clean up unused routes (Josef Strzibny) - Clean up unused routes (Josef Strzibny)
......
...@@ -9,10 +9,11 @@ ...@@ -9,10 +9,11 @@
licensePath: "/api/:version/licenses/:key", licensePath: "/api/:version/licenses/:key",
gitignorePath: "/api/:version/gitignores/:key", gitignorePath: "/api/:version/gitignores/:key",
gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key",
issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
group: function(group_id, callback) { group: function(group_id, callback) {
var url; var url = Api.buildUrl(Api.groupPath)
url = Api.buildUrl(Api.groupPath); .replace(':id', group_id);
url = url.replace(':id', group_id);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -24,8 +25,7 @@ ...@@ -24,8 +25,7 @@
}); });
}, },
groups: function(query, skip_ldap, callback) { groups: function(query, skip_ldap, callback) {
var url; var url = Api.buildUrl(Api.groupsPath);
url = Api.buildUrl(Api.groupsPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -39,8 +39,7 @@ ...@@ -39,8 +39,7 @@
}); });
}, },
namespaces: function(query, callback) { namespaces: function(query, callback) {
var url; var url = Api.buildUrl(Api.namespacesPath);
url = Api.buildUrl(Api.namespacesPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -54,8 +53,7 @@ ...@@ -54,8 +53,7 @@
}); });
}, },
projects: function(query, order, callback) { projects: function(query, order, callback) {
var url; var url = Api.buildUrl(Api.projectsPath);
url = Api.buildUrl(Api.projectsPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -70,9 +68,8 @@ ...@@ -70,9 +68,8 @@
}); });
}, },
newLabel: function(project_id, data, callback) { newLabel: function(project_id, data, callback) {
var url; var url = Api.buildUrl(Api.labelsPath)
url = Api.buildUrl(Api.labelsPath); .replace(':id', project_id);
url = url.replace(':id', project_id);
data.private_token = gon.api_token; data.private_token = gon.api_token;
return $.ajax({ return $.ajax({
url: url, url: url,
...@@ -86,9 +83,8 @@ ...@@ -86,9 +83,8 @@
}); });
}, },
groupProjects: function(group_id, query, callback) { groupProjects: function(group_id, query, callback) {
var url; var url = Api.buildUrl(Api.groupProjectsPath)
url = Api.buildUrl(Api.groupProjectsPath); .replace(':id', group_id);
url = url.replace(':id', group_id);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -102,8 +98,8 @@ ...@@ -102,8 +98,8 @@
}); });
}, },
licenseText: function(key, data, callback) { licenseText: function(key, data, callback) {
var url; var url = Api.buildUrl(Api.licensePath)
url = Api.buildUrl(Api.licensePath).replace(':key', key); .replace(':key', key);
return $.ajax({ return $.ajax({
url: url, url: url,
data: data data: data
...@@ -112,19 +108,32 @@ ...@@ -112,19 +108,32 @@
}); });
}, },
gitignoreText: function(key, callback) { gitignoreText: function(key, callback) {
var url; var url = Api.buildUrl(Api.gitignorePath)
url = Api.buildUrl(Api.gitignorePath).replace(':key', key); .replace(':key', key);
return $.get(url, function(gitignore) { return $.get(url, function(gitignore) {
return callback(gitignore); return callback(gitignore);
}); });
}, },
gitlabCiYml: function(key, callback) { gitlabCiYml: function(key, callback) {
var url; var url = Api.buildUrl(Api.gitlabCiYmlPath)
url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); .replace(':key', key);
return $.get(url, function(file) { return $.get(url, function(file) {
return callback(file); return callback(file);
}); });
}, },
issueTemplate: function(namespacePath, projectPath, key, type, callback) {
var url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key)
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
$.ajax({
url: url,
dataType: 'json'
}).done(function(file) {
callback(null, file);
}).error(callback);
},
buildUrl: function(url) { buildUrl: function(url) {
if (gon.relative_url_root != null) { if (gon.relative_url_root != null) {
url = gon.relative_url_root + url; url = gon.relative_url_root + url;
......
...@@ -41,6 +41,7 @@ ...@@ -41,6 +41,7 @@
/*= require date.format */ /*= require date.format */
/*= require_directory ./behaviors */ /*= require_directory ./behaviors */
/*= require_directory ./blob */ /*= require_directory ./blob */
/*= require_directory ./templates */
/*= require_directory ./commit */ /*= require_directory ./commit */
/*= require_directory ./extensions */ /*= require_directory ./extensions */
/*= require_directory ./lib/utils */ /*= require_directory ./lib/utils */
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
} }
this.onClick = bind(this.onClick, this); this.onClick = bind(this.onClick, this);
this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name');
this.dropdownIcon = $('.fa-chevron-down', this.dropdown);
this.buildDropdown(); this.buildDropdown();
this.bindEvents(); this.bindEvents();
this.onFilenameUpdate(); this.onFilenameUpdate();
...@@ -60,11 +61,26 @@ ...@@ -60,11 +61,26 @@
return this.requestFile(item); return this.requestFile(item);
}; };
TemplateSelector.prototype.requestFile = function(item) {}; TemplateSelector.prototype.requestFile = function(item) {
// This `requestFile` method is an abstract method that should
// be added by all subclasses.
};
TemplateSelector.prototype.requestFileSuccess = function(file) { TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) {
this.editor.setValue(file.content, 1); this.editor.setValue(file.content, 1);
return this.editor.focus(); if (!skipFocus) this.editor.focus();
};
TemplateSelector.prototype.startLoadingSpinner = function() {
this.dropdownIcon
.addClass('fa-spinner fa-spin')
.removeClass('fa-chevron-down');
};
TemplateSelector.prototype.stopLoadingSpinner = function() {
this.dropdownIcon
.addClass('fa-chevron-down')
.removeClass('fa-spinner fa-spin');
}; };
return TemplateSelector; return TemplateSelector;
......
...@@ -55,6 +55,7 @@ ...@@ -55,6 +55,7 @@
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new GLForm($('.issue-form')); new GLForm($('.issue-form'));
new IssuableForm($('.issue-form')); new IssuableForm($('.issue-form'));
new IssuableTemplateSelectors();
break; break;
case 'projects:merge_requests:new': case 'projects:merge_requests:new':
case 'projects:merge_requests:edit': case 'projects:merge_requests:edit':
...@@ -62,6 +63,7 @@ ...@@ -62,6 +63,7 @@
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new GLForm($('.merge-request-form')); new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form')); new IssuableForm($('.merge-request-form'));
new IssuableTemplateSelectors();
break; break;
case 'projects:tags:new': case 'projects:tags:new':
new ZenMode(); new ZenMode();
......
/*= require ../blob/template_selector */
((global) => {
class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
this.projectPath = this.dropdown.data('project-path');
this.namespacePath = this.dropdown.data('namespace-path');
this.issuableType = this.wrapper.data('issuable-type');
this.titleInput = $(`#${this.issuableType}_title`);
let initialQuery = {
name: this.dropdown.data('selected')
};
if (initialQuery.name) this.requestFile(initialQuery);
$('.reset-template', this.dropdown.parent()).on('click', () => {
if (this.currentTemplate) this.setInputValueToTemplateContent();
});
}
requestFile(query) {
this.startLoadingSpinner();
Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
this.currentTemplate = currentTemplate;
if (err) return; // Error handled by global AJAX error handler
this.stopLoadingSpinner();
this.setInputValueToTemplateContent();
});
return;
}
setInputValueToTemplateContent() {
// `this.requestFileSuccess` sets the value of the description input field
// to the content of the template selected.
if (this.titleInput.val() === '') {
// If the title has not yet been set, focus the title input and
// skip focusing the description input by setting `true` as the 2nd
// argument to `requestFileSuccess`.
this.requestFileSuccess(this.currentTemplate, true);
this.titleInput.focus();
} else {
this.requestFileSuccess(this.currentTemplate);
}
return;
}
}
global.IssuableTemplateSelector = IssuableTemplateSelector;
})(window);
((global) => {
class IssuableTemplateSelectors {
constructor(opts = {}) {
this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector');
this.editor = opts.editor || this.initEditor();
this.$dropdowns.each((i, dropdown) => {
let $dropdown = $(dropdown);
new IssuableTemplateSelector({
pattern: /(\.md)/,
data: $dropdown.data('data'),
wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
dropdown: $dropdown,
editor: this.editor
});
});
}
initEditor() {
let editor = $('.markdown-area');
// Proxy ace-editor's .setValue to jQuery's .val
editor.setValue = editor.val;
editor.getValue = editor.val;
return editor;
}
}
global.IssuableTemplateSelectors = IssuableTemplateSelectors;
})(window);
...@@ -56,9 +56,13 @@ ...@@ -56,9 +56,13 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 6px; right: 6px;
margin-top: -4px; margin-top: -6px;
color: $dropdown-toggle-icon-color; color: $dropdown-toggle-icon-color;
font-size: 10px; font-size: 10px;
&.fa-spinner {
font-size: 16px;
margin-top: -8px;
}
} }
&:hover, { &:hover, {
...@@ -406,6 +410,7 @@ ...@@ -406,6 +410,7 @@
font-size: 14px; font-size: 14px;
a { a {
cursor: pointer;
padding-left: 10px; padding-left: 10px;
} }
} }
......
...@@ -395,3 +395,12 @@ ...@@ -395,3 +395,12 @@
display: inline-block; display: inline-block;
line-height: 18px; line-height: 18px;
} }
.js-issuable-selector-wrap {
.js-issuable-selector {
width: 100%;
}
@media (max-width: $screen-sm-max) {
margin-bottom: $gl-padding;
}
}
class Projects::TemplatesController < Projects::ApplicationController
before_action :authenticate_user!, :get_template_class
def show
template = @template_type.find(params[:key], project)
respond_to do |format|
format.json { render json: template.to_json }
end
end
private
def get_template_class
template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access
@template_type = template_types[params[:template_type]]
render json: [], status: 404 unless @template_type
end
end
...@@ -182,17 +182,42 @@ module BlobHelper ...@@ -182,17 +182,42 @@ module BlobHelper
} }
end end
def selected_template(issuable)
templates = issuable_templates(issuable)
params[:issuable_template] if templates.include?(params[:issuable_template])
end
def can_add_template?(issuable)
names = issuable_templates(issuable)
names.empty? && can?(current_user, :push_code, @project) && !@project.private?
end
def merge_request_template_names
@merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project)
end
def issue_template_names
@issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project)
end
def issuable_templates(issuable)
@issuable_templates ||=
if issuable.is_a?(Issue)
issue_template_names
elsif issuable.is_a?(MergeRequest)
merge_request_template_names
end
end
def ref_project
@ref_project ||= @target_project || @project
end
def gitignore_names def gitignore_names
@gitignore_names ||= @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names
Gitlab::Template::Gitignore.categories.keys.map do |k|
[k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }]
end.to_h
end end
def gitlab_ci_ymls def gitlab_ci_ymls
@gitlab_ci_ymls ||= @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names
Gitlab::Template::GitlabCiYml.categories.keys.map do |k|
[k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }]
end.to_h
end end
end end
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
.filter-item.inline .filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
.filter-item.inline.labels-filter .filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
.filter-item.inline .filter-item.inline
= dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
%ul %ul
......
...@@ -2,7 +2,22 @@ ...@@ -2,7 +2,22 @@
.form-group .form-group
= f.label :title, class: 'control-label' = f.label :title, class: 'control-label'
.col-sm-10
- issuable_template_names = issuable_templates(issuable)
- if issuable_template_names.any?
.col-sm-3.col-lg-2
.js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } }
- title = selected_template(issuable) || "Choose a template"
= dropdown_tag(title, options: { toggle_class: 'js-issuable-selector',
title: title, filter: true, placeholder: 'Filter', footer_content: true,
data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do
%ul.dropdown-footer-list
%li
%a.reset-template
Reset template
%div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' }
= f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off',
class: 'form-control pad', required: true class: 'form-control pad', required: true
...@@ -23,6 +38,13 @@ ...@@ -23,6 +38,13 @@
to prevent a to prevent a
%strong Work In Progress %strong Work In Progress
merge request from being merged before it's ready. merge request from being merged before it's ready.
- if can_add_template?(issuable)
%p.help-block
Add
= link_to "issuable templates", help_page_path('workflow/description_templates')
to help your contributors communicate effectively!
.form-group.detail-page-description .form-group.detail-page-description
= f.label :description, 'Description', class: 'control-label' = f.label :description, 'Description', class: 'control-label'
.col-sm-10 .col-sm-10
......
...@@ -528,6 +528,11 @@ Rails.application.routes.draw do ...@@ -528,6 +528,11 @@ Rails.application.routes.draw do
put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob' put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob'
post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob' post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob'
#
# Templates
#
get '/templates/:template_type/:key' => 'templates#show', as: :template
scope do scope do
get( get(
'/blob/*id/diff', '/blob/*id/diff',
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
- [Share projects with other groups](share_projects_with_other_groups.md) - [Share projects with other groups](share_projects_with_other_groups.md)
- [Web Editor](web_editor.md) - [Web Editor](web_editor.md)
- [Releases](releases.md) - [Releases](releases.md)
- [Issuable Templates](issuable_templates.md)
- [Milestones](milestones.md) - [Milestones](milestones.md)
- [Merge Requests](merge_requests.md) - [Merge Requests](merge_requests.md)
- [Revert changes](revert_changes.md) - [Revert changes](revert_changes.md)
......
# Description templates
Description templates allow you to define context-specific templates for issue and merge request description fields for your project. When in use, users that create a new issue or merge request can select a description template to help them communicate with other contributors effectively.
Every GitLab project can define its own set of description templates as they are added to the root directory of a GitLab project's repository.
Description templates are written in markdown _(`.md`)_ and stored in your projects repository under the `/.gitlab/issue_templates/` and `/.gitlab/merge_request_templates/` directories.
![Description templates](img/description_templates.png)
_Example:_
`/.gitlab/issue_templates/bug.md` will enable the `bug` dropdown option for new issues. When `bug` is selected, the content from the `bug.md` template file will be copied to the issue description field.
module API module API
class Templates < Grape::API class Templates < Grape::API
TEMPLATE_TYPES = { GLOBAL_TEMPLATE_TYPES = {
gitignores: Gitlab::Template::Gitignore, gitignores: Gitlab::Template::GitignoreTemplate,
gitlab_ci_ymls: Gitlab::Template::GitlabCiYml gitlab_ci_ymls: Gitlab::Template::GitlabCiYmlTemplate
}.freeze }.freeze
TEMPLATE_TYPES.each do |template, klass| helpers do
def render_response(template_type, template)
not_found!(template_type.to_s.singularize) unless template
present template, with: Entities::Template
end
end
GLOBAL_TEMPLATE_TYPES.each do |template_type, klass|
# Get the list of the available template # Get the list of the available template
# #
# Example Request: # Example Request:
# GET /gitignores # GET /gitignores
# GET /gitlab_ci_ymls # GET /gitlab_ci_ymls
get template.to_s do get template_type.to_s do
present klass.all, with: Entities::TemplatesList present klass.all, with: Entities::TemplatesList
end end
# Get the text for a specific template # Get the text for a specific template present in local filesystem
# #
# Parameters: # Parameters:
# name (required) - The name of a template # name (required) - The name of a template
...@@ -23,13 +30,10 @@ module API ...@@ -23,13 +30,10 @@ module API
# Example Request: # Example Request:
# GET /gitignores/Elixir # GET /gitignores/Elixir
# GET /gitlab_ci_ymls/Ruby # GET /gitlab_ci_ymls/Ruby
get "#{template}/:name" do get "#{template_type}/:name" do
required_attributes! [:name] required_attributes! [:name]
new_template = klass.find(params[:name]) new_template = klass.find(params[:name])
not_found!(template.to_s.singularize) unless new_template render_response(template_type, new_template)
present new_template, with: Entities::Template
end end
end end
end end
......
module Gitlab module Gitlab
module Template module Template
class BaseTemplate class BaseTemplate
def initialize(path) def initialize(path, project = nil)
@path = path @path = path
@finder = self.class.finder(project)
end end
def name def name
...@@ -10,23 +11,32 @@ module Gitlab ...@@ -10,23 +11,32 @@ module Gitlab
end end
def content def content
File.read(@path) @finder.read(@path)
end end
class << self def to_json
def all { name: name, content: content }
self.categories.keys.flat_map { |cat| by_category(cat) }
end end
def find(key) class << self
file_name = "#{key}#{self.extension}" def all(project = nil)
if categories.any?
categories.keys.flat_map { |cat| by_category(cat, project) }
else
by_category("", project)
end
end
directory = select_directory(file_name) def find(key, project = nil)
directory ? new(File.join(category_directory(directory), file_name)) : nil path = self.finder(project).find(key)
path.present? ? new(path, project) : nil
end end
# Set categories as sub directories
# Example: { "category_name_1" => "directory_path_1", "category_name_2" => "directory_name_2" }
# Default is no category with all files in base dir of each class
def categories def categories
raise NotImplementedError {}
end end
def extension def extension
...@@ -37,29 +47,40 @@ module Gitlab ...@@ -37,29 +47,40 @@ module Gitlab
raise NotImplementedError raise NotImplementedError
end end
def by_category(category) # Defines which strategy will be used to get templates files
templates_for_directory(category_directory(category)) # RepoTemplateFinder - Finds templates on project repository, templates are filtered perproject
# GlobalTemplateFinder - Finds templates on gitlab installation source, templates can be used in all projects
def finder(project = nil)
raise NotImplementedError
end end
def category_directory(category) def by_category(category, project = nil)
File.join(base_dir, categories[category]) directory = category_directory(category)
files = finder(project).list_files_for(directory)
files.map { |f| new(f, project) }
end end
private def category_directory(category)
return base_dir unless category.present?
def select_directory(file_name) File.join(base_dir, categories[category])
categories.keys.find do |category|
File.exist?(File.join(category_directory(category), file_name))
end
end end
def templates_for_directory(dir) # If template is organized by category it returns { category_name: [{ name: template_name }, { name: template2_name }] }
dir << '/' unless dir.end_with?('/') # If no category is present returns [{ name: template_name }, { name: template2_name}]
Dir.glob(File.join(dir, "*#{self.extension}")).select { |f| f =~ filter_regex }.map { |f| new(f) } def dropdown_names(project = nil)
end return [] if project && !project.repository.exists?
def filter_regex if categories.any?
@filter_reges ||= /#{Regexp.escape(extension)}\z/ categories.keys.map do |category|
files = self.by_category(category, project)
[category, files.map { |t| { name: t.name } }]
end.to_h
else
files = self.all(project)
files.map { |t| { name: t.name } }
end
end end
end end
end end
......
module Gitlab
module Template
module Finders
class BaseTemplateFinder
def initialize(base_dir)
@base_dir = base_dir
end
def list_files_for
raise NotImplementedError
end
def read
raise NotImplementedError
end
def find
raise NotImplementedError
end
def category_directory(category)
return @base_dir unless category.present?
@base_dir + @categories[category]
end
class << self
def filter_regex(extension)
/#{Regexp.escape(extension)}\z/
end
end
end
end
end
end
# Searches and reads file present on Gitlab installation directory
module Gitlab
module Template
module Finders
class GlobalTemplateFinder < BaseTemplateFinder
def initialize(base_dir, extension, categories = {})
@categories = categories
@extension = extension
super(base_dir)
end
def read(path)
File.read(path)
end
def find(key)
file_name = "#{key}#{@extension}"
directory = select_directory(file_name)
directory ? File.join(category_directory(directory), file_name) : nil
end
def list_files_for(dir)
dir << '/' unless dir.end_with?('/')
Dir.glob(File.join(dir, "*#{@extension}")).select { |f| f =~ self.class.filter_regex(@extension) }
end
private
def select_directory(file_name)
@categories.keys.find do |category|
File.exist?(File.join(category_directory(category), file_name))
end
end
end
end
end
end
# Searches and reads files present on each Gitlab project repository
module Gitlab
module Template
module Finders
class RepoTemplateFinder < BaseTemplateFinder
# Raised when file is not found
class FileNotFoundError < StandardError; end
def initialize(project, base_dir, extension, categories = {})
@categories = categories
@extension = extension
@repository = project.repository
@commit = @repository.head_commit if @repository.exists?
super(base_dir)
end
def read(path)
blob = @repository.blob_at(@commit.id, path) if @commit
raise FileNotFoundError if blob.nil?
blob.data
end
def find(key)
file_name = "#{key}#{@extension}"
directory = select_directory(file_name)
raise FileNotFoundError if directory.nil?
category_directory(directory) + file_name
end
def list_files_for(dir)
return [] unless @commit
dir << '/' unless dir.end_with?('/')
entries = @repository.tree(:head, dir).entries
names = entries.map(&:name)
names.select { |f| f =~ self.class.filter_regex(@extension) }
end
private
def select_directory(file_name)
return [] unless @commit
# Insert root as directory
directories = ["", @categories.keys]
directories.find do |category|
path = category_directory(category) + file_name
@repository.blob_at(@commit.id, path)
end
end
end
end
end
end
module Gitlab module Gitlab
module Template module Template
class Gitignore < BaseTemplate class GitignoreTemplate < BaseTemplate
class << self class << self
def extension def extension
'.gitignore' '.gitignore'
...@@ -16,6 +16,10 @@ module Gitlab ...@@ -16,6 +16,10 @@ module Gitlab
def base_dir def base_dir
Rails.root.join('vendor/gitignore') Rails.root.join('vendor/gitignore')
end end
def finder(project = nil)
Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories)
end
end end
end end
end end
......
module Gitlab module Gitlab
module Template module Template
class GitlabCiYml < BaseTemplate class GitlabCiYmlTemplate < BaseTemplate
def content def content
explanation = "# This file is a template, and might need editing before it works on your project." explanation = "# This file is a template, and might need editing before it works on your project."
[explanation, super].join("\n") [explanation, super].join("\n")
...@@ -21,6 +21,10 @@ module Gitlab ...@@ -21,6 +21,10 @@ module Gitlab
def base_dir def base_dir
Rails.root.join('vendor/gitlab-ci-yml') Rails.root.join('vendor/gitlab-ci-yml')
end end
def finder(project = nil)
Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories)
end
end end
end end
end end
......
module Gitlab
module Template
class IssueTemplate < BaseTemplate
class << self
def extension
'.md'
end
def base_dir
'.gitlab/issue_templates/'
end
def finder(project)
Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories)
end
end
end
end
end
module Gitlab
module Template
class MergeRequestTemplate < BaseTemplate
class << self
def extension
'.md'
end
def base_dir
'.gitlab/merge_request_templates/'
end
def finder(project)
Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories)
end
end
end
end
end
require 'spec_helper'
describe Projects::TemplatesController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:file_path_1) { '.gitlab/issue_templates/bug.md' }
let(:body) { JSON.parse(response.body) }
before do
project.team << [user, :developer]
sign_in(user)
end
before do
project.team.add_user(user, Gitlab::Access::MASTER)
project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false)
end
describe '#show' do
it 'renders template name and content as json' do
get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json)
expect(response.status).to eq(200)
expect(body["name"]).to eq("bug")
expect(body["content"]).to eq("something valid")
end
it 'renders 404 when unauthorized' do
sign_in(user2)
get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json)
expect(response.status).to eq(404)
end
it 'renders 404 when template type is not found' do
sign_in(user)
get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json)
expect(response.status).to eq(404)
end
it 'renders 404 without errors' do
sign_in(user)
expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) }.not_to raise_error
end
end
end
require 'spec_helper'
feature 'issuable templates', feature: true, js: true do
include WaitForAjax
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
before do
project.team << [user, :master]
login_as user
end
context 'user creates an issue using templates' do
let(:template_content) { 'this is a test "bug" template' }
let(:issue) { create(:issue, author: user, assignee: user, project: project) }
background do
project.repository.commit_file(user, '.gitlab/issue_templates/bug.md', template_content, 'added issue template', 'master', false)
visit edit_namespace_project_issue_path project.namespace, project, issue
fill_in :'issue[title]', with: 'test issue title'
end
scenario 'user selects "bug" template' do
select_template 'bug'
wait_for_ajax
preview_template
save_changes
end
end
context 'user creates a merge request using templates' do
let(:template_content) { 'this is a test "feature-proposal" template' }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) }
background do
project.repository.commit_file(user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false)
visit edit_namespace_project_merge_request_path project.namespace, project, merge_request
fill_in :'merge_request[title]', with: 'test merge request title'
end
scenario 'user selects "feature-proposal" template' do
select_template 'feature-proposal'
wait_for_ajax
preview_template
save_changes
end
end
context 'user creates a merge request from a forked project using templates' do
let(:template_content) { 'this is a test "feature-proposal" template' }
let(:fork_user) { create(:user) }
let(:fork_project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project) }
background do
logout
project.team << [fork_user, :developer]
fork_project.team << [fork_user, :master]
create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project)
login_as fork_user
fork_project.repository.commit_file(fork_user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false)
visit edit_namespace_project_merge_request_path fork_project.namespace, fork_project, merge_request
fill_in :'merge_request[title]', with: 'test merge request title'
end
scenario 'user selects "feature-proposal" template' do
select_template 'feature-proposal'
wait_for_ajax
preview_template
save_changes
end
end
def preview_template
click_link 'Preview'
expect(page).to have_content template_content
end
def save_changes
click_button "Save changes"
expect(page).to have_content template_content
end
def select_template(name)
first('.js-issuable-selector').click
first('.js-issuable-selector-wrap .dropdown-content a', text: name).click
end
end
require 'spec_helper' require 'spec_helper'
describe Gitlab::Template::Gitignore do describe Gitlab::Template::GitignoreTemplate do
subject { described_class } subject { described_class }
describe '.all' do describe '.all' do
...@@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do ...@@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do
it 'returns the Gitignore object of a valid file' do it 'returns the Gitignore object of a valid file' do
ruby = subject.find('Ruby') ruby = subject.find('Ruby')
expect(ruby).to be_a Gitlab::Template::Gitignore expect(ruby).to be_a Gitlab::Template::GitignoreTemplate
expect(ruby.name).to eq('Ruby') expect(ruby.name).to eq('Ruby')
end end
end end
......
require 'spec_helper'
describe Gitlab::Template::GitlabCiYmlTemplate do
subject { described_class }
describe '.all' do
it 'strips the gitlab-ci suffix' do
expect(subject.all.first.name).not_to end_with('.gitlab-ci.yml')
end
it 'combines the globals and rest' do
all = subject.all.map(&:name)
expect(all).to include('Elixir')
expect(all).to include('Docker')
expect(all).to include('Ruby')
end
end
describe '.find' do
it 'returns nil if the file does not exist' do
expect(subject.find('mepmep-yadida')).to be nil
end
it 'returns the GitlabCiYml object of a valid file' do
ruby = subject.find('Ruby')
expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate
expect(ruby.name).to eq('Ruby')
end
end
describe '#content' do
it 'loads the full file' do
gitignore = subject.new(Rails.root.join('vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml'))
expect(gitignore.name).to eq 'Ruby'
expect(gitignore.content).to start_with('#')
end
end
end
require 'spec_helper'
describe Gitlab::Template::IssueTemplate do
subject { described_class }
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:file_path_1) { '.gitlab/issue_templates/bug.md' }
let(:file_path_2) { '.gitlab/issue_templates/template_test.md' }
let(:file_path_3) { '.gitlab/issue_templates/feature_proposal.md' }
before do
project.team.add_user(user, Gitlab::Access::MASTER)
project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false)
project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false)
project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false)
end
describe '.all' do
it 'strips the md suffix' do
expect(subject.all(project).first.name).not_to end_with('.issue_template')
end
it 'combines the globals and rest' do
all = subject.all(project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
expect(all).to include('template_test')
end
end
describe '.find' do
it 'returns nil if the file does not exist' do
expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
it 'returns the issue object of a valid file' do
ruby = subject.find('bug', project)
expect(ruby).to be_a Gitlab::Template::IssueTemplate
expect(ruby.name).to eq('bug')
end
end
describe '.by_category' do
it 'return array of templates' do
all = subject.by_category('', project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
expect(all).to include('template_test')
end
context 'when repo is bare or empty' do
let(:empty_project) { create(:empty_project) }
before { empty_project.team.add_user(user, Gitlab::Access::MASTER) }
it "returns empty array" do
templates = subject.by_category('', empty_project)
expect(templates).to be_empty
end
end
end
describe '#content' do
it 'loads the full file' do
issue_template = subject.new('.gitlab/issue_templates/bug.md', project)
expect(issue_template.name).to eq 'bug'
expect(issue_template.content).to eq('something valid')
end
it 'raises error when file is not found' do
issue_template = subject.new('.gitlab/issue_templates/bugnot.md', project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
context "when repo is empty" do
let(:empty_project) { create(:empty_project) }
before { empty_project.team.add_user(user, Gitlab::Access::MASTER) }
it "raises file not found" do
issue_template = subject.new('.gitlab/issue_templates/not_existent.md', empty_project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
end
end
end
require 'spec_helper'
describe Gitlab::Template::MergeRequestTemplate do
subject { described_class }
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:file_path_1) { '.gitlab/merge_request_templates/bug.md' }
let(:file_path_2) { '.gitlab/merge_request_templates/template_test.md' }
let(:file_path_3) { '.gitlab/merge_request_templates/feature_proposal.md' }
before do
project.team.add_user(user, Gitlab::Access::MASTER)
project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false)
project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false)
project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false)
end
describe '.all' do
it 'strips the md suffix' do
expect(subject.all(project).first.name).not_to end_with('.issue_template')
end
it 'combines the globals and rest' do
all = subject.all(project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
expect(all).to include('template_test')
end
end
describe '.find' do
it 'returns nil if the file does not exist' do
expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
it 'returns the merge request object of a valid file' do
ruby = subject.find('bug', project)
expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate
expect(ruby.name).to eq('bug')
end
end
describe '.by_category' do
it 'return array of templates' do
all = subject.by_category('', project).map(&:name)
expect(all).to include('bug')
expect(all).to include('feature_proposal')
expect(all).to include('template_test')
end
context 'when repo is bare or empty' do
let(:empty_project) { create(:empty_project) }
before { empty_project.team.add_user(user, Gitlab::Access::MASTER) }
it "returns empty array" do
templates = subject.by_category('', empty_project)
expect(templates).to be_empty
end
end
end
describe '#content' do
it 'loads the full file' do
issue_template = subject.new('.gitlab/merge_request_templates/bug.md', project)
expect(issue_template.name).to eq 'bug'
expect(issue_template.content).to eq('something valid')
end
it 'raises error when file is not found' do
issue_template = subject.new('.gitlab/merge_request_templates/bugnot.md', project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
context "when repo is empty" do
let(:empty_project) { create(:empty_project) }
before { empty_project.team.add_user(user, Gitlab::Access::MASTER) }
it "raises file not found" do
issue_template = subject.new('.gitlab/merge_request_templates/not_existent.md', empty_project)
expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError)
end
end
end
end
...@@ -3,6 +3,7 @@ require 'spec_helper' ...@@ -3,6 +3,7 @@ require 'spec_helper'
describe API::Templates, api: true do describe API::Templates, api: true do
include ApiHelpers include ApiHelpers
context 'global templates' do
describe 'the Template Entity' do describe 'the Template Entity' do
before { get api('/gitignores/Ruby') } before { get api('/gitignores/Ruby') }
...@@ -22,7 +23,7 @@ describe API::Templates, api: true do ...@@ -22,7 +23,7 @@ describe API::Templates, api: true do
it 'returns a list of available gitignore templates' do it 'returns a list of available gitignore templates' do
get api('/gitignores') get api('/gitignores')
expect(response).to have_http_status(200) expect(response.status).to eq(200)
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(json_response.size).to be > 15 expect(json_response.size).to be > 15
end end
...@@ -34,7 +35,7 @@ describe API::Templates, api: true do ...@@ -34,7 +35,7 @@ describe API::Templates, api: true do
it 'returns a list of available gitlab_ci_ymls' do it 'returns a list of available gitlab_ci_ymls' do
get api('/gitlab_ci_ymls') get api('/gitlab_ci_ymls')
expect(response).to have_http_status(200) expect(response.status).to eq(200)
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(json_response.first['name']).not_to be_nil expect(json_response.first['name']).not_to be_nil
end end
...@@ -46,7 +47,9 @@ describe API::Templates, api: true do ...@@ -46,7 +47,9 @@ describe API::Templates, api: true do
get api('/gitlab_ci_ymls/Ruby') get api('/gitlab_ci_ymls/Ruby')
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response['name']).not_to be_nil
expect(json_response['content']).to start_with("# This file is a template,") expect(json_response['content']).to start_with("# This file is a template,")
end end
end end
end
end end
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