diff --git a/CHANGELOG b/CHANGELOG
index 616f006b29e..837e9e27aba 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -40,6 +40,8 @@ v 8.11.0 (unreleased)
- Various redundant database indexes have been removed
- Update `timeago` plugin to use multiple string/locale settings
- Remove unused images (ClemMakesApps)
+ - Get issue and merge request description templates from repositories
+ - Add hover state to todos !5361 (winniehell)
- Limit git rev-list output count to one in forced push check
- Show deployment status on merge requests with external URLs
- Clean up unused routes (Josef Strzibny)
@@ -73,6 +75,7 @@ v 8.11.0 (unreleased)
- The overhead of instrumented method calls has been reduced
- Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
- Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
+ - Add pipeline events hook
- Bump gitlab_git to speedup DiffCollection iterations
- Rewrite description of a blocked user in admin settings. (Elias Werberich)
- Make branches sortable without push permission !5462 (winniehell)
@@ -82,6 +85,7 @@ v 8.11.0 (unreleased)
- Make "New issue" button in Issue page less obtrusive !5457 (winniehell)
- Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration
- Fix search for notes which belongs to deleted objects
+ - Allow Akismet to be trained by submitting issues as spam or ham !5538
- Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska)
- Allow branch names ending with .json for graph and network page !5579 (winniehell)
- Add the `sprockets-es6` gem
@@ -93,6 +97,7 @@ v 8.11.0 (unreleased)
- Add commit stats in commit api. !5517 (dixpac)
- Add CI configuration button on project page
- Make error pages responsive (Takuya Noguchi)
+ - The performance of the project dropdown used for moving issues has been improved
- Fix skip_repo parameter being ignored when destroying a namespace
- Change requests_profiles resource constraint to catch virtually any file
- Bump gitlab_git to lazy load compare commits
@@ -116,6 +121,12 @@ v 8.11.0 (unreleased)
- Speed up todos queries by limiting the projects set we join with
- Ensure file editing in UI does not overwrite commited changes without warning user
+v 8.10.6
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+ - Restore "Largest repository" sort option on Admin > Projects page. !5797
+ - Fix privilege escalation via project export.
+ - Require administrator privileges to perform a project import.
+
v 8.10.5
- Add a data migration to fix some missing timestamps in the members table. !5670
- Revert the "Defend against 'Host' header injection" change in the source NGINX templates. !5706
@@ -136,6 +147,9 @@ v 8.10.3
- Fix importer for GitHub Pull Requests when a branch was removed. !5573
- Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584
- Trim extra displayed carriage returns in diffs and files with CRLFs. !5588
+ - Fix label already exist error message in the right sidebar.
+
+v 8.10.3 (unreleased)
v 8.10.2
- User can now search branches by name. !5144
@@ -283,6 +297,7 @@ v 8.10.0
- Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab
- RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info.
- Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w)
+ - Made project list visibility icon fixed width
- Set import_url validation to be more strict
- Memoize MR merged/closed events retrieval
- Don't render discussion notes when requesting diff tab through AJAX
@@ -329,6 +344,10 @@ v 8.10.0
- Fix migration corrupting import data for old version upgrades
- Show tooltip on GitLab export link in new project page
+v 8.9.7
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+ - Require administrator privileges to perform a project import.
+
v 8.9.6
- Fix importing of events under notes for GitLab projects. !5154
- Fix log statements in import/export. !5129
@@ -594,6 +613,9 @@ v 8.9.0
- Add tooltip to pin/unpin navbar
- Add new sub nav style to Wiki and Graphs sub navigation
+v 8.8.8
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+
v 8.8.7
- Fix privilege escalation issue with OAuth external users.
- Ensure references to private repos aren't shown to logged-out users.
diff --git a/Gemfile.lock b/Gemfile.lock
index 3ba6048143c..2244c20203b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -338,7 +338,7 @@ GEM
httparty (0.13.7)
json (~> 1.8)
multi_xml (>= 0.5.2)
- httpclient (2.7.0.1)
+ httpclient (2.8.2)
i18n (0.7.0)
ice_nine (0.11.1)
influxdb (0.2.3)
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 49c2ac0dac3..84b292e59c6 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -9,10 +9,11 @@
licensePath: "/api/:version/licenses/:key",
gitignorePath: "/api/:version/gitignores/:key",
gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key",
+ issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
+
group: function(group_id, callback) {
- var url;
- url = Api.buildUrl(Api.groupPath);
- url = url.replace(':id', group_id);
+ var url = Api.buildUrl(Api.groupPath)
+ .replace(':id', group_id);
return $.ajax({
url: url,
data: {
@@ -24,8 +25,7 @@
});
},
groups: function(query, skip_ldap, callback) {
- var url;
- url = Api.buildUrl(Api.groupsPath);
+ var url = Api.buildUrl(Api.groupsPath);
return $.ajax({
url: url,
data: {
@@ -39,8 +39,7 @@
});
},
namespaces: function(query, callback) {
- var url;
- url = Api.buildUrl(Api.namespacesPath);
+ var url = Api.buildUrl(Api.namespacesPath);
return $.ajax({
url: url,
data: {
@@ -54,8 +53,7 @@
});
},
projects: function(query, order, callback) {
- var url;
- url = Api.buildUrl(Api.projectsPath);
+ var url = Api.buildUrl(Api.projectsPath);
return $.ajax({
url: url,
data: {
@@ -70,9 +68,8 @@
});
},
newLabel: function(project_id, data, callback) {
- var url;
- url = Api.buildUrl(Api.labelsPath);
- url = url.replace(':id', project_id);
+ var url = Api.buildUrl(Api.labelsPath)
+ .replace(':id', project_id);
data.private_token = gon.api_token;
return $.ajax({
url: url,
@@ -86,9 +83,8 @@
});
},
groupProjects: function(group_id, query, callback) {
- var url;
- url = Api.buildUrl(Api.groupProjectsPath);
- url = url.replace(':id', group_id);
+ var url = Api.buildUrl(Api.groupProjectsPath)
+ .replace(':id', group_id);
return $.ajax({
url: url,
data: {
@@ -102,8 +98,8 @@
});
},
licenseText: function(key, data, callback) {
- var url;
- url = Api.buildUrl(Api.licensePath).replace(':key', key);
+ var url = Api.buildUrl(Api.licensePath)
+ .replace(':key', key);
return $.ajax({
url: url,
data: data
@@ -112,19 +108,32 @@
});
},
gitignoreText: function(key, callback) {
- var url;
- url = Api.buildUrl(Api.gitignorePath).replace(':key', key);
+ var url = Api.buildUrl(Api.gitignorePath)
+ .replace(':key', key);
return $.get(url, function(gitignore) {
return callback(gitignore);
});
},
gitlabCiYml: function(key, callback) {
- var url;
- url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key);
+ var url = Api.buildUrl(Api.gitlabCiYmlPath)
+ .replace(':key', key);
return $.get(url, function(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) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root + url;
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index f1aab067351..e596b98603b 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -41,6 +41,7 @@
/*= require date.format */
/*= require_directory ./behaviors */
/*= require_directory ./blob */
+/*= require_directory ./templates */
/*= require_directory ./commit */
/*= require_directory ./extensions */
/*= require_directory ./lib/utils */
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 2cf0a6631b8..b0a37ef0e0a 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -9,6 +9,7 @@
}
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.dropdownIcon = $('.fa-chevron-down', this.dropdown);
this.buildDropdown();
this.bindEvents();
this.onFilenameUpdate();
@@ -60,11 +61,26 @@
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);
- 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;
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 12a34a276d3..1163edd8547 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -55,6 +55,7 @@
shortcut_handler = new ShortcutsNavigation();
new GLForm($('.issue-form'));
new IssuableForm($('.issue-form'));
+ new IssuableTemplateSelectors();
break;
case 'projects:merge_requests:new':
case 'projects:merge_requests:edit':
@@ -62,6 +63,7 @@
shortcut_handler = new ShortcutsNavigation();
new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
+ new IssuableTemplateSelectors();
break;
case 'projects:tags:new':
new ZenMode();
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 09b5eb398d4..b2e49b71fec 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -33,7 +33,7 @@
this.render = bind(this.render, this);
this.VIEW_TYPE = $('input#view[type=hidden]').val();
debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION);
- $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy);
+ $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy);
}
FilesCommentButton.prototype.render = function(e) {
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 675dd5b7cea..1bb0b67d0e8 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -70,13 +70,15 @@
name: newLabelField.val(),
color: newColorField.val()
}, function(label) {
- var errors;
$newLabelCreateButton.enable();
if (label.message != null) {
- errors = _.map(label.message, function(value, key) {
- return key + " " + value[0];
- });
- return $newLabelError.html(errors.join("
")).show();
+ var errorText = label.message;
+ if (_.isObject(label.message)) {
+ errorText = _.map(label.message, function(value, key) {
+ return key + " " + value[0];
+ }).join('
');
+ }
+ return $newLabelError.html(errorText).show();
} else {
return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
}
diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6
index 00e20a03b04..2efca2414dc 100644
--- a/app/assets/javascripts/protected_branch_create.js.es6
+++ b/app/assets/javascripts/protected_branch_create.js.es6
@@ -44,8 +44,8 @@
// Enable submit button
const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]');
- const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_level_attributes][access_level]"]');
- const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_level_attributes][access_level]"]');
+ const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
+ const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
if ($branchInput.val() && $allowedToMergeInput.val() && $allowedToPushInput.val()){
this.$form.find('input[type="submit"]').removeAttr('disabled');
diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6
index 8d42e268ebc..a59fcbfa082 100644
--- a/app/assets/javascripts/protected_branch_edit.js.es6
+++ b/app/assets/javascripts/protected_branch_edit.js.es6
@@ -39,12 +39,14 @@
_method: 'PATCH',
id: this.$wrap.data('banchId'),
protected_branch: {
- merge_access_level_attributes: {
+ merge_access_levels_attributes: [{
+ id: this.$allowedToMergeDropdown.data('access-level-id'),
access_level: $allowedToMergeInput.val()
- },
- push_access_level_attributes: {
+ }],
+ push_access_levels_attributes: [{
+ id: this.$allowedToPushDropdown.data('access-level-id'),
access_level: $allowedToPushInput.val()
- }
+ }]
}
},
success: () => {
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6
new file mode 100644
index 00000000000..c32ddf80219
--- /dev/null
+++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6
@@ -0,0 +1,51 @@
+/*= 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);
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6
new file mode 100644
index 00000000000..bd8cdde033e
--- /dev/null
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6
@@ -0,0 +1,29 @@
+((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);
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 473530cf094..f1fe1697d30 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -164,6 +164,10 @@
@include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light);
}
+ &.btn-spam {
+ @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light);
+ }
+
&.btn-danger,
&.btn-remove,
&.btn-red {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index e8eafa15899..f1635a53763 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -56,9 +56,13 @@
position: absolute;
top: 50%;
right: 6px;
- margin-top: -4px;
+ margin-top: -6px;
color: $dropdown-toggle-icon-color;
font-size: 10px;
+ &.fa-spinner {
+ font-size: 16px;
+ margin-top: -8px;
+ }
}
&:hover, {
@@ -406,6 +410,7 @@
font-size: 14px;
a {
+ cursor: pointer;
padding-left: 10px;
}
}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 7cf4d4fba42..07c8874bf03 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -6,11 +6,11 @@
table-layout: fixed;
pre {
- padding: 10px;
+ padding: 10px 0;
border: none;
border-radius: 0;
font-family: $monospace_font;
- font-size: $code_font_size !important;
+ font-size: $code_font_size;
line-height: $code_line_height !important;
margin: 0;
overflow: auto;
@@ -20,13 +20,20 @@
border-left: 1px solid;
code {
+ display: inline-block;
+ min-width: 100%;
font-family: $monospace_font;
- white-space: pre;
+ white-space: normal;
word-wrap: normal;
padding: 0;
.line {
- display: inline-block;
+ display: block;
+ width: 100%;
+ min-height: 19px;
+ padding-left: 10px;
+ padding-right: 10px;
+ white-space: pre;
}
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 7a50bc9c832..46c4a11aa2e 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -395,3 +395,12 @@
display: inline-block;
line-height: 18px;
}
+
+.js-issuable-selector-wrap {
+ .js-issuable-selector {
+ width: 100%;
+ }
+ @media (max-width: $screen-sm-max) {
+ margin-bottom: $gl-padding;
+ }
+}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index cf9aa02600d..27dc2b2a1fa 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -99,7 +99,7 @@
margin-left: auto;
margin-right: auto;
margin-bottom: 15px;
- max-width: 480px;
+ max-width: 700px;
> p {
margin-bottom: 0;
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index cf16d070cfe..0340526a53a 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -20,10 +20,43 @@
}
}
-.todo {
+.todos-list > .todo {
+ // workaround because we cannot use border-colapse
+ border-top: 1px solid transparent;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ flex-direction: row;
+
&:hover {
+ background-color: $row-hover;
+ border-color: $row-hover-border;
cursor: pointer;
}
+
+ // overwrite border style of .content-list
+ &:last-child {
+ border-bottom: 1px solid transparent;
+
+ &:hover {
+ border-color: $row-hover-border;
+ }
+ }
+
+ .todo-actions {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-justify-content: center;
+ justify-content: center;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ margin-left: 10px;
+ }
+
+ .todo-item {
+ -webkit-flex: auto;
+ flex: auto;
+ }
}
.todo-item {
@@ -43,8 +76,6 @@
}
.todo-body {
- margin-right: 174px;
-
.todo-note {
word-wrap: break-word;
@@ -90,6 +121,12 @@
}
@media (max-width: $screen-xs-max) {
+ .todo {
+ .avatar {
+ display: none;
+ }
+ }
+
.todo-item {
.todo-title {
white-space: normal;
@@ -98,10 +135,6 @@
margin-bottom: 10px;
}
- .avatar {
- display: none;
- }
-
.todo-body {
margin: 0;
border-left: 2px solid #ddd;
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 3a2f0185315..2abfa22712d 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -14,4 +14,14 @@ class Admin::SpamLogsController < Admin::ApplicationController
head :ok
end
end
+
+ def mark_as_ham
+ spam_log = SpamLog.find(params[:id])
+
+ if HamService.new(spam_log).mark_as_ham!
+ redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.'
+ else
+ redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ end
+ end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index d828d163c28..b48668eea87 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,5 +1,6 @@
class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users]
+ before_action :load_project, only: [:users]
before_action :find_users, only: [:users]
def users
@@ -34,19 +35,13 @@ class AutocompleteController < ApplicationController
def projects
project = Project.find_by_id(params[:project_id])
-
- projects = current_user.authorized_projects
- projects = projects.search(params[:search]) if params[:search].present?
- projects = projects.select do |project|
- current_user.can?(:admin_issue, project)
- end
+ projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
no_project = {
id: 0,
name_with_namespace: 'No project',
}
- projects.unshift(no_project)
- projects.delete(project)
+ projects.unshift(no_project) unless params[:offset_id].present?
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end
@@ -55,11 +50,8 @@ class AutocompleteController < ApplicationController
def find_users
@users =
- if params[:project_id].present?
- project = Project.find(params[:project_id])
- return render_404 unless can?(current_user, :read_project, project)
-
- project.team.users
+ if @project
+ @project.team.users
elsif params[:group_id].present?
group = Group.find(params[:group_id])
return render_404 unless can?(current_user, :read_group, group)
@@ -71,4 +63,18 @@ class AutocompleteController < ApplicationController
User.none
end
end
+
+ def load_project
+ @project ||= begin
+ if params[:project_id].present?
+ project = Project.find(params[:project_id])
+ return render_404 unless can?(current_user, :read_project, project)
+ project
+ end
+ end
+ end
+
+ def projects_finder
+ MoveToProjectFinder.new(current_user)
+ end
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 471d15af913..a69877edfd4 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -7,11 +7,16 @@ module ServiceParams
:build_key, :server, :teamcity_url, :drone_url, :build_type,
:description, :issues_url, :new_issue_url, :restrict_to_branch, :channel,
:colorize_messages, :channels,
- :push_events, :issues_events, :merge_requests_events, :tag_push_events,
- :note_events, :build_events, :wiki_page_events,
- :notify_only_broken_builds, :add_pusher,
- :send_from_committer_email, :disable_diffs, :external_wiki_url,
- :notify, :color,
+ # We're using `issues_events` and `merge_requests_events`
+ # in the view so we still need to explicitly state them
+ # here. `Service#event_names` would only give
+ # `issue_events` and `merge_request_events` (singular!)
+ # See app/helpers/services_helper.rb for how we
+ # make those event names plural as special case.
+ :issues_events, :merge_requests_events,
+ :notify_only_broken_builds, :notify_only_broken_pipelines,
+ :add_pusher, :send_from_committer_email, :disable_diffs,
+ :external_wiki_url, :notify, :color,
:server_host, :server_port, :default_irc_uri, :enable_ssl_verification,
:jira_issue_transition_id]
@@ -19,9 +24,7 @@ module ServiceParams
FILTER_BLANK_PARAMS = [:password]
def service_params
- dynamic_params = []
- dynamic_params.concat(@service.event_channel_names)
-
+ dynamic_params = @service.event_channel_names + @service.event_names
service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params)
if service_params[:service].is_a?(Hash)
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
new file mode 100644
index 00000000000..29e243c66a3
--- /dev/null
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -0,0 +1,25 @@
+module SpammableActions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authorize_submit_spammable!, only: :mark_as_spam
+ end
+
+ def mark_as_spam
+ if SpamService.new(spammable).mark_as_spam!
+ redirect_to spammable, notice: "#{spammable.class.to_s} was submitted to Akismet successfully."
+ else
+ redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.'
+ end
+ end
+
+ private
+
+ def spammable
+ raise NotImplementedError, "#{self.class} does not implement #{__method__}"
+ end
+
+ def authorize_submit_spammable!
+ access_denied! unless current_user.admin?
+ end
+end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 3ec173abcdb..7d0eff37635 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,5 +1,6 @@
class Import::GitlabProjectsController < Import::BaseController
before_action :verify_gitlab_project_import_enabled
+ before_action :authenticate_admin!
def new
@namespace_id = project_params[:namespace_id]
@@ -47,4 +48,8 @@ class Import::GitlabProjectsController < Import::BaseController
:path, :namespace_id, :file
)
end
+
+ def authenticate_admin!
+ render_404 unless current_user.is_admin?
+ end
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index a60027ff477..b5624046387 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -56,6 +56,7 @@ class Projects::HooksController < Projects::ApplicationController
def hook_params
params.require(:hook).permit(
:build_events,
+ :pipeline_events,
:enable_ssl_verification,
:issues_events,
:merge_requests_events,
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 660e0eba06f..e9fb11e8f94 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -4,6 +4,7 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableActions
include ToggleAwardEmoji
include IssuableCollections
+ include SpammableActions
before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
@@ -185,6 +186,7 @@ class Projects::IssuesController < Projects::ApplicationController
alias_method :subscribable_resource, :issue
alias_method :issuable, :issue
alias_method :awardable, :issue
+ alias_method :spammable, :issue
def authorize_read_issue!
return render_404 unless can?(current_user, :read_issue, @issue)
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index d28ec6e2eac..9a438d5512c 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def index
@protected_branch = @project.protected_branches.new
- load_protected_branches_gon_variables
+ load_gon_index
end
def create
- @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
+ @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
if @protected_branch.persisted?
redirect_to namespace_project_protected_branches_path(@project.namespace, @project)
else
load_protected_branches
- load_protected_branches_gon_variables
+ load_gon_index
render :index
end
end
@@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
end
def update
- @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
+ @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
if @protected_branch.valid?
respond_to do |format|
@@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def protected_branch_params
params.require(:protected_branch).permit(:name,
- merge_access_level_attributes: [:access_level],
- push_access_level_attributes: [:access_level])
+ merge_access_levels_attributes: [:access_level, :id],
+ push_access_levels_attributes: [:access_level, :id])
end
def load_protected_branches
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
end
- def load_protected_branches_gon_variables
- gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } },
- push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } },
- merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } })
+ def access_levels_options
+ {
+ push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
+ merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }
+ }
+ end
+
+ def load_gon_index
+ params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } }
+ gon.push(params.merge(access_levels_options))
end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
new file mode 100644
index 00000000000..694b468c8d3
--- /dev/null
+++ b/app/controllers/projects/templates_controller.rb
@@ -0,0 +1,19 @@
+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
diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb
new file mode 100644
index 00000000000..3334b8556df
--- /dev/null
+++ b/app/finders/move_to_project_finder.rb
@@ -0,0 +1,14 @@
+class MoveToProjectFinder
+ def initialize(user)
+ @user = user
+ end
+
+ def execute(from_project, search: nil, offset_id: nil)
+ projects = @user.projects_where_can_admin_issues
+ projects = projects.search(search) if search.present?
+ projects = projects.excluding_project(from_project)
+
+ # to ask for Project#name_with_namespace
+ projects.includes(namespace: :owner)
+ end
+end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 48c27828219..1cb5d847626 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -182,17 +182,42 @@ module BlobHelper
}
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
- @gitignore_names ||=
- Gitlab::Template::Gitignore.categories.keys.map do |k|
- [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }]
- end.to_h
+ @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names
end
def gitlab_ci_ymls
- @gitlab_ci_ymls ||=
- Gitlab::Template::GitlabCiYml.categories.keys.map do |k|
- [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }]
- end.to_h
+ @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3d6c6ea3209..4c84f4c21c5 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -344,7 +344,7 @@ module Ci
def execute_hooks
return unless project
- build_data = Gitlab::BuildDataBuilder.build(self)
+ build_data = Gitlab::DataBuilder::Build.build(self)
project.execute_hooks(build_data.dup, :build_hooks)
project.execute_services(build_data.dup, :build_hooks)
project.running_or_pending_build_count(force: true)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 8cfba92ae9b..130afeb724e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -19,6 +19,8 @@ module Ci
after_save :keep_around_commits
+ delegate :stages, to: :statuses
+
state_machine :status, initial: :created do
event :enqueue do
transition created: :pending
@@ -56,6 +58,10 @@ module Ci
before_transition do |pipeline|
pipeline.update_duration
end
+
+ after_transition do |pipeline, transition|
+ pipeline.execute_hooks unless transition.loopback?
+ end
end
# ref can't be HEAD or SHA, can only be branch/tag name
@@ -243,8 +249,18 @@ module Ci
self.duration = statuses.latest.duration
end
+ def execute_hooks
+ data = pipeline_data
+ project.execute_hooks(data, :pipeline_hooks)
+ project.execute_services(data, :pipeline_hooks)
+ end
+
private
+ def pipeline_data
+ Gitlab::DataBuilder::Pipeline.build(self)
+ end
+
def latest_builds_status
return 'failed' unless yaml_errors.blank?
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
new file mode 100644
index 00000000000..5a7b36070e7
--- /dev/null
+++ b/app/models/concerns/protected_branch_access.rb
@@ -0,0 +1,7 @@
+module ProtectedBranchAccess
+ extend ActiveSupport::Concern
+
+ def humanize
+ self.class.human_access_levels[self.access_level]
+ end
+end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 3b8e6df2da9..ce54fe5d3bf 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -1,9 +1,32 @@
module Spammable
extend ActiveSupport::Concern
+ module ClassMethods
+ def attr_spammable(attr, options = {})
+ spammable_attrs << [attr.to_s, options]
+ end
+ end
+
included do
+ has_one :user_agent_detail, as: :subject, dependent: :destroy
+
attr_accessor :spam
+
after_validation :check_for_spam, on: :create
+
+ cattr_accessor :spammable_attrs, instance_accessor: false do
+ []
+ end
+
+ delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true
+ end
+
+ def submittable_as_spam?
+ if user_agent_detail
+ user_agent_detail.submittable?
+ else
+ false
+ end
end
def spam?
@@ -13,4 +36,33 @@ module Spammable
def check_for_spam
self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam?
end
+
+ def spam_title
+ attr = self.class.spammable_attrs.find do |_, options|
+ options.fetch(:spam_title, false)
+ end
+
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ end
+
+ def spam_description
+ attr = self.class.spammable_attrs.find do |_, options|
+ options.fetch(:spam_description, false)
+ end
+
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ end
+
+ def spammable_text
+ result = self.class.spammable_attrs.map do |attr|
+ public_send(attr.first)
+ end
+
+ result.reject(&:blank?).join("\n")
+ end
+
+ # Override in Spammable if further checks are necessary
+ def check_for_spam?
+ true
+ end
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index ba42a8eeb70..836a75b0608 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -5,5 +5,6 @@ class ProjectHook < WebHook
scope :note_hooks, -> { where(note_events: true) }
scope :merge_request_hooks, -> { where(merge_requests_events: true) }
scope :build_hooks, -> { where(build_events: true) }
+ scope :pipeline_hooks, -> { where(pipeline_events: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true) }
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 8b87b6c3d64..f365dee3141 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -8,6 +8,7 @@ class WebHook < ActiveRecord::Base
default_value_for :merge_requests_events, false
default_value_for :tag_push_events, false
default_value_for :build_events, false
+ default_value_for :pipeline_events, false
default_value_for :enable_ssl_verification, true
scope :push_hooks, -> { where(push_events: true) }
diff --git a/app/models/issue.rb b/app/models/issue.rb
index d62ffb21467..788611305fe 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -36,6 +36,9 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
+ attr_spammable :title, spam_title: true
+ attr_spammable :description, spam_description: true
+
state_machine :state, initial: :opened do
event :close do
transition [:reopened, :opened] => :closed
@@ -262,4 +265,9 @@ class Issue < ActiveRecord::Base
def overdue?
due_date.try(:past?) || false
end
+
+ # Only issues on public projects should be checked for spam
+ def check_for_spam?
+ project.public?
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index e0b28160937..eefdae35615 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -197,6 +197,8 @@ class Project < ActiveRecord::Base
scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') }
scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) }
+ scope :excluding_project, ->(project) { where.not(id: project) }
+
state_machine :import_status, initial: :none do
event :import_start do
transition [:none, :finished] => :started
diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb
index 5e166471077..fa66e5864b8 100644
--- a/app/models/project_services/builds_email_service.rb
+++ b/app/models/project_services/builds_email_service.rb
@@ -51,8 +51,7 @@ class BuildsEmailService < Service
end
def test_data(project = nil, user = nil)
- build = project.builds.last
- Gitlab::BuildDataBuilder.build(build)
+ Gitlab::DataBuilder::Build.build(project.builds.last)
end
def fields
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 226b3f54342..6240912a6e1 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base
validates :name, presence: true
validates :project, presence: true
- has_one :merge_access_level, dependent: :destroy
- has_one :push_access_level, dependent: :destroy
+ has_many :merge_access_levels, dependent: :destroy
+ has_many :push_access_levels, dependent: :destroy
- accepts_nested_attributes_for :push_access_level
- accepts_nested_attributes_for :merge_access_level
+ validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch."
+ validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch."
+
+ accepts_nested_attributes_for :push_access_levels
+ accepts_nested_attributes_for :merge_access_levels
def commit
project.commit(self.name)
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index b1112ee737d..806b3ccd275 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,4 +1,6 @@
class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
+ include ProtectedBranchAccess
+
belongs_to :protected_branch
delegate :project, to: :protected_branch
@@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
project.team.max_member_access(user.id) >= access_level
end
-
- def humanize
- self.class.human_access_levels[self.access_level]
- end
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 6a5e49cf453..92e9c51d883 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,4 +1,6 @@
class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
+ include ProtectedBranchAccess
+
belongs_to :protected_branch
delegate :project, to: :protected_branch
@@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
project.team.max_member_access(user.id) >= access_level
end
-
- def humanize
- self.class.human_access_levels[self.access_level]
- end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index 40cd9b861f0..09b4717a523 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -36,6 +36,7 @@ class Service < ActiveRecord::Base
scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
scope :note_hooks, -> { where(note_events: true, active: true) }
scope :build_hooks, -> { where(build_events: true, active: true) }
+ scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
@@ -79,13 +80,17 @@ class Service < ActiveRecord::Base
end
def test_data(project, user)
- Gitlab::PushDataBuilder.build_sample(project, user)
+ Gitlab::DataBuilder::Push.build_sample(project, user)
end
def event_channel_names
[]
end
+ def event_names
+ supported_events.map { |event| "#{event}_events" }
+ end
+
def event_field(event)
nil
end
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index 12df68ef83b..3b8b9833565 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base
user.block
user.destroy
end
+
+ def text
+ [title, description].join("\n")
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 87a2d999843..48e83ab7e56 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -429,6 +429,13 @@ class User < ActiveRecord::Base
owned_groups.select(:id), namespace.id).joins(:namespace)
end
+ # Returns projects which user can admin issues on (for example to move an issue to that project).
+ #
+ # This logic is duplicated from `Ability#project_abilities` into a SQL form.
+ def projects_where_can_admin_issues
+ authorized_projects(Gitlab::Access::REPORTER).non_archived.where.not(issues_enabled: false)
+ end
+
def is_admin?
admin
end
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
new file mode 100644
index 00000000000..0949c6ef083
--- /dev/null
+++ b/app/models/user_agent_detail.rb
@@ -0,0 +1,9 @@
+class UserAgentDetail < ActiveRecord::Base
+ belongs_to :subject, polymorphic: true
+
+ validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
+
+ def submittable?
+ !submitted?
+ end
+end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
new file mode 100644
index 00000000000..5c60addbe7c
--- /dev/null
+++ b/app/services/akismet_service.rb
@@ -0,0 +1,79 @@
+class AkismetService
+ attr_accessor :owner, :text, :options
+
+ def initialize(owner, text, options = {})
+ @owner = owner
+ @text = text
+ @options = options
+ end
+
+ def is_spam?
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ created_at: DateTime.now,
+ author: owner.name,
+ author_email: owner.email,
+ referrer: options[:referrer],
+ }
+
+ begin
+ is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params)
+ is_spam || is_blatant
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check")
+ false
+ end
+ end
+
+ def submit_ham
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ author: owner.name,
+ author_email: owner.email
+ }
+
+ begin
+ akismet_client.submit_ham(options[:ip_address], options[:user_agent], params)
+ true
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
+ false
+ end
+ end
+
+ def submit_spam
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ author: owner.name,
+ author_email: owner.email
+ }
+
+ begin
+ akismet_client.submit_spam(options[:ip_address], options[:user_agent], params)
+ true
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
+ false
+ end
+ end
+
+ private
+
+ def akismet_client
+ @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
+ Gitlab.config.gitlab.url)
+ end
+
+ def akismet_enabled?
+ current_application_settings.akismet_enabled
+ end
+end
diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb
deleted file mode 100644
index 59a66fde47a..00000000000
--- a/app/services/create_spam_log_service.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-class CreateSpamLogService < BaseService
- def initialize(project, user, params)
- super(project, user, params)
- end
-
- def execute
- spam_params = params.merge({ user_id: @current_user.id,
- project_id: @project.id } )
- spam_log = SpamLog.new(spam_params)
- spam_log.save
- spam_log
- end
-end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 87f066edb6f..918eddaa53a 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -39,7 +39,12 @@ class DeleteBranchService < BaseService
end
def build_push_data(branch)
- Gitlab::PushDataBuilder
- .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", [])
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ branch.target.sha,
+ Gitlab::Git::BLANK_SHA,
+ "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}",
+ [])
end
end
diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb
index 32e0eed6b63..d0cb151a010 100644
--- a/app/services/delete_tag_service.rb
+++ b/app/services/delete_tag_service.rb
@@ -33,7 +33,12 @@ class DeleteTagService < BaseService
end
def build_push_data(tag)
- Gitlab::PushDataBuilder
- .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", [])
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ tag.target.sha,
+ Gitlab::Git::BLANK_SHA,
+ "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
+ [])
end
end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 6f521462cf3..78feb37aa2a 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -91,12 +91,12 @@ class GitPushService < BaseService
params = {
name: @project.default_branch,
- push_access_level_attributes: {
+ push_access_levels_attributes: [{
access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- },
- merge_access_level_attributes: {
+ }],
+ merge_access_levels_attributes: [{
access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }
+ }]
}
ProtectedBranches::CreateService.new(@project, current_user, params).execute
@@ -138,13 +138,23 @@ class GitPushService < BaseService
end
def build_push_data
- @push_data ||= Gitlab::PushDataBuilder.
- build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits)
+ @push_data ||= Gitlab::DataBuilder::Push.build(
+ @project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ push_commits)
end
def build_push_data_system_hook
- @push_data_system ||= Gitlab::PushDataBuilder.
- build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], [])
+ @push_data_system ||= Gitlab::DataBuilder::Push.build(
+ @project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ [])
end
def push_to_existing_branch?
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
index d2b52f16fa8..e6002b03b93 100644
--- a/app/services/git_tag_push_service.rb
+++ b/app/services/git_tag_push_service.rb
@@ -34,12 +34,24 @@ class GitTagPushService < BaseService
end
end
- Gitlab::PushDataBuilder.
- build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message)
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ commits,
+ message)
end
def build_system_push_data
- Gitlab::PushDataBuilder.
- build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '')
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ [],
+ '')
end
end
diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb
new file mode 100644
index 00000000000..b0e1799b489
--- /dev/null
+++ b/app/services/ham_service.rb
@@ -0,0 +1,26 @@
+class HamService
+ attr_accessor :spam_log
+
+ def initialize(spam_log)
+ @spam_log = spam_log
+ end
+
+ def mark_as_ham!
+ if akismet.submit_ham
+ spam_log.update_attribute(:submitted_as_ham, true)
+ else
+ false
+ end
+ end
+
+ private
+
+ def akismet
+ @akismet ||= AkismetService.new(
+ spam_log.user,
+ spam_log.text,
+ ip_address: spam_log.source_ip,
+ user_agent: spam_log.user_agent
+ )
+ end
+end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 5e2de2ccf64..65550ab8ec6 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -3,29 +3,34 @@ module Issues
def execute
filter_params
label_params = params.delete(:label_ids)
- request = params.delete(:request)
- api = params.delete(:api)
- issue = project.issues.new(params)
- issue.author = params[:author] || current_user
+ @request = params.delete(:request)
+ @api = params.delete(:api)
+ @issue = project.issues.new(params)
+ @issue.author = params[:author] || current_user
- issue.spam = spam_check_service.execute(request, api)
+ @issue.spam = spam_service.check(@api)
- if issue.save
- issue.update_attributes(label_ids: label_params)
- notification_service.new_issue(issue, current_user)
- todo_service.new_issue(issue, current_user)
- event_service.open_issue(issue, current_user)
- issue.create_cross_references!(current_user)
- execute_hooks(issue, 'open')
+ if @issue.save
+ @issue.update_attributes(label_ids: label_params)
+ notification_service.new_issue(@issue, current_user)
+ todo_service.new_issue(@issue, current_user)
+ event_service.open_issue(@issue, current_user)
+ user_agent_detail_service.create
+ @issue.create_cross_references!(current_user)
+ execute_hooks(@issue, 'open')
end
- issue
+ @issue
end
private
- def spam_check_service
- SpamCheckService.new(project, current_user, params)
+ def spam_service
+ SpamService.new(@issue, @request)
+ end
+
+ def user_agent_detail_service
+ UserAgentDetailService.new(@issue, @request)
end
end
end
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 534c48aefff..e4cd3fc7833 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -16,7 +16,7 @@ module Notes
end
def hook_data
- Gitlab::NoteDataBuilder.build(@note, @note.author)
+ Gitlab::DataBuilder::Note.build(@note, @note.author)
end
def execute_note_hooks
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index 6150a2a83c9..a84e335340d 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -5,23 +5,7 @@ module ProtectedBranches
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- protected_branch = project.protected_branches.new(params)
-
- ProtectedBranch.transaction do
- protected_branch.save!
-
- if protected_branch.push_access_level.blank?
- protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER)
- end
-
- if protected_branch.merge_access_level.blank?
- protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER)
- end
- end
-
- protected_branch
- rescue ActiveRecord::RecordInvalid
- protected_branch
+ project.protected_branches.create(params)
end
end
end
diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb
deleted file mode 100644
index 7c3e692bde9..00000000000
--- a/app/services/spam_check_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-class SpamCheckService < BaseService
- include Gitlab::AkismetHelper
-
- attr_accessor :request, :api
-
- def execute(request, api)
- @request, @api = request, api
- return false unless request || check_for_spam?(project)
- return false unless is_spam?(request.env, current_user, text)
-
- create_spam_log
-
- true
- end
-
- private
-
- def text
- [params[:title], params[:description]].reject(&:blank?).join("\n")
- end
-
- def spam_log_attrs
- {
- user_id: current_user.id,
- project_id: project.id,
- title: params[:title],
- description: params[:description],
- source_ip: client_ip(request.env),
- user_agent: user_agent(request.env),
- noteable_type: 'Issue',
- via_api: api
- }
- end
-
- def create_spam_log
- CreateSpamLogService.new(project, current_user, spam_log_attrs).execute
- end
-end
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
new file mode 100644
index 00000000000..48903291799
--- /dev/null
+++ b/app/services/spam_service.rb
@@ -0,0 +1,78 @@
+class SpamService
+ attr_accessor :spammable, :request, :options
+
+ def initialize(spammable, request = nil)
+ @spammable = spammable
+ @request = request
+ @options = {}
+
+ if @request
+ @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
+ @options[:user_agent] = @request.env['HTTP_USER_AGENT']
+ @options[:referrer] = @request.env['HTTP_REFERRER']
+ else
+ @options[:ip_address] = @spammable.ip_address
+ @options[:user_agent] = @spammable.user_agent
+ end
+ end
+
+ def check(api = false)
+ return false unless request && check_for_spam?
+
+ return false unless akismet.is_spam?
+
+ create_spam_log(api)
+ true
+ end
+
+ def mark_as_spam!
+ return false unless spammable.submittable_as_spam?
+
+ if akismet.submit_spam
+ spammable.user_agent_detail.update_attribute(:submitted, true)
+ else
+ false
+ end
+ end
+
+ private
+
+ def akismet
+ @akismet ||= AkismetService.new(
+ spammable_owner,
+ spammable.spammable_text,
+ options
+ )
+ end
+
+ def spammable_owner
+ @user ||= User.find(spammable_owner_id)
+ end
+
+ def spammable_owner_id
+ @owner_id ||=
+ if spammable.respond_to?(:author_id)
+ spammable.author_id
+ elsif spammable.respond_to?(:creator_id)
+ spammable.creator_id
+ end
+ end
+
+ def check_for_spam?
+ spammable.check_for_spam?
+ end
+
+ def create_spam_log(api)
+ SpamLog.create(
+ {
+ user_id: spammable_owner_id,
+ title: spammable.spam_title,
+ description: spammable.spam_description,
+ source_ip: options[:ip_address],
+ user_agent: options[:user_agent],
+ noteable_type: spammable.class.to_s,
+ via_api: api
+ }
+ )
+ end
+end
diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb
index e85e58751e7..280c81f7d2d 100644
--- a/app/services/test_hook_service.rb
+++ b/app/services/test_hook_service.rb
@@ -1,6 +1,6 @@
class TestHookService
def execute(hook, current_user)
- data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user)
+ data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user)
hook.execute(data, 'push_hooks')
end
end
diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb
new file mode 100644
index 00000000000..a1ee3df5fe1
--- /dev/null
+++ b/app/services/user_agent_detail_service.rb
@@ -0,0 +1,13 @@
+class UserAgentDetailService
+ attr_accessor :spammable, :request
+
+ def initialize(spammable, request)
+ @spammable, @request = spammable, request
+ end
+
+ def create
+ return unless request
+
+ spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s)
+ end
+end
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 8aea67f4497..4ce4eab8753 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -24,6 +24,11 @@
= link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true),
data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove"
%td
+ - if spam_log.submitted_as_ham?
+ .btn.btn-xs.disabled
+ Submitted as ham
+ - else
+ = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning'
- if user && !user.blocked?
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
- else
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 98f302d2f93..b40395c74de 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -1,6 +1,7 @@
%li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} }
+ = author_avatar(todo, size: 40)
+
.todo-item.todo-block
- = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:''
.todo-title.title
- unless todo.build_failed?
= todo_target_state_pill(todo)
@@ -19,13 +20,13 @@
· #{time_ago_with_tooltip(todo.created_at)}
- - if todo.pending?
- .todo-actions.pull-right
- = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do
- Done
- = icon('spinner spin')
-
.todo-body
.todo-note
.md
= event_note(todo.body, project: todo.project)
+
+ - if todo.pending?
+ .todo-actions
+ = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do
+ Done
+ = icon('spinner spin')
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index ff379bafb26..0237e152b54 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -24,7 +24,7 @@
.encoding-selector
= select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2'
- .file-content.code
+ .file-editor.code
%pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]}
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml
index 8151187d499..3fcf1692e09 100644
--- a/app/views/projects/hooks/_project_hook.html.haml
+++ b/app/views/projects/hooks/_project_hook.html.haml
@@ -3,7 +3,7 @@
.col-md-8.col-lg-7
%strong.light-header= hook.url
%div
- - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events wiki_page_events).each do |trigger|
+ - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger|
- if hook.send(trigger)
%span.label.label-gray.deploy-project-label= trigger.titleize
.col-md-4.col-lg-5.text-right-lg.prepend-top-5
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index e5cce16a171..9f1a046ea74 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -37,14 +37,19 @@
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
+ - if @issue.submittable_as_spam? && current_user.admin?
+ %li
+ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
+
- if can?(current_user, :create_issue, @project)
= link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue
- if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do
- Edit
+ - if @issue.submittable_as_spam? && current_user.admin?
+ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index adcc984f506..ea4898f2107 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -77,7 +77,7 @@
= link_to "#", class: 'btn js-toggle-button import_git' do
= icon('git', text: 'Repo by URL')
%div{ class: 'import_gitlab_project' }
- - if gitlab_project_import_enabled?
+ - if gitlab_project_import_enabled? && current_user.is_admin?
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
= icon('gitlab', text: 'GitLab export')
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index 85d0c494ba8..d4c6fa24768 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -5,6 +5,7 @@
Protect a branch
.panel-body
.form-horizontal
+ = form_errors(@protected_branch)
.form-group
= f.label :name, class: 'col-md-2 text-right' do
Branch:
@@ -18,19 +19,19 @@
%code production/*
are supported
.form-group
- %label.col-md-2.text-right{ for: 'merge_access_level_attributes' }
+ %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
Allowed to merge:
.col-md-10
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-merge wide',
- data: { field_name: 'protected_branch[merge_access_level_attributes][access_level]', input_id: 'merge_access_level_attributes' }})
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
.form-group
- %label.col-md-2.text-right{ for: 'push_access_level_attributes' }
+ %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
Allowed to push:
.col-md-10
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-push wide',
- data: { field_name: 'protected_branch[push_access_level_attributes][access_level]', input_id: 'push_access_level_attributes' }})
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
.panel-footer
= f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index e2e01ee78f8..0628134b1bb 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -13,16 +13,9 @@
= time_ago_with_tooltip(commit.committed_date)
- else
(branch was removed from repository)
- %td
- = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level
- = dropdown_tag( (protected_branch.merge_access_level.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container',
- data: { field_name: "allowed_to_merge_#{protected_branch.id}" }})
- %td
- = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level
- = dropdown_tag( (protected_branch.push_access_level.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container',
- data: { field_name: "allowed_to_push_#{protected_branch.id}" }})
+
+ = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch }
+
- if can_admin_project
%td
= link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml
new file mode 100644
index 00000000000..d6044aacaec
--- /dev/null
+++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml
@@ -0,0 +1,10 @@
+%td
+ = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level
+ = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container',
+ data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }})
+%td
+ = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level
+ = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container',
+ data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }})
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 0b7fa8c7d06..c957cd84479 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -45,7 +45,7 @@
.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 } })
.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
= 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
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c30bdb0ae91..210b43c7e0b 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -2,7 +2,22 @@
.form-group
= 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',
class: 'form-control pad', required: true
@@ -23,6 +38,13 @@
to prevent a
%strong Work In Progress
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
= f.label :description, 'Description', class: 'control-label'
.col-sm-10
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 470dac6d75b..d2ec6c3ddef 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -29,49 +29,56 @@
= f.label :push_events, class: 'list-label' do
%strong Push events
%p.light
- This url will be triggered by a push to the repository
+ This URL will be triggered by a push to the repository
%li
= f.check_box :tag_push_events, class: 'pull-left'
.prepend-left-20
= f.label :tag_push_events, class: 'list-label' do
%strong Tag push events
%p.light
- This url will be triggered when a new tag is pushed to the repository
+ This URL will be triggered when a new tag is pushed to the repository
%li
= f.check_box :note_events, class: 'pull-left'
.prepend-left-20
= f.label :note_events, class: 'list-label' do
%strong Comments
%p.light
- This url will be triggered when someone adds a comment
+ This URL will be triggered when someone adds a comment
%li
= f.check_box :issues_events, class: 'pull-left'
.prepend-left-20
= f.label :issues_events, class: 'list-label' do
%strong Issues events
%p.light
- This url will be triggered when an issue is created/updated/merged
+ This URL will be triggered when an issue is created/updated/merged
%li
= f.check_box :merge_requests_events, class: 'pull-left'
.prepend-left-20
= f.label :merge_requests_events, class: 'list-label' do
%strong Merge Request events
%p.light
- This url will be triggered when a merge request is created/updated/merged
+ This URL will be triggered when a merge request is created/updated/merged
%li
= f.check_box :build_events, class: 'pull-left'
.prepend-left-20
= f.label :build_events, class: 'list-label' do
%strong Build events
%p.light
- This url will be triggered when the build status changes
+ This URL will be triggered when the build status changes
+ %li
+ = f.check_box :pipeline_events, class: 'pull-left'
+ .prepend-left-20
+ = f.label :pipeline_events, class: 'list-label' do
+ %strong Pipeline events
+ %p.light
+ This URL will be triggered when the pipeline status changes
%li
= f.check_box :wiki_page_events, class: 'pull-left'
.prepend-left-20
= f.label :wiki_page_events, class: 'list-label' do
%strong Wiki Page events
%p.light
- This url will be triggered when a wiki page is created/updated
+ This URL will be triggered when a wiki page is created/updated
.form-group
= f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox'
.checkbox
diff --git a/config/routes.rb b/config/routes.rb
index 46a05d9a1a9..343c1b614ed 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -252,7 +252,11 @@ Rails.application.routes.draw do
resource :impersonation, only: :destroy
resources :abuse_reports, only: [:index, :destroy]
- resources :spam_logs, only: [:index, :destroy]
+ resources :spam_logs, only: [:index, :destroy] do
+ member do
+ post :mark_as_ham
+ end
+ end
resources :applications
@@ -524,6 +528,11 @@ Rails.application.routes.draw do
put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob'
post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob'
+ #
+ # Templates
+ #
+ get '/templates/:template_type/:key' => 'templates#show', as: :template
+
scope do
get(
'/blob/*id/diff',
@@ -815,6 +824,7 @@ Rails.application.routes.draw do
member do
post :toggle_subscription
post :toggle_award_emoji
+ post :mark_as_spam
get :referenced_merge_requests
get :related_branches
get :can_create_branch
diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb
new file mode 100644
index 00000000000..ed4ccfedc0a
--- /dev/null
+++ b/db/migrate/20160727163552_create_user_agent_details.rb
@@ -0,0 +1,18 @@
+class CreateUserAgentDetails < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ create_table :user_agent_details do |t|
+ t.string :user_agent, null: false
+ t.string :ip_address, null: false
+ t.integer :subject_id, null: false
+ t.string :subject_type, null: false
+ t.boolean :submitted, default: false, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb
new file mode 100644
index 00000000000..b800e6d7283
--- /dev/null
+++ b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb
@@ -0,0 +1,16 @@
+class AddPipelineEventsToWebHooks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:web_hooks, :pipeline_events, :boolean,
+ default: false, allow_null: false)
+ end
+
+ def down
+ remove_column(:web_hooks, :pipeline_events)
+ end
+end
diff --git a/db/migrate/20160728103734_add_pipeline_events_to_services.rb b/db/migrate/20160728103734_add_pipeline_events_to_services.rb
new file mode 100644
index 00000000000..bcd24fe1566
--- /dev/null
+++ b/db/migrate/20160728103734_add_pipeline_events_to_services.rb
@@ -0,0 +1,16 @@
+class AddPipelineEventsToServices < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:services, :pipeline_events, :boolean,
+ default: false, allow_null: false)
+ end
+
+ def down
+ remove_column(:services, :pipeline_events)
+ end
+end
diff --git a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb
new file mode 100644
index 00000000000..e28ab31d629
--- /dev/null
+++ b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb
@@ -0,0 +1,29 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = true
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ DOWNTIME_REASON = 'Removing a column that contains data that is not used anywhere.'
+
+ # When using the methods "add_concurrent_index" or "add_column_with_default"
+ # you must disable the use of transactions as these methods can not run in an
+ # existing transaction. When using "add_concurrent_index" make sure that this
+ # method is the _only_ method called in the migration, any other changes
+ # should go in a separate migration. This ensures that upon failure _only_ the
+ # index creation fails and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def change
+ remove_column :spam_logs, :project_id, :integer
+ end
+end
diff --git a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb
new file mode 100644
index 00000000000..296f1dfac7b
--- /dev/null
+++ b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb
@@ -0,0 +1,20 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddSubmittedAsHamToSpamLogs < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ # DOWNTIME_REASON = ''
+
+ disable_ddl_transaction!
+
+ def change
+ add_column_with_default :spam_logs, :submitted_as_ham, :boolean, default: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f008a6bd7a7..445f484a8c7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -897,6 +897,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.string "category", default: "common", null: false
t.boolean "default", default: false
t.boolean "wiki_page_events", default: true
+ t.boolean "pipeline_events", default: false, null: false
end
add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree
@@ -926,12 +927,12 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.string "source_ip"
t.string "user_agent"
t.boolean "via_api"
- t.integer "project_id"
t.string "noteable_type"
t.string "title"
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "submitted_as_ham", default: false, null: false
end
create_table "subscriptions", force: :cascade do |t|
@@ -999,6 +1000,16 @@ ActiveRecord::Schema.define(version: 20160810142633) do
add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree
add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree
+ create_table "user_agent_details", force: :cascade do |t|
+ t.string "user_agent", null: false
+ t.string "ip_address", null: false
+ t.integer "subject_id", null: false
+ t.string "subject_type", null: false
+ t.boolean "submitted", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@@ -1100,6 +1111,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.boolean "build_events", default: false, null: false
t.boolean "wiki_page_events", default: false, null: false
t.string "token"
+ t.boolean "pipeline_events", default: false, null: false
end
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
diff --git a/doc/development/README.md b/doc/development/README.md
index bf67b5d8dff..57f37da6f80 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -30,7 +30,11 @@
- [Rake tasks](rake_tasks.md) for development
- [Shell commands](shell_commands.md) in the GitLab codebase
- [Sidekiq debugging](sidekiq_debugging.md)
+
+## Databases
+
- [What requires downtime?](what_requires_downtime.md)
+- [Adding database indexes](adding_database_indexes.md)
## Compliance
diff --git a/doc/development/adding_database_indexes.md b/doc/development/adding_database_indexes.md
new file mode 100644
index 00000000000..ea6f14da3b9
--- /dev/null
+++ b/doc/development/adding_database_indexes.md
@@ -0,0 +1,123 @@
+# Adding Database Indexes
+
+Indexes can be used to speed up database queries, but when should you add a new
+index? Traditionally the answer to this question has been to add an index for
+every column used for filtering or joining data. For example, consider the
+following query:
+
+```sql
+SELECT *
+FROM projects
+WHERE user_id = 2;
+```
+
+Here we are filtering by the `user_id` column and as such a developer may decide
+to index this column.
+
+While in certain cases indexing columns using the above approach may make sense
+it can actually have a negative impact. Whenever you write data to a table any
+existing indexes need to be updated. The more indexes there are the slower this
+can potentially become. Indexes can also take up quite some disk space depending
+on the amount of data indexed and the index type. For example, PostgreSQL offers
+"GIN" indexes which can be used to index certain data types that can not be
+indexed by regular btree indexes. These indexes however generally take up more
+data and are slower to update compared to btree indexes.
+
+Because of all this one should not blindly add a new index for every column used
+to filter data by. Instead one should ask themselves the following questions:
+
+1. Can I write my query in such a way that it re-uses as many existing indexes
+ as possible?
+2. Is the data going to be large enough that using an index will actually be
+ faster than just iterating over the rows in the table?
+3. Is the overhead of maintaining the index worth the reduction in query
+ timings?
+
+We'll explore every question in detail below.
+
+## Re-using Queries
+
+The first step is to make sure your query re-uses as many existing indexes as
+possible. For example, consider the following query:
+
+```sql
+SELECT *
+FROM todos
+WHERE user_id = 123
+AND state = 'open';
+```
+
+Now imagine we already have an index on the `user_id` column but not on the
+`state` column. One may think this query will perform badly due to `state` being
+unindexed. In reality the query may perform just fine given the index on
+`user_id` can filter out enough rows.
+
+The best way to determine if indexes are re-used is to run your query using
+`EXPLAIN ANALYZE`. Depending on any extra tables that may be joined and
+other columns being used for filtering you may find an extra index is not going
+to make much (if any) difference. On the other hand you may determine that the
+index _may_ make a difference.
+
+In short:
+
+1. Try to write your query in such a way that it re-uses as many existing
+ indexes as possible.
+2. Run the query using `EXPLAIN ANALYZE` and study the output to find the most
+ ideal query.
+
+## Data Size
+
+A database may decide not to use an index despite it existing in case a regular
+sequence scan (= simply iterating over all existing rows) is faster. This is
+especially the case for small tables.
+
+If a table is expected to grow in size and you expect your query has to filter
+out a lot of rows you may want to consider adding an index. If the table size is
+very small (e.g. only a handful of rows) or any existing indexes filter out
+enough rows you may _not_ want to add a new index.
+
+## Maintenance Overhead
+
+Indexes have to be updated on every table write. In case of PostgreSQL _all_
+existing indexes will be updated whenever data is written to a table. As a
+result of this having many indexes on the same table will slow down writes.
+
+Because of this one should ask themselves: is the reduction in query performance
+worth the overhead of maintaining an extra index?
+
+If adding an index reduces SELECT timings by 5 milliseconds but increases
+INSERT/UPDATE/DELETE timings by 10 milliseconds then the index may not be worth
+it. On the other hand, if SELECT timings are reduced but INSERT/UPDATE/DELETE
+timings are not affected you may want to add the index after all.
+
+## Finding Unused Indexes
+
+To see which indexes are unused you can run the following query:
+
+```sql
+SELECT relname as table_name, indexrelname as index_name, idx_scan, idx_tup_read, idx_tup_fetch, pg_size_pretty(pg_relation_size(indexrelname::regclass))
+FROM pg_stat_all_indexes
+WHERE schemaname = 'public'
+AND idx_scan = 0
+AND idx_tup_read = 0
+AND idx_tup_fetch = 0
+ORDER BY pg_relation_size(indexrelname::regclass) desc;
+```
+
+This query outputs a list containing all indexes that are never used and sorts
+them by indexes sizes in descending order. This query can be useful to
+determine if any previously indexes are useful after all. More information on
+the meaning of the various columns can be found at
+.
+
+Because the output of this query relies on the actual usage of your database it
+may be affected by factors such as (but not limited to):
+
+* Certain queries never being executed, thus not being able to use certain
+ indexes.
+* Certain tables having little data, resulting in PostgreSQL using sequence
+ scans instead of index scans.
+
+In other words, this data is only reliable for a frequently used database with
+plenty of data and with as many GitLab features enabled (and being used) as
+possible.
diff --git a/doc/development/ui_guide.md b/doc/development/ui_guide.md
index 3a8c823e026..2d1d504202c 100644
--- a/doc/development/ui_guide.md
+++ b/doc/development/ui_guide.md
@@ -15,11 +15,14 @@ repository and maintained by GitLab UX designers.
## Navigation
GitLab's layout contains 2 sections: the left sidebar and the content. The left sidebar contains a static navigation menu.
-This menu will be visible regardless of what page you visit. The left sidebar also contains the GitLab logo
-and the current user's profile picture. The content section contains a header and the content itself.
-The header describes the current GitLab page and what navigation is
-available to user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example when user visits one of the
-project pages the header will contain a project name and navigation for that project. When the user visits a group page it will contain a group name and navigation related to this group.
+This menu will be visible regardless of what page you visit.
+The content section contains a header and the content itself. The header describes the current GitLab page and what navigation is
+available to the user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example, when the user visits one of the
+project pages the header will contain the project's name and navigation for that project. When the user visits a group page it will contain the group's name and navigation related to this group.
+
+You can see a visual representation of the navigation in GitLab in the GitLab Product Map, which is located in the [Design Repository](gitlab-map-graffle)
+along with [PDF](gitlab-map-pdf) and [PNG](gitlab-map-png) exports.
+
### Adding new tab to header navigation
@@ -99,3 +102,6 @@ Do not use both green and blue button in one form.
display counts in the UI.
[number_with_delimiter]: http://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_with_delimiter
+[gitlab-map-graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/master/production/resources/gitlab-map.graffle
+[gitlab-map-pdf]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.pdf
+[gitlab-map-png]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png
\ No newline at end of file
diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md
index c222d21612f..a6436b5f926 100644
--- a/doc/integration/akismet.md
+++ b/doc/integration/akismet.md
@@ -22,14 +22,37 @@ To use Akismet:
2. Sign-in or create a new account.
-3. Click on "Show" to reveal the API key.
+3. Click on **Show** to reveal the API key.
4. Go to Applications Settings on Admin Area (`admin/application_settings`)
-5. Check the `Enable Akismet` checkbox
+5. Check the **Enable Akismet** checkbox
6. Fill in the API key from step 3.
7. Save the configuration.
![Screenshot of Akismet settings](img/akismet_settings.png)
+
+
+## Training
+
+> *Note:* Training the Akismet filter is only available in 8.11 and above.
+
+As a way to better recognize between spam and ham, you can train the Akismet
+filter whenever there is a false positive or false negative.
+
+When an entry is recognized as spam, it is rejected and added to the Spam Logs.
+From here you can review if they are really spam. If one of them is not really
+spam, you can use the **Submit as ham** button to tell Akismet that it falsely
+recognized an entry as spam.
+
+![Screenshot of Spam Logs](img/spam_log.png)
+
+If an entry that is actually spam was not recognized as such, you will be able
+to also submit this to Akismet. The **Submit as spam** button will only appear
+to admin users.
+
+![Screenshot of Issue](img/submit_issue.png)
+
+Training Akismet will help it to recognize spam more accurately in the future.
diff --git a/doc/integration/img/spam_log.png b/doc/integration/img/spam_log.png
new file mode 100644
index 00000000000..8d574448690
Binary files /dev/null and b/doc/integration/img/spam_log.png differ
diff --git a/doc/integration/img/submit_issue.png b/doc/integration/img/submit_issue.png
new file mode 100644
index 00000000000..5c7896a7eec
Binary files /dev/null and b/doc/integration/img/submit_issue.png differ
diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md
index 2513def49a4..08ff89ce6ae 100644
--- a/doc/user/project/settings/import_export.md
+++ b/doc/user/project/settings/import_export.md
@@ -7,8 +7,7 @@
> than that of the exporter.
> - For existing installations, the project import option has to be enabled in
> application settings (`/admin/application_settings`) under 'Import sources'.
-> Ask your administrator if you don't see the **GitLab export** button when
-> creating a new project.
+> You will have to be an administrator to enable and use the import functionality.
> - You can find some useful raketasks if you are an administrator in the
> [import_export](../../../administration/raketasks/project_import_export.md)
> raketask.
diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md
index d4b28d875cd..33c1a79d59c 100644
--- a/doc/web_hooks/web_hooks.md
+++ b/doc/web_hooks/web_hooks.md
@@ -754,6 +754,174 @@ X-Gitlab-Event: Wiki Page Hook
}
```
+## Pipeline events
+
+Triggered on status change of Pipeline.
+
+**Request Header**:
+
+```
+X-Gitlab-Event: Pipeline Hook
+```
+
+**Request Body**:
+
+```json
+{
+ "object_kind": "pipeline",
+ "object_attributes":{
+ "id": 31,
+ "ref": "master",
+ "tag": false,
+ "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+ "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+ "status": "success",
+ "stages":[
+ "build",
+ "test",
+ "deploy"
+ ],
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "finished_at": "2016-08-12 15:26:29 UTC",
+ "duration": 63
+ },
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "project":{
+ "name": "Gitlab Test",
+ "description": "Atque in sunt eos similique dolores voluptatem.",
+ "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test",
+ "avatar_url": null,
+ "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git",
+ "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git",
+ "namespace": "Gitlab Org",
+ "visibility_level": 20,
+ "path_with_namespace": "gitlab-org/gitlab-test",
+ "default_branch": "master"
+ },
+ "commit":{
+ "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+ "message": "test\n",
+ "timestamp": "2016-08-12T17:23:21+02:00",
+ "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+ "author":{
+ "name": "User",
+ "email": "user@gitlab.com"
+ }
+ },
+ "builds":[
+ {
+ "id": 380,
+ "stage": "deploy",
+ "name": "production",
+ "status": "skipped",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "started_at": null,
+ "finished_at": null,
+ "when": "manual",
+ "manual": true,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "runner": null,
+ "artifacts_file":{
+ "filename": null,
+ "size": null
+ }
+ },
+ {
+ "id": 377,
+ "stage": "test",
+ "name": "test-image",
+ "status": "success",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "started_at": "2016-08-12 15:26:12 UTC",
+ "finished_at": null,
+ "when": "on_success",
+ "manual": false,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "runner": null,
+ "artifacts_file":{
+ "filename": null,
+ "size": null
+ }
+ },
+ {
+ "id": 378,
+ "stage": "test",
+ "name": "test-build",
+ "status": "success",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "started_at": "2016-08-12 15:26:12 UTC",
+ "finished_at": "2016-08-12 15:26:29 UTC",
+ "when": "on_success",
+ "manual": false,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "runner": null,
+ "artifacts_file":{
+ "filename": null,
+ "size": null
+ }
+ },
+ {
+ "id": 376,
+ "stage": "build",
+ "name": "build-image",
+ "status": "success",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "started_at": "2016-08-12 15:24:56 UTC",
+ "finished_at": "2016-08-12 15:25:26 UTC",
+ "when": "on_success",
+ "manual": false,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "runner": null,
+ "artifacts_file":{
+ "filename": null,
+ "size": null
+ }
+ },
+ {
+ "id": 379,
+ "stage": "deploy",
+ "name": "staging",
+ "status": "created",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "started_at": null,
+ "finished_at": null,
+ "when": "on_success",
+ "manual": false,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon"
+ },
+ "runner": null,
+ "artifacts_file":{
+ "filename": null,
+ "size": null
+ }
+ }
+ ]
+}
+```
+
#### Example webhook receiver
If you want to see GitLab's webhooks in action for testing purposes you can use
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 49dec613716..993349e5b46 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -17,6 +17,7 @@
- [Share projects with other groups](share_projects_with_other_groups.md)
- [Web Editor](web_editor.md)
- [Releases](releases.md)
+- [Issuable Templates](issuable_templates.md)
- [Milestones](milestones.md)
- [Merge Requests](merge_requests.md)
- [Revert changes](revert_changes.md)
diff --git a/doc/workflow/description_templates.md b/doc/workflow/description_templates.md
new file mode 100644
index 00000000000..9514564af02
--- /dev/null
+++ b/doc/workflow/description_templates.md
@@ -0,0 +1,12 @@
+# 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.
diff --git a/doc/workflow/img/description_templates.png b/doc/workflow/img/description_templates.png
new file mode 100644
index 00000000000..af2e9403826
Binary files /dev/null and b/doc/workflow/img/description_templates.png differ
diff --git a/features/dashboard/new_project.feature b/features/dashboard/new_project.feature
index 8ddafb6a7ac..046e2815d4e 100644
--- a/features/dashboard/new_project.feature
+++ b/features/dashboard/new_project.feature
@@ -9,7 +9,7 @@ Background:
@javascript
Scenario: I should see New Projects page
Then I see "New Project" page
- Then I see all possible import optios
+ Then I see all possible import options
@javascript
Scenario: I should see instructions on how to import from Git URL
diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb
index dcfa88f69fc..f0d8d498e46 100644
--- a/features/steps/dashboard/new_project.rb
+++ b/features/steps/dashboard/new_project.rb
@@ -14,14 +14,13 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps
expect(page).to have_content('Project name')
end
- step 'I see all possible import optios' do
+ step 'I see all possible import options' do
expect(page).to have_link('GitHub')
expect(page).to have_link('Bitbucket')
expect(page).to have_link('GitLab.com')
expect(page).to have_link('Gitorious.org')
expect(page).to have_link('Google Code')
expect(page).to have_link('Repo by URL')
- expect(page).to have_link('GitLab export')
end
step 'I click on "Import project from GitHub"' do
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index a77afe634f6..b615703df93 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -61,22 +61,27 @@ module API
name: @branch.name
}
- unless developers_can_merge.nil?
- protected_branch_params.merge!({
- merge_access_level_attributes: {
- access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }
- })
+ # If `developers_can_merge` is switched off, _all_ `DEVELOPER`
+ # merge_access_levels need to be deleted.
+ if developers_can_merge == false
+ protected_branch.merge_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all
end
- unless developers_can_push.nil?
- protected_branch_params.merge!({
- push_access_level_attributes: {
- access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }
- })
+ # If `developers_can_push` is switched off, _all_ `DEVELOPER`
+ # push_access_levels need to be deleted.
+ if developers_can_push == false
+ protected_branch.push_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all
end
+ protected_branch_params.merge!(
+ merge_access_levels_attributes: [{
+ access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }],
+ push_access_levels_attributes: [{
+ access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }]
+ )
+
if protected_branch
service = ProtectedBranches::UpdateService.new(user_project, current_user, protected_branch_params)
service.execute(protected_branch)
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index ae74d14a4bb..055716ab1e3 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -48,7 +48,8 @@ module API
class ProjectHook < Hook
expose :project_id, :push_events
- expose :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events
+ expose :issues_events, :merge_requests_events, :tag_push_events
+ expose :note_events, :build_events, :pipeline_events
expose :enable_ssl_verification
end
@@ -129,12 +130,14 @@ module API
expose :developers_can_push do |repo_branch, options|
project = options[:project]
- project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_level.access_level == Gitlab::Access::DEVELOPER }
+ access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten
+ access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER }
end
expose :developers_can_merge do |repo_branch, options|
project = options[:project]
- project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_level.access_level == Gitlab::Access::DEVELOPER }
+ access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten
+ access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER }
end
end
@@ -344,7 +347,8 @@ module API
class ProjectService < Grape::Entity
expose :id, :title, :created_at, :updated_at, :active
- expose :push_events, :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events
+ expose :push_events, :issues_events, :merge_requests_events
+ expose :tag_push_events, :note_events, :build_events, :pipeline_events
# Expose serialized properties
expose :properties do |service, options|
field_names = service.fields.
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index c4d3134da6c..077258faee1 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -3,8 +3,6 @@ module API
class Issues < Grape::API
before { authenticate! }
- helpers ::Gitlab::AkismetHelper
-
helpers do
def filter_issues_state(issues, state)
case state
diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb
index 6bb70bc8bc3..3f63cd678e8 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -45,6 +45,7 @@ module API
:tag_push_events,
:note_events,
:build_events,
+ :pipeline_events,
:enable_ssl_verification
]
@hook = user_project.hooks.new(attrs)
@@ -78,6 +79,7 @@ module API
:tag_push_events,
:note_events,
:build_events,
+ :pipeline_events,
:enable_ssl_verification
]
diff --git a/lib/api/templates.rb b/lib/api/templates.rb
index 18408797756..b9e718147e1 100644
--- a/lib/api/templates.rb
+++ b/lib/api/templates.rb
@@ -1,21 +1,28 @@
module API
class Templates < Grape::API
- TEMPLATE_TYPES = {
- gitignores: Gitlab::Template::Gitignore,
- gitlab_ci_ymls: Gitlab::Template::GitlabCiYml
+ GLOBAL_TEMPLATE_TYPES = {
+ gitignores: Gitlab::Template::GitignoreTemplate,
+ gitlab_ci_ymls: Gitlab::Template::GitlabCiYmlTemplate
}.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
#
# Example Request:
# GET /gitignores
# GET /gitlab_ci_ymls
- get template.to_s do
+ get template_type.to_s do
present klass.all, with: Entities::TemplatesList
end
- # Get the text for a specific template
+ # Get the text for a specific template present in local filesystem
#
# Parameters:
# name (required) - The name of a template
@@ -23,13 +30,10 @@ module API
# Example Request:
# GET /gitignores/Elixir
# GET /gitlab_ci_ymls/Ruby
- get "#{template}/:name" do
+ get "#{template_type}/:name" do
required_attributes! [:name]
-
new_template = klass.find(params[:name])
- not_found!(template.to_s.singularize) unless new_template
-
- present new_template, with: Entities::Template
+ render_response(template_type, new_template)
end
end
end
diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb
deleted file mode 100644
index 207736b59db..00000000000
--- a/lib/gitlab/akismet_helper.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-module Gitlab
- module AkismetHelper
- def akismet_enabled?
- current_application_settings.akismet_enabled
- end
-
- def akismet_client
- @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
- Gitlab.config.gitlab.url)
- end
-
- def client_ip(env)
- env['action_dispatch.remote_ip'].to_s
- end
-
- def user_agent(env)
- env['HTTP_USER_AGENT']
- end
-
- def check_for_spam?(project)
- akismet_enabled? && project.public?
- end
-
- def is_spam?(environment, user, text)
- client = akismet_client
- ip_address = client_ip(environment)
- user_agent = user_agent(environment)
-
- params = {
- type: 'comment',
- text: text,
- created_at: DateTime.now,
- author: user.name,
- author_email: user.email,
- referrer: environment['HTTP_REFERER'],
- }
-
- begin
- is_spam, is_blatant = client.check(ip_address, user_agent, params)
- is_spam || is_blatant
- rescue => e
- Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check")
- false
- end
- end
- end
-end
diff --git a/lib/gitlab/build_data_builder.rb b/lib/gitlab/data_builder/build.rb
similarity index 96%
rename from lib/gitlab/build_data_builder.rb
rename to lib/gitlab/data_builder/build.rb
index 9f45aefda0f..6548e6475c6 100644
--- a/lib/gitlab/build_data_builder.rb
+++ b/lib/gitlab/data_builder/build.rb
@@ -1,6 +1,8 @@
module Gitlab
- class BuildDataBuilder
- class << self
+ module DataBuilder
+ module Build
+ extend self
+
def build(build)
project = build.project
commit = build.pipeline
diff --git a/lib/gitlab/note_data_builder.rb b/lib/gitlab/data_builder/note.rb
similarity index 97%
rename from lib/gitlab/note_data_builder.rb
rename to lib/gitlab/data_builder/note.rb
index 8bdc89a7751..50fea1232af 100644
--- a/lib/gitlab/note_data_builder.rb
+++ b/lib/gitlab/data_builder/note.rb
@@ -1,6 +1,8 @@
module Gitlab
- class NoteDataBuilder
- class << self
+ module DataBuilder
+ module Note
+ extend self
+
# Produce a hash of post-receive data
#
# For all notes:
diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb
new file mode 100644
index 00000000000..06a783ebc1c
--- /dev/null
+++ b/lib/gitlab/data_builder/pipeline.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ module DataBuilder
+ module Pipeline
+ extend self
+
+ def build(pipeline)
+ {
+ object_kind: 'pipeline',
+ object_attributes: hook_attrs(pipeline),
+ user: pipeline.user.try(:hook_attrs),
+ project: pipeline.project.hook_attrs(backward: false),
+ commit: pipeline.commit.try(:hook_attrs),
+ builds: pipeline.builds.map(&method(:build_hook_attrs))
+ }
+ end
+
+ def hook_attrs(pipeline)
+ {
+ id: pipeline.id,
+ ref: pipeline.ref,
+ tag: pipeline.tag,
+ sha: pipeline.sha,
+ before_sha: pipeline.before_sha,
+ status: pipeline.status,
+ stages: pipeline.stages,
+ created_at: pipeline.created_at,
+ finished_at: pipeline.finished_at,
+ duration: pipeline.duration
+ }
+ end
+
+ def build_hook_attrs(build)
+ {
+ id: build.id,
+ stage: build.stage,
+ name: build.name,
+ status: build.status,
+ created_at: build.created_at,
+ started_at: build.started_at,
+ finished_at: build.finished_at,
+ when: build.when,
+ manual: build.manual?,
+ user: build.user.try(:hook_attrs),
+ runner: build.runner && runner_hook_attrs(build.runner),
+ artifacts_file: {
+ filename: build.artifacts_file.filename,
+ size: build.artifacts_size
+ }
+ }
+ end
+
+ def runner_hook_attrs(runner)
+ {
+ id: runner.id,
+ description: runner.description,
+ active: runner.active?,
+ is_shared: runner.is_shared?
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/push_data_builder.rb b/lib/gitlab/data_builder/push.rb
similarity index 98%
rename from lib/gitlab/push_data_builder.rb
rename to lib/gitlab/data_builder/push.rb
index c8f12577112..4f81863da35 100644
--- a/lib/gitlab/push_data_builder.rb
+++ b/lib/gitlab/data_builder/push.rb
@@ -1,6 +1,8 @@
module Gitlab
- class PushDataBuilder
- class << self
+ module DataBuilder
+ module Push
+ extend self
+
# Produce a hash of post-receive data
#
# data = {
diff --git a/lib/gitlab/downtime_check/message.rb b/lib/gitlab/downtime_check/message.rb
index 4446e921e0d..40a4815a9a0 100644
--- a/lib/gitlab/downtime_check/message.rb
+++ b/lib/gitlab/downtime_check/message.rb
@@ -1,10 +1,10 @@
module Gitlab
class DowntimeCheck
class Message
- attr_reader :path, :offline, :reason
+ attr_reader :path, :offline
- OFFLINE = "\e[32moffline\e[0m"
- ONLINE = "\e[31monline\e[0m"
+ OFFLINE = "\e[31moffline\e[0m"
+ ONLINE = "\e[32monline\e[0m"
# path - The file path of the migration.
# offline - When set to `true` the migration will require downtime.
@@ -19,10 +19,21 @@ module Gitlab
label = offline ? OFFLINE : ONLINE
message = "[#{label}]: #{path}"
- message += ": #{reason}" if reason
+
+ if reason?
+ message += ":\n\n#{reason}\n\n"
+ end
message
end
+
+ def reason?
+ @reason.present?
+ end
+
+ def reason
+ @reason.strip.lines.map(&:strip).join("\n")
+ end
end
end
end
diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb
index 008300bde45..0cc10f40087 100644
--- a/lib/gitlab/import_export/json_hash_builder.rb
+++ b/lib/gitlab/import_export/json_hash_builder.rb
@@ -57,19 +57,16 @@ module Gitlab
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def create_model_value(current_key, value, json_config_hash)
- parsed_hash = { include: value }
- parse_hash(value, parsed_hash)
-
- json_config_hash[current_key] = parsed_hash
+ json_config_hash[current_key] = parse_hash(value) || { include: value }
end
# Calls attributes finder to parse the hash and add any attributes to it
#
# +value+ existing model to be included in the hash
# +parsed_hash+ the original hash
- def parse_hash(value, parsed_hash)
+ def parse_hash(value)
@attributes_finder.parse(value) do |hash|
- parsed_hash = { include: hash_or_merge(value, hash) }
+ { include: hash_or_merge(value, hash) }
end
end
diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb
index 760ff3e614a..7ebec8e2cff 100644
--- a/lib/gitlab/template/base_template.rb
+++ b/lib/gitlab/template/base_template.rb
@@ -1,8 +1,9 @@
module Gitlab
module Template
class BaseTemplate
- def initialize(path)
+ def initialize(path, project = nil)
@path = path
+ @finder = self.class.finder(project)
end
def name
@@ -10,23 +11,32 @@ module Gitlab
end
def content
- File.read(@path)
+ @finder.read(@path)
+ end
+
+ def to_json
+ { name: name, content: content }
end
class << self
- def all
- self.categories.keys.flat_map { |cat| by_category(cat) }
+ def all(project = nil)
+ if categories.any?
+ categories.keys.flat_map { |cat| by_category(cat, project) }
+ else
+ by_category("", project)
+ end
end
- def find(key)
- file_name = "#{key}#{self.extension}"
-
- directory = select_directory(file_name)
- directory ? new(File.join(category_directory(directory), file_name)) : nil
+ def find(key, project = nil)
+ path = self.finder(project).find(key)
+ path.present? ? new(path, project) : nil
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
- raise NotImplementedError
+ {}
end
def extension
@@ -37,30 +47,41 @@ module Gitlab
raise NotImplementedError
end
- def by_category(category)
- templates_for_directory(category_directory(category))
+ # Defines which strategy will be used to get templates files
+ # 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
+
+ def by_category(category, project = nil)
+ directory = category_directory(category)
+ files = finder(project).list_files_for(directory)
+
+ files.map { |f| new(f, project) }
end
def category_directory(category)
+ return base_dir unless category.present?
+
File.join(base_dir, categories[category])
end
- private
+ # If template is organized by category it returns { category_name: [{ name: template_name }, { name: template2_name }] }
+ # If no category is present returns [{ name: template_name }, { name: template2_name}]
+ def dropdown_names(project = nil)
+ return [] if project && !project.repository.exists?
- def select_directory(file_name)
- categories.keys.find do |category|
- File.exist?(File.join(category_directory(category), file_name))
+ if categories.any?
+ 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
-
- def templates_for_directory(dir)
- dir << '/' unless dir.end_with?('/')
- Dir.glob(File.join(dir, "*#{self.extension}")).select { |f| f =~ filter_regex }.map { |f| new(f) }
- end
-
- def filter_regex
- @filter_reges ||= /#{Regexp.escape(extension)}\z/
- end
end
end
end
diff --git a/lib/gitlab/template/finders/base_template_finder.rb b/lib/gitlab/template/finders/base_template_finder.rb
new file mode 100644
index 00000000000..473b05257c6
--- /dev/null
+++ b/lib/gitlab/template/finders/base_template_finder.rb
@@ -0,0 +1,35 @@
+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
diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb
new file mode 100644
index 00000000000..831da45191f
--- /dev/null
+++ b/lib/gitlab/template/finders/global_template_finder.rb
@@ -0,0 +1,38 @@
+# 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
diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb
new file mode 100644
index 00000000000..22c39436cb2
--- /dev/null
+++ b/lib/gitlab/template/finders/repo_template_finder.rb
@@ -0,0 +1,59 @@
+# 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
diff --git a/lib/gitlab/template/gitignore.rb b/lib/gitlab/template/gitignore_template.rb
similarity index 63%
rename from lib/gitlab/template/gitignore.rb
rename to lib/gitlab/template/gitignore_template.rb
index 964fbfd4de3..8d2a9d2305c 100644
--- a/lib/gitlab/template/gitignore.rb
+++ b/lib/gitlab/template/gitignore_template.rb
@@ -1,6 +1,6 @@
module Gitlab
module Template
- class Gitignore < BaseTemplate
+ class GitignoreTemplate < BaseTemplate
class << self
def extension
'.gitignore'
@@ -16,6 +16,10 @@ module Gitlab
def base_dir
Rails.root.join('vendor/gitignore')
end
+
+ def finder(project = nil)
+ Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories)
+ end
end
end
end
diff --git a/lib/gitlab/template/gitlab_ci_yml.rb b/lib/gitlab/template/gitlab_ci_yml_template.rb
similarity index 72%
rename from lib/gitlab/template/gitlab_ci_yml.rb
rename to lib/gitlab/template/gitlab_ci_yml_template.rb
index 7f480fe33c0..8d1a1ed54c9 100644
--- a/lib/gitlab/template/gitlab_ci_yml.rb
+++ b/lib/gitlab/template/gitlab_ci_yml_template.rb
@@ -1,6 +1,6 @@
module Gitlab
module Template
- class GitlabCiYml < BaseTemplate
+ class GitlabCiYmlTemplate < BaseTemplate
def content
explanation = "# This file is a template, and might need editing before it works on your project."
[explanation, super].join("\n")
@@ -21,6 +21,10 @@ module Gitlab
def base_dir
Rails.root.join('vendor/gitlab-ci-yml')
end
+
+ def finder(project = nil)
+ Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories)
+ end
end
end
end
diff --git a/lib/gitlab/template/issue_template.rb b/lib/gitlab/template/issue_template.rb
new file mode 100644
index 00000000000..c6fa8d3eafc
--- /dev/null
+++ b/lib/gitlab/template/issue_template.rb
@@ -0,0 +1,19 @@
+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
diff --git a/lib/gitlab/template/merge_request_template.rb b/lib/gitlab/template/merge_request_template.rb
new file mode 100644
index 00000000000..f826c02f3b5
--- /dev/null
+++ b/lib/gitlab/template/merge_request_template.rb
@@ -0,0 +1,19 @@
+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
diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb
index c55a7fc4d3d..9858d2e7d83 100644
--- a/lib/gitlab/user_access.rb
+++ b/lib/gitlab/user_access.rb
@@ -32,7 +32,7 @@ module Gitlab
if project.protected_branch?(ref)
return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user)
- access_levels = project.protected_branches.matching(ref).map(&:push_access_level)
+ access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten
access_levels.any? { |access_level| access_level.check_access(user) }
else
user.can?(:push_code, project)
@@ -43,7 +43,7 @@ module Gitlab
return false unless user
if project.protected_branch?(ref)
- access_levels = project.protected_branches.matching(ref).map(&:merge_access_level)
+ access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten
access_levels.any? { |access_level| access_level.check_access(user) }
else
user.can?(:push_code, project)
diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb
index 520a4f6f9c5..585ca31389d 100644
--- a/spec/controllers/admin/spam_logs_controller_spec.rb
+++ b/spec/controllers/admin/spam_logs_controller_spec.rb
@@ -34,4 +34,16 @@ describe Admin::SpamLogsController do
expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
+
+ describe '#mark_as_ham' do
+ before do
+ allow_any_instance_of(AkismetService).to receive(:submit_ham).and_return(true)
+ end
+ it 'submits the log as ham' do
+ post :mark_as_ham, id: first_spam.id
+
+ expect(response).to have_http_status(302)
+ expect(SpamLog.find(first_spam.id).submitted_as_ham).to be_truthy
+ end
+ end
end
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index ed0b7f9e240..44128a43362 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -2,178 +2,262 @@ require 'spec_helper'
describe AutocompleteController do
let!(:project) { create(:project) }
- let!(:user) { create(:user) }
- let!(:user2) { create(:user) }
- let!(:non_member) { create(:user) }
+ let!(:user) { create(:user) }
- context 'project members' do
- before do
- sign_in(user)
- project.team << [user, :master]
+ context 'users and members' do
+ let!(:user2) { create(:user) }
+ let!(:non_member) { create(:user) }
+
+ context 'project members' do
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ describe 'GET #users with project ID' do
+ before do
+ get(:users, project_id: project.id)
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 1 }
+ it { expect(body.map { |u| u["username"] }).to include(user.username) }
+ end
+
+ describe 'GET #users with unknown project' do
+ before do
+ get(:users, project_id: 'unknown')
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
end
- describe 'GET #users with project ID' do
+ context 'group members' do
+ let(:group) { create(:group) }
+
before do
- get(:users, project_id: project.id)
+ sign_in(user)
+ group.add_owner(user)
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ describe 'GET #users with group ID' do
+ before do
+ get(:users, group_id: group.id)
+ end
+
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 1 }
+ it { expect(body.first["username"]).to eq user.username }
+ end
+
+ describe 'GET #users with unknown group ID' do
+ before do
+ get(:users, group_id: 'unknown')
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
+ end
+
+ context 'non-member login for public project' do
+ let!(:project) { create(:project, :public) }
+
+ before do
+ sign_in(non_member)
+ project.team << [user, :master]
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ describe 'GET #users with project ID' do
+ before do
+ get(:users, project_id: project.id, current_user: true)
+ end
+
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 2 }
+ it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) }
+ end
+ end
+
+ context 'all users' do
+ before do
+ sign_in(user)
+ get(:users)
end
let(:body) { JSON.parse(response.body) }
it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
- it { expect(body.map { |u| u["username"] }).to include(user.username) }
+ it { expect(body.size).to eq User.count }
end
- describe 'GET #users with unknown project' do
- before do
- get(:users, project_id: 'unknown')
+ context 'unauthenticated user' do
+ let(:public_project) { create(:project, :public) }
+ let(:body) { JSON.parse(response.body) }
+
+ describe 'GET #users with public project' do
+ before do
+ public_project.team << [user, :guest]
+ get(:users, project_id: public_project.id)
+ end
+
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 1 }
end
- it { expect(response).to have_http_status(404) }
+ describe 'GET #users with project' do
+ before do
+ get(:users, project_id: project.id)
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
+
+ describe 'GET #users with unknown project' do
+ before do
+ get(:users, project_id: 'unknown')
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
+
+ describe 'GET #users with inaccessible group' do
+ before do
+ project.team << [user, :guest]
+ get(:users, group_id: user.namespace.id)
+ end
+
+ it { expect(response).to have_http_status(404) }
+ end
+
+ describe 'GET #users with no project' do
+ before do
+ get(:users)
+ end
+
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 0 }
+ end
+ end
+
+ context 'author of issuable included' do
+ before do
+ sign_in(user)
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ it 'includes the author' do
+ get(:users, author_id: non_member.id)
+
+ expect(body.first["username"]).to eq non_member.username
+ end
+
+ it 'rejects non existent user ids' do
+ get(:users, author_id: 99999)
+
+ expect(body.collect { |u| u['id'] }).not_to include(99999)
+ end
+ end
+
+ context 'skip_users parameter included' do
+ before { sign_in(user) }
+
+ it 'skips the user IDs passed' do
+ get(:users, skip_users: [user, user2].map(&:id))
+
+ other_user_ids = [non_member, project.owner, project.creator].map(&:id)
+ response_user_ids = JSON.parse(response.body).map { |user| user['id'] }
+
+ expect(response_user_ids).to contain_exactly(*other_user_ids)
+ end
end
end
- context 'group members' do
- let(:group) { create(:group) }
+ context 'projects' do
+ let(:authorized_project) { create(:project) }
+ let(:authorized_search_project) { create(:project, name: 'rugged') }
before do
sign_in(user)
- group.add_owner(user)
- end
-
- let(:body) { JSON.parse(response.body) }
-
- describe 'GET #users with group ID' do
- before do
- get(:users, group_id: group.id)
- end
-
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
- it { expect(body.first["username"]).to eq user.username }
- end
-
- describe 'GET #users with unknown group ID' do
- before do
- get(:users, group_id: 'unknown')
- end
-
- it { expect(response).to have_http_status(404) }
- end
- end
-
- context 'non-member login for public project' do
- let!(:project) { create(:project, :public) }
-
- before do
- sign_in(non_member)
project.team << [user, :master]
end
- let(:body) { JSON.parse(response.body) }
-
- describe 'GET #users with project ID' do
+ context 'authorized projects' do
before do
- get(:users, project_id: project.id, current_user: true)
+ authorized_project.team << [user, :master]
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 2 }
- it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) }
- end
- end
+ describe 'GET #projects with project ID' do
+ before do
+ get(:projects, project_id: project.id)
+ end
- context 'all users' do
- before do
- sign_in(user)
- get(:users)
+ let(:body) { JSON.parse(response.body) }
+
+ it do
+ expect(body).to be_kind_of(Array)
+ expect(body.size).to eq 2
+
+ expect(body.first['id']).to eq 0
+ expect(body.first['name_with_namespace']).to eq 'No project'
+
+ expect(body.last['id']).to eq authorized_project.id
+ expect(body.last['name_with_namespace']).to eq authorized_project.name_with_namespace
+ end
+ end
end
- let(:body) { JSON.parse(response.body) }
-
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq User.count }
- end
-
- context 'unauthenticated user' do
- let(:public_project) { create(:project, :public) }
- let(:body) { JSON.parse(response.body) }
-
- describe 'GET #users with public project' do
+ context 'authorized projects and search' do
before do
- public_project.team << [user, :guest]
- get(:users, project_id: public_project.id)
+ authorized_project.team << [user, :master]
+ authorized_search_project.team << [user, :master]
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
+ describe 'GET #projects with project ID and search' do
+ before do
+ get(:projects, project_id: project.id, search: 'rugged')
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ it do
+ expect(body).to be_kind_of(Array)
+ expect(body.size).to eq 2
+
+ expect(body.last['id']).to eq authorized_search_project.id
+ expect(body.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace
+ end
+ end
end
- describe 'GET #users with project' do
- before do
- get(:users, project_id: project.id)
+ context 'authorized projects without admin_issue ability' do
+ before(:each) do
+ authorized_project.team << [user, :guest]
+
+ expect(user.can?(:admin_issue, authorized_project)).to eq(false)
end
- it { expect(response).to have_http_status(404) }
- end
+ describe 'GET #projects with project ID' do
+ before do
+ get(:projects, project_id: project.id)
+ end
- describe 'GET #users with unknown project' do
- before do
- get(:users, project_id: 'unknown')
+ let(:body) { JSON.parse(response.body) }
+
+ it do
+ expect(body).to be_kind_of(Array)
+ expect(body.size).to eq 1 # 'No project'
+
+ expect(body.first['id']).to eq 0
+ end
end
-
- it { expect(response).to have_http_status(404) }
- end
-
- describe 'GET #users with inaccessible group' do
- before do
- project.team << [user, :guest]
- get(:users, group_id: user.namespace.id)
- end
-
- it { expect(response).to have_http_status(404) }
- end
-
- describe 'GET #users with no project' do
- before do
- get(:users)
- end
-
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 0 }
- end
- end
-
- context 'author of issuable included' do
- before do
- sign_in(user)
- end
-
- let(:body) { JSON.parse(response.body) }
-
- it 'includes the author' do
- get(:users, author_id: non_member.id)
-
- expect(body.first["username"]).to eq non_member.username
- end
-
- it 'rejects non existent user ids' do
- get(:users, author_id: 99999)
-
- expect(body.collect { |u| u['id'] }).not_to include(99999)
- end
- end
-
- context 'skip_users parameter included' do
- before { sign_in(user) }
-
- it 'skips the user IDs passed' do
- get(:users, skip_users: [user, user2].map(&:id))
-
- other_user_ids = [non_member, project.owner, project.creator].map(&:id)
- response_user_ids = JSON.parse(response.body).map { |user| user['id'] }
-
- expect(response_user_ids).to contain_exactly(*other_user_ids)
end
end
end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index b6a0276846c..0836b71056c 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -274,8 +274,8 @@ describe Projects::IssuesController do
describe 'POST #create' do
context 'Akismet is enabled' do
before do
- allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true)
- allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true)
+ allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
+ allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
end
def post_spam_issue
@@ -300,6 +300,52 @@ describe Projects::IssuesController do
expect(spam_logs[0].title).to eq('Spam Title')
end
end
+
+ context 'user agent details are saved' do
+ before do
+ request.env['action_dispatch.remote_ip'] = '127.0.0.1'
+ end
+
+ def post_new_issue
+ sign_in(user)
+ project = create(:empty_project, :public)
+ post :create, {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ issue: { title: 'Title', description: 'Description' }
+ }
+ end
+
+ it 'creates a user agent detail' do
+ expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
+ end
+ end
+ end
+
+ describe 'POST #mark_as_spam' do
+ context 'properly submits to Akismet' do
+ before do
+ allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true)
+ allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true)
+ end
+
+ def post_spam
+ admin = create(:admin)
+ create(:user_agent_detail, subject: issue)
+ project.team << [admin, :master]
+ sign_in(admin)
+ post :mark_as_spam, {
+ namespace_id: project.namespace.path,
+ project_id: project.path,
+ id: issue.iid
+ }
+ end
+
+ it 'updates issue' do
+ post_spam
+ expect(issue.submittable_as_spam?).to be_falsey
+ end
+ end
end
describe "DELETE #destroy" do
diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb
new file mode 100644
index 00000000000..7b3a26d7ca7
--- /dev/null
+++ b/spec/controllers/projects/templates_controller_spec.rb
@@ -0,0 +1,48 @@
+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
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 3195fb3ddcc..4fd51a23490 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -5,5 +5,15 @@ FactoryGirl.define do
trait :token do
token { SecureRandom.hex(10) }
end
+
+ trait :all_events_enabled do
+ push_events true
+ merge_requests_events true
+ tag_push_events true
+ issues_events true
+ note_events true
+ build_events true
+ pipeline_events true
+ end
end
end
diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb
index 5575852c2d7..b2695e0482a 100644
--- a/spec/factories/protected_branches.rb
+++ b/spec/factories/protected_branches.rb
@@ -3,26 +3,26 @@ FactoryGirl.define do
name
project
- after(:create) do |protected_branch|
- protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER)
- protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER)
+ after(:build) do |protected_branch|
+ protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER)
+ protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER)
end
trait :developers_can_push do
after(:create) do |protected_branch|
- protected_branch.push_access_level.update!(access_level: Gitlab::Access::DEVELOPER)
+ protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
end
end
trait :developers_can_merge do
after(:create) do |protected_branch|
- protected_branch.merge_access_level.update!(access_level: Gitlab::Access::DEVELOPER)
+ protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER)
end
end
trait :no_one_can_push do
after(:create) do |protected_branch|
- protected_branch.push_access_level.update!(access_level: Gitlab::Access::NO_ACCESS)
+ protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS)
end
end
end
diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb
new file mode 100644
index 00000000000..9763cc0cf15
--- /dev/null
+++ b/spec/factories/user_agent_details.rb
@@ -0,0 +1,7 @@
+FactoryGirl.define do
+ factory :user_agent_detail do
+ ip_address '127.0.0.1'
+ user_agent 'AppleWebKit/537.36'
+ association :subject, factory: :issue
+ end
+end
diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
index dbd07464444..a521ce50f35 100644
--- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb
+++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
@@ -23,7 +23,7 @@ feature 'project owner creates a license file', feature: true, js: true do
select_template('MIT License')
- file_content = find('.file-content')
+ file_content = first('.file-editor')
expect(file_content).to have_content('The MIT License (MIT)')
expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
@@ -47,7 +47,7 @@ feature 'project owner creates a license file', feature: true, js: true do
select_template('MIT License')
- file_content = find('.file-content')
+ file_content = first('.file-editor')
expect(file_content).to have_content('The MIT License (MIT)')
expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
index 45bf0c0d038..4453b6d485f 100644
--- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
@@ -23,7 +23,7 @@ feature 'project owner sees a link to create a license file in empty project', f
select_template('MIT License')
- file_content = find('.file-content')
+ file_content = first('.file-editor')
expect(file_content).to have_content('The MIT License (MIT)')
expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}")
diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb
index 7835e1678ad..f707ccf4e93 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -3,8 +3,9 @@ require 'spec_helper'
feature 'project import', feature: true, js: true do
include Select2Helper
- let(:user) { create(:admin) }
- let!(:namespace) { create(:namespace, name: "asd", owner: user) }
+ let(:admin) { create(:admin) }
+ let(:normal_user) { create(:user) }
+ let!(:namespace) { create(:namespace, name: "asd", owner: admin) }
let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') }
let(:export_path) { "#{Dir::tmpdir}/import_file_spec" }
let(:project) { Project.last }
@@ -12,66 +13,87 @@ feature 'project import', feature: true, js: true do
background do
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
- login_as(user)
end
after(:each) do
FileUtils.rm_rf(export_path, secure: true)
end
- scenario 'user imports an exported project successfully' do
- expect(Project.all.count).to be_zero
+ context 'admin user' do
+ before do
+ login_as(admin)
+ end
- visit new_project_path
+ scenario 'user imports an exported project successfully' do
+ expect(Project.all.count).to be_zero
- select2('2', from: '#project_namespace_id')
- fill_in :project_path, with: 'test-project-path', visible: true
- click_link 'GitLab export'
+ visit new_project_path
- expect(page).to have_content('GitLab project export')
- expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path')
+ select2('2', from: '#project_namespace_id')
+ fill_in :project_path, with: 'test-project-path', visible: true
+ click_link 'GitLab export'
- attach_file('file', file)
+ expect(page).to have_content('GitLab project export')
+ expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path')
- click_on 'Import project' # import starts
+ attach_file('file', file)
- expect(project).not_to be_nil
- expect(project.issues).not_to be_empty
- expect(project.merge_requests).not_to be_empty
- expect(project_hook).to exist
- expect(wiki_exists?).to be true
- expect(project.import_status).to eq('finished')
- end
+ click_on 'Import project' # import starts
- scenario 'invalid project' do
- project = create(:project, namespace_id: 2)
+ expect(project).not_to be_nil
+ expect(project.issues).not_to be_empty
+ expect(project.merge_requests).not_to be_empty
+ expect(project_hook).to exist
+ expect(wiki_exists?).to be true
+ expect(project.import_status).to eq('finished')
+ end
- visit new_project_path
+ scenario 'invalid project' do
+ project = create(:project, namespace_id: 2)
- select2('2', from: '#project_namespace_id')
- fill_in :project_path, with: project.name, visible: true
- click_link 'GitLab export'
+ visit new_project_path
- attach_file('file', file)
- click_on 'Import project'
+ select2('2', from: '#project_namespace_id')
+ fill_in :project_path, with: project.name, visible: true
+ click_link 'GitLab export'
- page.within('.flash-container') do
- expect(page).to have_content('Project could not be imported')
+ attach_file('file', file)
+ click_on 'Import project'
+
+ page.within('.flash-container') do
+ expect(page).to have_content('Project could not be imported')
+ end
+ end
+
+ scenario 'project with no name' do
+ create(:project, namespace_id: 2)
+
+ visit new_project_path
+
+ select2('2', from: '#project_namespace_id')
+
+ # click on disabled element
+ find(:link, 'GitLab export').trigger('click')
+
+ page.within('.flash-container') do
+ expect(page).to have_content('Please enter path and name')
+ end
end
end
- scenario 'project with no name' do
- create(:project, namespace_id: 2)
+ context 'normal user' do
+ before do
+ login_as(normal_user)
+ end
- visit new_project_path
+ scenario 'non-admin user is not allowed to import a project' do
+ expect(Project.all.count).to be_zero
- select2('2', from: '#project_namespace_id')
+ visit new_project_path
- # click on disabled element
- find(:link, 'GitLab export').trigger('click')
+ fill_in :project_path, with: 'test-project-path', visible: true
- page.within('.flash-container') do
- expect(page).to have_content('Please enter path and name')
+ expect(page).not_to have_content('GitLab export')
end
end
diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
new file mode 100644
index 00000000000..4a83740621a
--- /dev/null
+++ b/spec/features/projects/issuable_templates_spec.rb
@@ -0,0 +1,89 @@
+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
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index 3499460c84d..a0ee6cab7ec 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -71,7 +71,10 @@ feature 'Projected Branches', feature: true, js: true do
project.repository.add_branch(user, 'production-stable', 'master')
project.repository.add_branch(user, 'staging-stable', 'master')
project.repository.add_branch(user, 'development', 'master')
- create(:protected_branch, project: project, name: "*-stable")
+
+ visit namespace_project_protected_branches_path(project.namespace, project)
+ set_protected_branch_name('*-stable')
+ click_on "Protect"
visit namespace_project_protected_branches_path(project.namespace, project)
click_on "2 matching branches"
@@ -90,13 +93,17 @@ feature 'Projected Branches', feature: true, js: true do
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
within('.new_protected_branch') do
- find(".js-allowed-to-push").click
- within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ allowed_to_push_button = find(".js-allowed-to-push")
+
+ unless allowed_to_push_button.text == access_type_name
+ allowed_to_push_button.click
+ within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ end
end
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id)
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id])
end
it "allows updating protected branches so that #{access_type_name} can push to them" do
@@ -112,7 +119,7 @@ feature 'Projected Branches', feature: true, js: true do
end
wait_for_ajax
- expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id)
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
end
end
@@ -121,13 +128,17 @@ feature 'Projected Branches', feature: true, js: true do
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
within('.new_protected_branch') do
- find(".js-allowed-to-merge").click
- within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ allowed_to_merge_button = find(".js-allowed-to-merge")
+
+ unless allowed_to_merge_button.text == access_type_name
+ allowed_to_merge_button.click
+ within(".dropdown.open .dropdown-menu") { click_on access_type_name }
+ end
end
click_on "Protect"
expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id)
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
end
it "allows updating protected branches so that #{access_type_name} can merge to them" do
@@ -143,7 +154,7 @@ feature 'Projected Branches', feature: true, js: true do
end
wait_for_ajax
- expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id)
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id)
end
end
end
diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb
new file mode 100644
index 00000000000..4f3304f7b6d
--- /dev/null
+++ b/spec/finders/move_to_project_finder_spec.rb
@@ -0,0 +1,75 @@
+require 'spec_helper'
+
+describe MoveToProjectFinder do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ let(:no_access_project) { create(:project) }
+ let(:guest_project) { create(:project) }
+ let(:reporter_project) { create(:project) }
+ let(:developer_project) { create(:project) }
+ let(:master_project) { create(:project) }
+
+ subject { described_class.new(user) }
+
+ describe '#execute' do
+ context 'filter' do
+ it 'does not return projects under Gitlab::Access::REPORTER' do
+ guest_project.team << [user, :guest]
+
+ expect(subject.execute(project)).to be_empty
+ end
+
+ it 'returns projects equal or above Gitlab::Access::REPORTER ordered by id in descending order' do
+ reporter_project.team << [user, :reporter]
+ developer_project.team << [user, :developer]
+ master_project.team << [user, :master]
+
+ expect(subject.execute(project).to_a).to eq([master_project, developer_project, reporter_project])
+ end
+
+ it 'does not include the source project' do
+ project.team << [user, :reporter]
+
+ expect(subject.execute(project).to_a).to be_empty
+ end
+
+ it 'does not return archived projects' do
+ reporter_project.team << [user, :reporter]
+ reporter_project.update_attributes(archived: true)
+ other_reporter_project = create(:project)
+ other_reporter_project.team << [user, :reporter]
+
+ expect(subject.execute(project).to_a).to eq([other_reporter_project])
+ end
+
+ it 'does not return projects for which issues are disabled' do
+ reporter_project.team << [user, :reporter]
+ reporter_project.update_attributes(issues_enabled: false)
+ other_reporter_project = create(:project)
+ other_reporter_project.team << [user, :reporter]
+
+ expect(subject.execute(project).to_a).to eq([other_reporter_project])
+ end
+ end
+
+ context 'search' do
+ it 'uses Project#search' do
+ expect(user).to receive_message_chain(:projects_where_can_admin_issues, :search) { Project.all }
+
+ subject.execute(project, search: 'wadus')
+ end
+
+ it 'returns projects matching a search query' do
+ foo_project = create(:project)
+ foo_project.team << [user, :master]
+
+ wadus_project = create(:project, name: 'wadus')
+ wadus_project.team << [user, :master]
+
+ expect(subject.execute(project).to_a).to eq([wadus_project, foo_project])
+ expect(subject.execute(project, search: 'wadus').to_a).to eq([wadus_project])
+ end
+ end
+ end
+end
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index 153f1864ceb..9c577501f00 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -38,6 +38,11 @@ describe NotesHelper do
end
describe '#preload_max_access_for_authors' do
+ before do
+ # This method reads cache from RequestStore, so make sure it's clean.
+ RequestStore.clear!
+ end
+
it 'loads multiple users' do
expected_access = {
owner.id => Gitlab::Access::OWNER,
diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb
deleted file mode 100644
index b08396da4d2..00000000000
--- a/spec/lib/gitlab/akismet_helper_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::AkismetHelper, type: :helper do
- let(:project) { create(:project, :public) }
- let(:user) { create(:user) }
-
- before do
- allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
- allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true)
- allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345')
- end
-
- describe '#check_for_spam?' do
- it 'returns true for public project' do
- expect(helper.check_for_spam?(project)).to eq(true)
- end
-
- it 'returns false for private project' do
- project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
- expect(helper.check_for_spam?(project)).to eq(false)
- end
- end
-
- describe '#is_spam?' do
- it 'returns true for spam' do
- environment = {
- 'action_dispatch.remote_ip' => '127.0.0.1',
- 'HTTP_USER_AGENT' => 'Test User Agent'
- }
-
- allow_any_instance_of(::Akismet::Client).to receive(:check).and_return([true, true])
- expect(helper.is_spam?(environment, user, 'Is this spam?')).to eq(true)
- end
- end
-end
diff --git a/spec/lib/gitlab/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb
similarity index 88%
rename from spec/lib/gitlab/build_data_builder_spec.rb
rename to spec/lib/gitlab/data_builder/build_spec.rb
index 23ae5cfacc4..6c71e98066b 100644
--- a/spec/lib/gitlab/build_data_builder_spec.rb
+++ b/spec/lib/gitlab/data_builder/build_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
-describe 'Gitlab::BuildDataBuilder' do
+describe Gitlab::DataBuilder::Build do
let(:build) { create(:ci_build) }
describe '.build' do
let(:data) do
- Gitlab::BuildDataBuilder.build(build)
+ described_class.build(build)
end
it { expect(data).to be_a(Hash) }
diff --git a/spec/lib/gitlab/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb
similarity index 97%
rename from spec/lib/gitlab/note_data_builder_spec.rb
rename to spec/lib/gitlab/data_builder/note_spec.rb
index 3d6bcdfd873..9a4dec91e56 100644
--- a/spec/lib/gitlab/note_data_builder_spec.rb
+++ b/spec/lib/gitlab/data_builder/note_spec.rb
@@ -1,9 +1,9 @@
require 'spec_helper'
-describe 'Gitlab::NoteDataBuilder', lib: true do
+describe Gitlab::DataBuilder::Note, lib: true do
let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:data) { Gitlab::NoteDataBuilder.build(note, user) }
+ let(:data) { described_class.build(note, user) }
let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors
before(:each) do
diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb
new file mode 100644
index 00000000000..a68f5943a6a
--- /dev/null
+++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::DataBuilder::Pipeline do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: project,
+ status: 'success',
+ sha: project.commit.sha,
+ ref: project.default_branch)
+ end
+
+ let!(:build) { create(:ci_build, pipeline: pipeline) }
+
+ describe '.build' do
+ let(:data) { described_class.build(pipeline) }
+ let(:attributes) { data[:object_attributes] }
+ let(:build_data) { data[:builds].first }
+ let(:project_data) { data[:project] }
+
+ it { expect(attributes).to be_a(Hash) }
+ it { expect(attributes[:ref]).to eq(pipeline.ref) }
+ it { expect(attributes[:sha]).to eq(pipeline.sha) }
+ it { expect(attributes[:tag]).to eq(pipeline.tag) }
+ it { expect(attributes[:id]).to eq(pipeline.id) }
+ it { expect(attributes[:status]).to eq(pipeline.status) }
+
+ it { expect(build_data).to be_a(Hash) }
+ it { expect(build_data[:id]).to eq(build.id) }
+ it { expect(build_data[:status]).to eq(build.status) }
+
+ it { expect(project_data).to eq(project.hook_attrs(backward: false)) }
+ end
+end
diff --git a/spec/lib/gitlab/push_data_builder_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb
similarity index 97%
rename from spec/lib/gitlab/push_data_builder_spec.rb
rename to spec/lib/gitlab/data_builder/push_spec.rb
index 6bd7393aaa7..b73434e8dd7 100644
--- a/spec/lib/gitlab/push_data_builder_spec.rb
+++ b/spec/lib/gitlab/data_builder/push_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::PushDataBuilder, lib: true do
+describe Gitlab::DataBuilder::Push, lib: true do
let(:project) { create(:project) }
let(:user) { create(:user) }
diff --git a/spec/lib/gitlab/downtime_check/message_spec.rb b/spec/lib/gitlab/downtime_check/message_spec.rb
index 93094cda776..a5a398abf78 100644
--- a/spec/lib/gitlab/downtime_check/message_spec.rb
+++ b/spec/lib/gitlab/downtime_check/message_spec.rb
@@ -5,13 +5,35 @@ describe Gitlab::DowntimeCheck::Message do
it 'returns an ANSI formatted String for an offline migration' do
message = described_class.new('foo.rb', true, 'hello')
- expect(message.to_s).to eq("[\e[32moffline\e[0m]: foo.rb: hello")
+ expect(message.to_s).to eq("[\e[31moffline\e[0m]: foo.rb:\n\nhello\n\n")
end
it 'returns an ANSI formatted String for an online migration' do
message = described_class.new('foo.rb')
- expect(message.to_s).to eq("[\e[31monline\e[0m]: foo.rb")
+ expect(message.to_s).to eq("[\e[32monline\e[0m]: foo.rb")
+ end
+ end
+
+ describe '#reason?' do
+ it 'returns false when no reason is specified' do
+ message = described_class.new('foo.rb')
+
+ expect(message.reason?).to eq(false)
+ end
+
+ it 'returns true when a reason is specified' do
+ message = described_class.new('foo.rb', true, 'hello')
+
+ expect(message.reason?).to eq(true)
+ end
+ end
+
+ describe '#reason' do
+ it 'strips excessive whitespace from the returned String' do
+ message = described_class.new('foo.rb', true, " hello\n world\n\n foo")
+
+ expect(message.reason).to eq("hello\nworld\n\nfoo")
end
end
end
diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb
index b76e14deca1..b6dec41d218 100644
--- a/spec/lib/gitlab/import_export/reader_spec.rb
+++ b/spec/lib/gitlab/import_export/reader_spec.rb
@@ -12,7 +12,8 @@ describe Gitlab::ImportExport::Reader, lib: true do
except: [:iid],
include: [:merge_request_diff, :merge_request_test]
} },
- { commit_statuses: { include: :commit } }]
+ { commit_statuses: { include: :commit } },
+ { project_members: { include: { user: { only: [:email] } } } }]
}
end
diff --git a/spec/lib/gitlab/template/gitignore_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb
similarity index 88%
rename from spec/lib/gitlab/template/gitignore_spec.rb
rename to spec/lib/gitlab/template/gitignore_template_spec.rb
index bc0ec9325cc..9750a012e22 100644
--- a/spec/lib/gitlab/template/gitignore_spec.rb
+++ b/spec/lib/gitlab/template/gitignore_template_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Template::Gitignore do
+describe Gitlab::Template::GitignoreTemplate do
subject { described_class }
describe '.all' do
@@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do
it 'returns the Gitignore object of a valid file' do
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')
end
end
diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
new file mode 100644
index 00000000000..e3b8321eda3
--- /dev/null
+++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
@@ -0,0 +1,41 @@
+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
diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb
new file mode 100644
index 00000000000..f770857e958
--- /dev/null
+++ b/spec/lib/gitlab/template/issue_template_spec.rb
@@ -0,0 +1,89 @@
+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
diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb
new file mode 100644
index 00000000000..bb0f68043fa
--- /dev/null
+++ b/spec/lib/gitlab/template/merge_request_template_spec.rb
@@ -0,0 +1,89 @@
+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
diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb
index 5980f6ddc32..ee2c3d04984 100644
--- a/spec/models/build_spec.rb
+++ b/spec/models/build_spec.rb
@@ -42,7 +42,7 @@ describe Ci::Build, models: true do
describe '#ignored?' do
subject { build.ignored? }
- context 'if build is not allowed to fail' do
+ context 'when build is not allowed to fail' do
before do
build.allow_failure = false
end
@@ -64,7 +64,7 @@ describe Ci::Build, models: true do
end
end
- context 'if build is allowed to fail' do
+ context 'when build is allowed to fail' do
before do
build.allow_failure = true
end
@@ -92,7 +92,7 @@ describe Ci::Build, models: true do
it { is_expected.to be_empty }
- context 'if build.trace contains text' do
+ context 'when build.trace contains text' do
let(:text) { 'example output' }
before do
build.trace = text
@@ -102,7 +102,7 @@ describe Ci::Build, models: true do
it { expect(subject.length).to be >= text.length }
end
- context 'if build.trace hides token' do
+ context 'when build.trace hides token' do
let(:token) { 'my_secret_token' }
before do
@@ -283,13 +283,13 @@ describe Ci::Build, models: true do
stub_ci_pipeline_yaml_file(config)
end
- context 'if config is not found' do
+ context 'when config is not found' do
let(:config) { nil }
it { is_expected.to eq(predefined_variables) }
end
- context 'if config does not have a questioned job' do
+ context 'when config does not have a questioned job' do
let(:config) do
YAML.dump({
test_other: {
@@ -301,7 +301,7 @@ describe Ci::Build, models: true do
it { is_expected.to eq(predefined_variables) }
end
- context 'if config has variables' do
+ context 'when config has variables' do
let(:config) do
YAML.dump({
test: {
@@ -393,7 +393,7 @@ describe Ci::Build, models: true do
it { is_expected.to be_falsey }
end
- context 'if there are runner' do
+ context 'when there are runners' do
let(:runner) { create(:ci_runner) }
before do
@@ -423,29 +423,27 @@ describe Ci::Build, models: true do
describe '#stuck?' do
subject { build.stuck? }
- %w(pending).each do |state|
- context "if commit_status.status is #{state}" do
+ context "when commit_status.status is pending" do
+ before do
+ build.status = 'pending'
+ end
+
+ it { is_expected.to be_truthy }
+
+ context "and there are specific runner" do
+ let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) }
+
before do
- build.status = state
+ build.project.runners << runner
+ runner.save
end
- it { is_expected.to be_truthy }
-
- context "and there are specific runner" do
- let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) }
-
- before do
- build.project.runners << runner
- runner.save
- end
-
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
end
end
- %w(success failed canceled running).each do |state|
- context "if commit_status.status is #{state}" do
+ %w[success failed canceled running].each do |state|
+ context "when commit_status.status is #{state}" do
before do
build.status = state
end
@@ -767,7 +765,7 @@ describe Ci::Build, models: true do
describe '#when' do
subject { build.when }
- context 'if is undefined' do
+ context 'when `when` is undefined' do
before do
build.when = nil
end
@@ -777,13 +775,13 @@ describe Ci::Build, models: true do
stub_ci_pipeline_yaml_file(config)
end
- context 'if config is not found' do
+ context 'when config is not found' do
let(:config) { nil }
it { is_expected.to eq('on_success') }
end
- context 'if config does not have a questioned job' do
+ context 'when config does not have a questioned job' do
let(:config) do
YAML.dump({
test_other: {
@@ -795,7 +793,7 @@ describe Ci::Build, models: true do
it { is_expected.to eq('on_success') }
end
- context 'if config has when' do
+ context 'when config has `when`' do
let(:config) do
YAML.dump({
test: {
@@ -881,7 +879,7 @@ describe Ci::Build, models: true do
subject { build.play }
- it 'enques a build' do
+ it 'enqueues a build' do
is_expected.to be_pending
is_expected.to eq(build)
end
@@ -901,7 +899,7 @@ describe Ci::Build, models: true do
describe '#when' do
subject { build.when }
- context 'if is undefined' do
+ context 'when `when` is undefined' do
before do
build.when = nil
end
@@ -911,13 +909,13 @@ describe Ci::Build, models: true do
stub_ci_pipeline_yaml_file(config)
end
- context 'if config is not found' do
+ context 'when config is not found' do
let(:config) { nil }
it { is_expected.to eq('on_success') }
end
- context 'if config does not have a questioned job' do
+ context 'when config does not have a questioned job' do
let(:config) do
YAML.dump({
test_other: {
@@ -929,7 +927,7 @@ describe Ci::Build, models: true do
it { is_expected.to eq('on_success') }
end
- context 'if config has when' do
+ context 'when config has when' do
let(:config) do
YAML.dump({
test: {
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 950833cb219..8137e9f8f71 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Ci::Pipeline, models: true do
let(:project) { FactoryGirl.create :empty_project }
- let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project }
+ let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, status: 'created', project: project }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:user) }
@@ -18,6 +18,8 @@ describe Ci::Pipeline, models: true do
it { is_expected.to respond_to :git_author_email }
it { is_expected.to respond_to :short_sha }
+ it { is_expected.to delegate_method(:stages).to(:statuses) }
+
describe '#valid_commit_sha' do
context 'commit.sha can not start with 00000000' do
before do
@@ -310,4 +312,87 @@ describe Ci::Pipeline, models: true do
it { is_expected.to eq('running') }
end
end
+
+ describe '#execute_hooks' do
+ let!(:build_a) { create_build('a') }
+ let!(:build_b) { create_build('b') }
+
+ let!(:hook) do
+ create(:project_hook, project: project, pipeline_events: enabled)
+ end
+
+ before do
+ ProjectWebHookWorker.drain
+ end
+
+ context 'with pipeline hooks enabled' do
+ let(:enabled) { true }
+
+ before do
+ WebMock.stub_request(:post, hook.url)
+ end
+
+ context 'with multiple builds' do
+ context 'when build is queued' do
+ before do
+ build_a.enqueue
+ build_b.enqueue
+ end
+
+ it 'receive a pending event once' do
+ expect(WebMock).to have_requested_pipeline_hook('pending').once
+ end
+ end
+
+ context 'when build is run' do
+ before do
+ build_a.enqueue
+ build_a.run
+ build_b.enqueue
+ build_b.run
+ end
+
+ it 'receive a running event once' do
+ expect(WebMock).to have_requested_pipeline_hook('running').once
+ end
+ end
+
+ context 'when all builds succeed' do
+ before do
+ build_a.success
+ build_b.success
+ end
+
+ it 'receive a success event once' do
+ expect(WebMock).to have_requested_pipeline_hook('success').once
+ end
+ end
+
+ def have_requested_pipeline_hook(status)
+ have_requested(:post, hook.url).with do |req|
+ json_body = JSON.parse(req.body)
+ json_body['object_attributes']['status'] == status &&
+ json_body['builds'].length == 2
+ end
+ end
+ end
+ end
+
+ context 'with pipeline hooks disabled' do
+ let(:enabled) { false }
+
+ before do
+ build_a.enqueue
+ build_b.enqueue
+ end
+
+ it 'did not execute pipeline_hook after touched' do
+ expect(WebMock).not_to have_requested(:post, hook.url)
+ end
+ end
+
+ def create_build(name)
+ create(:ci_build, :created, pipeline: pipeline, name: name)
+ end
+ end
end
diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb
new file mode 100644
index 00000000000..32935bc0b09
--- /dev/null
+++ b/spec/models/concerns/spammable_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Issue, 'Spammable' do
+ let(:issue) { create(:issue, description: 'Test Desc.') }
+
+ describe 'Associations' do
+ it { is_expected.to have_one(:user_agent_detail).dependent(:destroy) }
+ end
+
+ describe 'ClassMethods' do
+ it 'should return correct attr_spammable' do
+ expect(issue.spammable_text).to eq("#{issue.title}\n#{issue.description}")
+ end
+ end
+
+ describe 'InstanceMethods' do
+ it 'should be invalid if spam' do
+ issue = build(:issue, spam: true)
+ expect(issue.valid?).to be_falsey
+ end
+
+ describe '#check_for_spam?' do
+ it 'returns true for public project' do
+ issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
+ expect(issue.check_for_spam?).to eq(true)
+ end
+
+ it 'returns false for other visibility levels' do
+ expect(issue.check_for_spam?).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb
index 00c4e0fb64c..d672d80156c 100644
--- a/spec/models/project_services/assembla_service_spec.rb
+++ b/spec/models/project_services/assembla_service_spec.rb
@@ -39,7 +39,7 @@ describe AssemblaService, models: true do
token: 'verySecret',
subdomain: 'project_name'
)
- @sample_data = Gitlab::PushDataBuilder.build_sample(project, user)
+ @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
@api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret'
WebMock.stub_request(:post, @api_url)
end
diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb
index ca2cd8aa551..0194f9e2563 100644
--- a/spec/models/project_services/builds_email_service_spec.rb
+++ b/spec/models/project_services/builds_email_service_spec.rb
@@ -1,7 +1,9 @@
require 'spec_helper'
describe BuildsEmailService do
- let(:data) { Gitlab::BuildDataBuilder.build(create(:ci_build)) }
+ let(:data) do
+ Gitlab::DataBuilder::Build.build(create(:ci_build))
+ end
describe 'Validations' do
context 'when service is active' do
@@ -39,7 +41,7 @@ describe BuildsEmailService do
describe '#test' do
it 'sends email' do
- data = Gitlab::BuildDataBuilder.build(create(:ci_build))
+ data = Gitlab::DataBuilder::Build.build(create(:ci_build))
subject.recipients = 'test@gitlab.com'
expect(BuildEmailWorker).to receive(:perform_async)
@@ -49,7 +51,7 @@ describe BuildsEmailService do
context 'notify only failed builds is true' do
it 'sends email' do
- data = Gitlab::BuildDataBuilder.build(create(:ci_build))
+ data = Gitlab::DataBuilder::Build.build(create(:ci_build))
data[:build_status] = "success"
subject.recipients = 'test@gitlab.com'
diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb
index 1adf93258f3..c76ae21421b 100644
--- a/spec/models/project_services/campfire_service_spec.rb
+++ b/spec/models/project_services/campfire_service_spec.rb
@@ -54,7 +54,7 @@ describe CampfireService, models: true do
subdomain: 'project-name',
room: 'test-room'
)
- @sample_data = Gitlab::PushDataBuilder.build_sample(project, user)
+ @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
@rooms_url = 'https://verySecret:X@project-name.campfirenow.com/rooms.json'
@headers = { 'Content-Type' => 'application/json; charset=utf-8' }
end
diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb
index 3a8e67438fc..8ef892259f2 100644
--- a/spec/models/project_services/drone_ci_service_spec.rb
+++ b/spec/models/project_services/drone_ci_service_spec.rb
@@ -84,7 +84,9 @@ describe DroneCiService, models: true do
include_context :drone_ci_service
let(:user) { create(:user, username: 'username') }
- let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:push_sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
it do
service_hook = double
diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb
index 6518098ceea..d2557019756 100644
--- a/spec/models/project_services/flowdock_service_spec.rb
+++ b/spec/models/project_services/flowdock_service_spec.rb
@@ -52,7 +52,7 @@ describe FlowdockService, models: true do
service_hook: true,
token: 'verySecret'
)
- @sample_data = Gitlab::PushDataBuilder.build_sample(project, user)
+ @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
@api_url = 'https://api.flowdock.com/v1/messages'
WebMock.stub_request(:post, @api_url)
end
diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb
index 2c5583bdaa2..3d0b6c9816b 100644
--- a/spec/models/project_services/gemnasium_service_spec.rb
+++ b/spec/models/project_services/gemnasium_service_spec.rb
@@ -55,7 +55,7 @@ describe GemnasiumService, models: true do
token: 'verySecret',
api_key: 'GemnasiumUserApiKey'
)
- @sample_data = Gitlab::PushDataBuilder.build_sample(project, user)
+ @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
end
it "calls Gemnasium service" do
expect(Gemnasium::GitlabService).to receive(:execute).with(an_instance_of(Hash)).once
diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb
index 1b383219eb9..34eafbe555d 100644
--- a/spec/models/project_services/hipchat_service_spec.rb
+++ b/spec/models/project_services/hipchat_service_spec.rb
@@ -48,7 +48,9 @@ describe HipchatService, models: true do
let(:project_name) { project.name_with_namespace.gsub(/\s/, '') }
let(:token) { 'verySecret' }
let(:server_url) { 'https://hipchat.example.com'}
- let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:push_sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
before(:each) do
allow(hipchat).to receive_messages(
@@ -108,7 +110,15 @@ describe HipchatService, models: true do
end
context 'tag_push events' do
- let(:push_sample_data) { Gitlab::PushDataBuilder.build(project, user, Gitlab::Git::BLANK_SHA, '1' * 40, 'refs/tags/test', []) }
+ let(:push_sample_data) do
+ Gitlab::DataBuilder::Push.build(
+ project,
+ user,
+ Gitlab::Git::BLANK_SHA,
+ '1' * 40,
+ 'refs/tags/test',
+ [])
+ end
it "calls Hipchat API for tag push events" do
hipchat.execute(push_sample_data)
@@ -185,7 +195,7 @@ describe HipchatService, models: true do
end
it "calls Hipchat API for commit comment events" do
- data = Gitlab::NoteDataBuilder.build(commit_note, user)
+ data = Gitlab::DataBuilder::Note.build(commit_note, user)
hipchat.execute(data)
expect(WebMock).to have_requested(:post, api_url).once
@@ -217,7 +227,7 @@ describe HipchatService, models: true do
end
it "calls Hipchat API for merge request comment events" do
- data = Gitlab::NoteDataBuilder.build(merge_request_note, user)
+ data = Gitlab::DataBuilder::Note.build(merge_request_note, user)
hipchat.execute(data)
expect(WebMock).to have_requested(:post, api_url).once
@@ -244,7 +254,7 @@ describe HipchatService, models: true do
end
it "calls Hipchat API for issue comment events" do
- data = Gitlab::NoteDataBuilder.build(issue_note, user)
+ data = Gitlab::DataBuilder::Note.build(issue_note, user)
hipchat.execute(data)
message = hipchat.send(:create_message, data)
@@ -270,7 +280,7 @@ describe HipchatService, models: true do
end
it "calls Hipchat API for snippet comment events" do
- data = Gitlab::NoteDataBuilder.build(snippet_note, user)
+ data = Gitlab::DataBuilder::Note.build(snippet_note, user)
hipchat.execute(data)
expect(WebMock).to have_requested(:post, api_url).once
@@ -293,7 +303,7 @@ describe HipchatService, models: true do
context 'build events' do
let(:pipeline) { create(:ci_empty_pipeline) }
let(:build) { create(:ci_build, pipeline: pipeline) }
- let(:data) { Gitlab::BuildDataBuilder.build(build) }
+ let(:data) { Gitlab::DataBuilder::Build.build(build) }
context 'for failed' do
before { build.drop }
diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb
index ea718232255..ffb17fd3259 100644
--- a/spec/models/project_services/irker_service_spec.rb
+++ b/spec/models/project_services/irker_service_spec.rb
@@ -46,7 +46,9 @@ describe IrkerService, models: true do
let(:irker) { IrkerService.new }
let(:user) { create(:user) }
let(:project) { create(:project) }
- let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
let(:recipients) { '#commits irc://test.net/#test ftp://bad' }
let(:colorize_messages) { '1' }
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 342403f6354..9037ca5cc20 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -66,7 +66,7 @@ describe JiraService, models: true do
password: 'gitlab_jira_password'
)
@jira_service.save # will build API URL, as api_url was not specified above
- @sample_data = Gitlab::PushDataBuilder.build_sample(project, user)
+ @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
# https://github.com/bblimke/webmock#request-with-basic-authentication
@api_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions'
@comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment'
diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb
index 19c0270a493..5959c81577d 100644
--- a/spec/models/project_services/pushover_service_spec.rb
+++ b/spec/models/project_services/pushover_service_spec.rb
@@ -48,7 +48,9 @@ describe PushoverService, models: true do
let(:pushover) { PushoverService.new }
let(:user) { create(:user) }
let(:project) { create(:project) }
- let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
let(:api_key) { 'verySecret' }
let(:user_key) { 'verySecret' }
diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb
index 45a5f4ef12a..28af68d13b4 100644
--- a/spec/models/project_services/slack_service_spec.rb
+++ b/spec/models/project_services/slack_service_spec.rb
@@ -45,7 +45,9 @@ describe SlackService, models: true do
let(:slack) { SlackService.new }
let(:user) { create(:user) }
let(:project) { create(:project) }
- let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:push_sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' }
let(:username) { 'slack_username' }
let(:channel) { 'slack_channel' }
@@ -195,7 +197,7 @@ describe SlackService, models: true do
it "uses the right channel" do
slack.update_attributes(note_channel: "random")
- note_data = Gitlab::NoteDataBuilder.build(issue_note, user)
+ note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
expect(Slack::Notifier).to receive(:new).
with(webhook_url, channel: "random").
@@ -235,7 +237,7 @@ describe SlackService, models: true do
end
it "calls Slack API for commit comment events" do
- data = Gitlab::NoteDataBuilder.build(commit_note, user)
+ data = Gitlab::DataBuilder::Note.build(commit_note, user)
slack.execute(data)
expect(WebMock).to have_requested(:post, webhook_url).once
@@ -249,7 +251,7 @@ describe SlackService, models: true do
end
it "calls Slack API for merge request comment events" do
- data = Gitlab::NoteDataBuilder.build(merge_request_note, user)
+ data = Gitlab::DataBuilder::Note.build(merge_request_note, user)
slack.execute(data)
expect(WebMock).to have_requested(:post, webhook_url).once
@@ -262,7 +264,7 @@ describe SlackService, models: true do
end
it "calls Slack API for issue comment events" do
- data = Gitlab::NoteDataBuilder.build(issue_note, user)
+ data = Gitlab::DataBuilder::Note.build(issue_note, user)
slack.execute(data)
expect(WebMock).to have_requested(:post, webhook_url).once
@@ -276,7 +278,7 @@ describe SlackService, models: true do
end
it "calls Slack API for snippet comment events" do
- data = Gitlab::NoteDataBuilder.build(snippet_note, user)
+ data = Gitlab::DataBuilder::Note.build(snippet_note, user)
slack.execute(data)
expect(WebMock).to have_requested(:post, webhook_url).once
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 9c3b4712cab..0a32a486703 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1089,13 +1089,13 @@ describe Project, models: true do
let(:project) { create(:project) }
it 'returns true when the branch matches a protected branch via direct match' do
- project.protected_branches.create!(name: 'foo')
+ create(:protected_branch, project: project, name: "foo")
expect(project.protected_branch?('foo')).to eq(true)
end
it 'returns true when the branch matches a protected branch via wildcard match' do
- project.protected_branches.create!(name: 'production/*')
+ create(:protected_branch, project: project, name: "production/*")
expect(project.protected_branch?('production/some-branch')).to eq(true)
end
@@ -1105,7 +1105,7 @@ describe Project, models: true do
end
it 'returns false when the branch does not match a protected branch via wildcard match' do
- project.protected_branches.create!(name: 'production/*')
+ create(:protected_branch, project: project, name: "production/*")
expect(project.protected_branch?('staging/some-branch')).to eq(false)
end
diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb
new file mode 100644
index 00000000000..a8c25766e73
--- /dev/null
+++ b/spec/models/user_agent_detail_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+describe UserAgentDetail, type: :model do
+ describe '.submittable?' do
+ it 'is submittable when not already submitted' do
+ detail = build(:user_agent_detail)
+
+ expect(detail.submittable?).to be_truthy
+ end
+
+ it 'is not submittable when already submitted' do
+ detail = build(:user_agent_detail, submitted: true)
+
+ expect(detail.submittable?).to be_falsey
+ end
+ end
+
+ describe '.valid?' do
+ it 'is valid with a subject' do
+ detail = build(:user_agent_detail)
+
+ expect(detail).to be_valid
+ end
+
+ it 'is invalid without a subject' do
+ detail = build(:user_agent_detail, subject: nil)
+
+ expect(detail).not_to be_valid
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f67acbbef37..51e4780e2b1 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -895,7 +895,9 @@ describe User, models: true do
subject { create(:user) }
let!(:project1) { create(:project) }
let!(:project2) { create(:project, forked_from_project: project1) }
- let!(:push_data) { Gitlab::PushDataBuilder.build_sample(project2, subject) }
+ let!(:push_data) do
+ Gitlab::DataBuilder::Push.build_sample(project2, subject)
+ end
let!(:push_event) { create(:event, action: Event::PUSHED, project: project2, target: project1, author: subject, data: push_data) }
before do
@@ -955,6 +957,53 @@ describe User, models: true do
end
end
+ describe '#projects_where_can_admin_issues' do
+ let(:user) { create(:user) }
+
+ it 'includes projects for which the user access level is above or equal to reporter' do
+ create(:project)
+ reporter_project = create(:project)
+ developer_project = create(:project)
+ master_project = create(:project)
+
+ reporter_project.team << [user, :reporter]
+ developer_project.team << [user, :developer]
+ master_project.team << [user, :master]
+
+ expect(user.projects_where_can_admin_issues.to_a).to eq([master_project, developer_project, reporter_project])
+ expect(user.can?(:admin_issue, master_project)).to eq(true)
+ expect(user.can?(:admin_issue, developer_project)).to eq(true)
+ expect(user.can?(:admin_issue, reporter_project)).to eq(true)
+ end
+
+ it 'does not include for which the user access level is below reporter' do
+ project = create(:project)
+ guest_project = create(:project)
+
+ guest_project.team << [user, :guest]
+
+ expect(user.projects_where_can_admin_issues.to_a).to be_empty
+ expect(user.can?(:admin_issue, guest_project)).to eq(false)
+ expect(user.can?(:admin_issue, project)).to eq(false)
+ end
+
+ it 'does not include archived projects' do
+ project = create(:project)
+ project.update_attributes(archived: true)
+
+ expect(user.projects_where_can_admin_issues.to_a).to be_empty
+ expect(user.can?(:admin_issue, project)).to eq(false)
+ end
+
+ it 'does not include projects for which issues are disabled' do
+ project = create(:project)
+ project.update_attributes(issues_enabled: false)
+
+ expect(user.projects_where_can_admin_issues.to_a).to be_empty
+ expect(user.can?(:admin_issue, project)).to eq(false)
+ end
+ end
+
describe '#ci_authorized_runners' do
let(:user) { create(:user) }
let(:runner) { create(:ci_runner) }
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 9444138f93d..3fd989dd7a6 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -243,7 +243,7 @@ describe API::API, api: true do
end
it "removes protected branch" do
- project.protected_branches.create(name: branch_name)
+ create(:protected_branch, project: project, name: branch_name)
delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user)
expect(response).to have_http_status(405)
expect(json_response['message']).to eq('Protected branch cant be removed')
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 3cd4e981fb2..a40e1a93b71 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -531,8 +531,8 @@ describe API::API, api: true do
describe 'POST /projects/:id/issues with spam filtering' do
before do
- allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true)
- allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true)
+ allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
+ allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true)
end
let(:params) do
@@ -554,7 +554,6 @@ describe API::API, api: true do
expect(spam_logs[0].description).to eq('content here')
expect(spam_logs[0].user).to eq(user)
expect(spam_logs[0].noteable_type).to eq('Issue')
- expect(spam_logs[0].project_id).to eq(project.id)
end
end
diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb
index 34fac297923..914e88c9487 100644
--- a/spec/requests/api/project_hooks_spec.rb
+++ b/spec/requests/api/project_hooks_spec.rb
@@ -7,9 +7,9 @@ describe API::API, 'ProjectHooks', api: true do
let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:hook) do
create(:project_hook,
- project: project, url: "http://example.com",
- push_events: true, merge_requests_events: true, tag_push_events: true,
- issues_events: true, note_events: true, build_events: true,
+ :all_events_enabled,
+ project: project,
+ url: 'http://example.com',
enable_ssl_verification: true)
end
@@ -33,6 +33,7 @@ describe API::API, 'ProjectHooks', api: true do
expect(json_response.first['tag_push_events']).to eq(true)
expect(json_response.first['note_events']).to eq(true)
expect(json_response.first['build_events']).to eq(true)
+ expect(json_response.first['pipeline_events']).to eq(true)
expect(json_response.first['enable_ssl_verification']).to eq(true)
end
end
@@ -91,6 +92,7 @@ describe API::API, 'ProjectHooks', api: true do
expect(json_response['tag_push_events']).to eq(false)
expect(json_response['note_events']).to eq(false)
expect(json_response['build_events']).to eq(false)
+ expect(json_response['pipeline_events']).to eq(false)
expect(json_response['enable_ssl_verification']).to eq(true)
end
diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb
index 68d0f41b489..5bd5b861792 100644
--- a/spec/requests/api/templates_spec.rb
+++ b/spec/requests/api/templates_spec.rb
@@ -3,50 +3,53 @@ require 'spec_helper'
describe API::Templates, api: true do
include ApiHelpers
- describe 'the Template Entity' do
- before { get api('/gitignores/Ruby') }
+ context 'global templates' do
+ describe 'the Template Entity' do
+ before { get api('/gitignores/Ruby') }
- it { expect(json_response['name']).to eq('Ruby') }
- it { expect(json_response['content']).to include('*.gem') }
- end
+ it { expect(json_response['name']).to eq('Ruby') }
+ it { expect(json_response['content']).to include('*.gem') }
+ end
- describe 'the TemplateList Entity' do
- before { get api('/gitignores') }
+ describe 'the TemplateList Entity' do
+ before { get api('/gitignores') }
- it { expect(json_response.first['name']).not_to be_nil }
- it { expect(json_response.first['content']).to be_nil }
- end
+ it { expect(json_response.first['name']).not_to be_nil }
+ it { expect(json_response.first['content']).to be_nil }
+ end
- context 'requesting gitignores' do
- describe 'GET /gitignores' do
- it 'returns a list of available gitignore templates' do
- get api('/gitignores')
+ context 'requesting gitignores' do
+ describe 'GET /gitignores' do
+ it 'returns a list of available gitignore templates' do
+ get api('/gitignores')
- expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
- expect(json_response.size).to be > 15
+ expect(response.status).to eq(200)
+ expect(json_response).to be_an Array
+ expect(json_response.size).to be > 15
+ end
end
end
- end
- context 'requesting gitlab-ci-ymls' do
- describe 'GET /gitlab_ci_ymls' do
- it 'returns a list of available gitlab_ci_ymls' do
- get api('/gitlab_ci_ymls')
+ context 'requesting gitlab-ci-ymls' do
+ describe 'GET /gitlab_ci_ymls' do
+ it 'returns a list of available gitlab_ci_ymls' do
+ get api('/gitlab_ci_ymls')
- expect(response).to have_http_status(200)
- expect(json_response).to be_an Array
- expect(json_response.first['name']).not_to be_nil
+ expect(response.status).to eq(200)
+ expect(json_response).to be_an Array
+ expect(json_response.first['name']).not_to be_nil
+ end
end
end
- end
- describe 'GET /gitlab_ci_ymls/Ruby' do
- it 'adds a disclaimer on the top' do
- get api('/gitlab_ci_ymls/Ruby')
+ describe 'GET /gitlab_ci_ymls/Ruby' do
+ it 'adds a disclaimer on the top' do
+ get api('/gitlab_ci_ymls/Ruby')
- expect(response).to have_http_status(200)
- expect(json_response['content']).to start_with("# This file is a template,")
+ 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,")
+ end
end
end
end
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index 80f6ebac86c..6ac1fa8f182 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -227,8 +227,8 @@ describe GitPushService, services: true do
expect(project.default_branch).to eq("master")
execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
expect(project.protected_branches).not_to be_empty
- expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER)
- expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::MASTER)
+ expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
+ expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
end
it "when pushing a branch for the first time with default branch protection disabled" do
@@ -249,8 +249,8 @@ describe GitPushService, services: true do
execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
expect(project.protected_branches).not_to be_empty
- expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER)
- expect(project.protected_branches.last.merge_access_level.access_level).to eq(Gitlab::Access::MASTER)
+ expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
+ expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
end
it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do
@@ -260,8 +260,8 @@ describe GitPushService, services: true do
expect(project.default_branch).to eq("master")
execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' )
expect(project.protected_branches).not_to be_empty
- expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER)
- expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::DEVELOPER)
+ expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
+ expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
end
it "when pushing new commits to existing branch" do
diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml
index 3ceec506401..17136dee000 100644
--- a/spec/support/import_export/import_export.yml
+++ b/spec/support/import_export/import_export.yml
@@ -7,6 +7,8 @@ project_tree:
- :merge_request_test
- commit_statuses:
- :commit
+ - project_members:
+ - :user
included_attributes:
project:
@@ -14,6 +16,8 @@ included_attributes:
- :path
merge_requests:
- :id
+ user:
+ - :email
excluded_attributes:
merge_requests:
diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb
index 98deae0a588..788b92c1b84 100644
--- a/spec/workers/build_email_worker_spec.rb
+++ b/spec/workers/build_email_worker_spec.rb
@@ -5,7 +5,7 @@ describe BuildEmailWorker do
let(:build) { create(:ci_build) }
let(:user) { create(:user) }
- let(:data) { Gitlab::BuildDataBuilder.build(build) }
+ let(:data) { Gitlab::DataBuilder::Build.build(build) }
subject { BuildEmailWorker.new }
diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb
index 796751efe8d..eecc32875a5 100644
--- a/spec/workers/emails_on_push_worker_spec.rb
+++ b/spec/workers/emails_on_push_worker_spec.rb
@@ -5,7 +5,7 @@ describe EmailsOnPushWorker do
let(:project) { create(:project) }
let(:user) { create(:user) }
- let(:data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
let(:recipients) { user.email }
let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) }
diff --git a/vendor/assets/javascripts/vue-resource.full.js b/vendor/assets/javascripts/vue-resource.full.js
new file mode 100644
index 00000000000..d7981dbec7e
--- /dev/null
+++ b/vendor/assets/javascripts/vue-resource.full.js
@@ -0,0 +1,1318 @@
+/*!
+ * vue-resource v0.9.3
+ * https://github.com/vuejs/vue-resource
+ * Released under the MIT License.
+ */
+
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global.VueResource = factory());
+}(this, function () { 'use strict';
+
+ /**
+ * Promises/A+ polyfill v1.1.4 (https://github.com/bramstein/promis)
+ */
+
+ var RESOLVED = 0;
+ var REJECTED = 1;
+ var PENDING = 2;
+
+ function Promise$2(executor) {
+
+ this.state = PENDING;
+ this.value = undefined;
+ this.deferred = [];
+
+ var promise = this;
+
+ try {
+ executor(function (x) {
+ promise.resolve(x);
+ }, function (r) {
+ promise.reject(r);
+ });
+ } catch (e) {
+ promise.reject(e);
+ }
+ }
+
+ Promise$2.reject = function (r) {
+ return new Promise$2(function (resolve, reject) {
+ reject(r);
+ });
+ };
+
+ Promise$2.resolve = function (x) {
+ return new Promise$2(function (resolve, reject) {
+ resolve(x);
+ });
+ };
+
+ Promise$2.all = function all(iterable) {
+ return new Promise$2(function (resolve, reject) {
+ var count = 0,
+ result = [];
+
+ if (iterable.length === 0) {
+ resolve(result);
+ }
+
+ function resolver(i) {
+ return function (x) {
+ result[i] = x;
+ count += 1;
+
+ if (count === iterable.length) {
+ resolve(result);
+ }
+ };
+ }
+
+ for (var i = 0; i < iterable.length; i += 1) {
+ Promise$2.resolve(iterable[i]).then(resolver(i), reject);
+ }
+ });
+ };
+
+ Promise$2.race = function race(iterable) {
+ return new Promise$2(function (resolve, reject) {
+ for (var i = 0; i < iterable.length; i += 1) {
+ Promise$2.resolve(iterable[i]).then(resolve, reject);
+ }
+ });
+ };
+
+ var p$1 = Promise$2.prototype;
+
+ p$1.resolve = function resolve(x) {
+ var promise = this;
+
+ if (promise.state === PENDING) {
+ if (x === promise) {
+ throw new TypeError('Promise settled with itself.');
+ }
+
+ var called = false;
+
+ try {
+ var then = x && x['then'];
+
+ if (x !== null && typeof x === 'object' && typeof then === 'function') {
+ then.call(x, function (x) {
+ if (!called) {
+ promise.resolve(x);
+ }
+ called = true;
+ }, function (r) {
+ if (!called) {
+ promise.reject(r);
+ }
+ called = true;
+ });
+ return;
+ }
+ } catch (e) {
+ if (!called) {
+ promise.reject(e);
+ }
+ return;
+ }
+
+ promise.state = RESOLVED;
+ promise.value = x;
+ promise.notify();
+ }
+ };
+
+ p$1.reject = function reject(reason) {
+ var promise = this;
+
+ if (promise.state === PENDING) {
+ if (reason === promise) {
+ throw new TypeError('Promise settled with itself.');
+ }
+
+ promise.state = REJECTED;
+ promise.value = reason;
+ promise.notify();
+ }
+ };
+
+ p$1.notify = function notify() {
+ var promise = this;
+
+ nextTick(function () {
+ if (promise.state !== PENDING) {
+ while (promise.deferred.length) {
+ var deferred = promise.deferred.shift(),
+ onResolved = deferred[0],
+ onRejected = deferred[1],
+ resolve = deferred[2],
+ reject = deferred[3];
+
+ try {
+ if (promise.state === RESOLVED) {
+ if (typeof onResolved === 'function') {
+ resolve(onResolved.call(undefined, promise.value));
+ } else {
+ resolve(promise.value);
+ }
+ } else if (promise.state === REJECTED) {
+ if (typeof onRejected === 'function') {
+ resolve(onRejected.call(undefined, promise.value));
+ } else {
+ reject(promise.value);
+ }
+ }
+ } catch (e) {
+ reject(e);
+ }
+ }
+ }
+ });
+ };
+
+ p$1.then = function then(onResolved, onRejected) {
+ var promise = this;
+
+ return new Promise$2(function (resolve, reject) {
+ promise.deferred.push([onResolved, onRejected, resolve, reject]);
+ promise.notify();
+ });
+ };
+
+ p$1.catch = function (onRejected) {
+ return this.then(undefined, onRejected);
+ };
+
+ var PromiseObj = window.Promise || Promise$2;
+
+ function Promise$1(executor, context) {
+
+ if (executor instanceof PromiseObj) {
+ this.promise = executor;
+ } else {
+ this.promise = new PromiseObj(executor.bind(context));
+ }
+
+ this.context = context;
+ }
+
+ Promise$1.all = function (iterable, context) {
+ return new Promise$1(PromiseObj.all(iterable), context);
+ };
+
+ Promise$1.resolve = function (value, context) {
+ return new Promise$1(PromiseObj.resolve(value), context);
+ };
+
+ Promise$1.reject = function (reason, context) {
+ return new Promise$1(PromiseObj.reject(reason), context);
+ };
+
+ Promise$1.race = function (iterable, context) {
+ return new Promise$1(PromiseObj.race(iterable), context);
+ };
+
+ var p = Promise$1.prototype;
+
+ p.bind = function (context) {
+ this.context = context;
+ return this;
+ };
+
+ p.then = function (fulfilled, rejected) {
+
+ if (fulfilled && fulfilled.bind && this.context) {
+ fulfilled = fulfilled.bind(this.context);
+ }
+
+ if (rejected && rejected.bind && this.context) {
+ rejected = rejected.bind(this.context);
+ }
+
+ return new Promise$1(this.promise.then(fulfilled, rejected), this.context);
+ };
+
+ p.catch = function (rejected) {
+
+ if (rejected && rejected.bind && this.context) {
+ rejected = rejected.bind(this.context);
+ }
+
+ return new Promise$1(this.promise.catch(rejected), this.context);
+ };
+
+ p.finally = function (callback) {
+
+ return this.then(function (value) {
+ callback.call(this);
+ return value;
+ }, function (reason) {
+ callback.call(this);
+ return PromiseObj.reject(reason);
+ });
+ };
+
+ var debug = false;
+ var util = {};
+ var array = [];
+ function Util (Vue) {
+ util = Vue.util;
+ debug = Vue.config.debug || !Vue.config.silent;
+ }
+
+ function warn(msg) {
+ if (typeof console !== 'undefined' && debug) {
+ console.warn('[VueResource warn]: ' + msg);
+ }
+ }
+
+ function error(msg) {
+ if (typeof console !== 'undefined') {
+ console.error(msg);
+ }
+ }
+
+ function nextTick(cb, ctx) {
+ return util.nextTick(cb, ctx);
+ }
+
+ function trim(str) {
+ return str.replace(/^\s*|\s*$/g, '');
+ }
+
+ var isArray = Array.isArray;
+
+ function isString(val) {
+ return typeof val === 'string';
+ }
+
+ function isBoolean(val) {
+ return val === true || val === false;
+ }
+
+ function isFunction(val) {
+ return typeof val === 'function';
+ }
+
+ function isObject(obj) {
+ return obj !== null && typeof obj === 'object';
+ }
+
+ function isPlainObject(obj) {
+ return isObject(obj) && Object.getPrototypeOf(obj) == Object.prototype;
+ }
+
+ function isFormData(obj) {
+ return typeof FormData !== 'undefined' && obj instanceof FormData;
+ }
+
+ function when(value, fulfilled, rejected) {
+
+ var promise = Promise$1.resolve(value);
+
+ if (arguments.length < 2) {
+ return promise;
+ }
+
+ return promise.then(fulfilled, rejected);
+ }
+
+ function options(fn, obj, opts) {
+
+ opts = opts || {};
+
+ if (isFunction(opts)) {
+ opts = opts.call(obj);
+ }
+
+ return merge(fn.bind({ $vm: obj, $options: opts }), fn, { $options: opts });
+ }
+
+ function each(obj, iterator) {
+
+ var i, key;
+
+ if (typeof obj.length == 'number') {
+ for (i = 0; i < obj.length; i++) {
+ iterator.call(obj[i], obj[i], i);
+ }
+ } else if (isObject(obj)) {
+ for (key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ iterator.call(obj[key], obj[key], key);
+ }
+ }
+ }
+
+ return obj;
+ }
+
+ var assign = Object.assign || _assign;
+
+ function merge(target) {
+
+ var args = array.slice.call(arguments, 1);
+
+ args.forEach(function (source) {
+ _merge(target, source, true);
+ });
+
+ return target;
+ }
+
+ function defaults(target) {
+
+ var args = array.slice.call(arguments, 1);
+
+ args.forEach(function (source) {
+
+ for (var key in source) {
+ if (target[key] === undefined) {
+ target[key] = source[key];
+ }
+ }
+ });
+
+ return target;
+ }
+
+ function _assign(target) {
+
+ var args = array.slice.call(arguments, 1);
+
+ args.forEach(function (source) {
+ _merge(target, source);
+ });
+
+ return target;
+ }
+
+ function _merge(target, source, deep) {
+ for (var key in source) {
+ if (deep && (isPlainObject(source[key]) || isArray(source[key]))) {
+ if (isPlainObject(source[key]) && !isPlainObject(target[key])) {
+ target[key] = {};
+ }
+ if (isArray(source[key]) && !isArray(target[key])) {
+ target[key] = [];
+ }
+ _merge(target[key], source[key], deep);
+ } else if (source[key] !== undefined) {
+ target[key] = source[key];
+ }
+ }
+ }
+
+ function root (options, next) {
+
+ var url = next(options);
+
+ if (isString(options.root) && !url.match(/^(https?:)?\//)) {
+ url = options.root + '/' + url;
+ }
+
+ return url;
+ }
+
+ function query (options, next) {
+
+ var urlParams = Object.keys(Url.options.params),
+ query = {},
+ url = next(options);
+
+ each(options.params, function (value, key) {
+ if (urlParams.indexOf(key) === -1) {
+ query[key] = value;
+ }
+ });
+
+ query = Url.params(query);
+
+ if (query) {
+ url += (url.indexOf('?') == -1 ? '?' : '&') + query;
+ }
+
+ return url;
+ }
+
+ /**
+ * URL Template v2.0.6 (https://github.com/bramstein/url-template)
+ */
+
+ function expand(url, params, variables) {
+
+ var tmpl = parse(url),
+ expanded = tmpl.expand(params);
+
+ if (variables) {
+ variables.push.apply(variables, tmpl.vars);
+ }
+
+ return expanded;
+ }
+
+ function parse(template) {
+
+ var operators = ['+', '#', '.', '/', ';', '?', '&'],
+ variables = [];
+
+ return {
+ vars: variables,
+ expand: function (context) {
+ return template.replace(/\{([^\{\}]+)\}|([^\{\}]+)/g, function (_, expression, literal) {
+ if (expression) {
+
+ var operator = null,
+ values = [];
+
+ if (operators.indexOf(expression.charAt(0)) !== -1) {
+ operator = expression.charAt(0);
+ expression = expression.substr(1);
+ }
+
+ expression.split(/,/g).forEach(function (variable) {
+ var tmp = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable);
+ values.push.apply(values, getValues(context, operator, tmp[1], tmp[2] || tmp[3]));
+ variables.push(tmp[1]);
+ });
+
+ if (operator && operator !== '+') {
+
+ var separator = ',';
+
+ if (operator === '?') {
+ separator = '&';
+ } else if (operator !== '#') {
+ separator = operator;
+ }
+
+ return (values.length !== 0 ? operator : '') + values.join(separator);
+ } else {
+ return values.join(',');
+ }
+ } else {
+ return encodeReserved(literal);
+ }
+ });
+ }
+ };
+ }
+
+ function getValues(context, operator, key, modifier) {
+
+ var value = context[key],
+ result = [];
+
+ if (isDefined(value) && value !== '') {
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ value = value.toString();
+
+ if (modifier && modifier !== '*') {
+ value = value.substring(0, parseInt(modifier, 10));
+ }
+
+ result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null));
+ } else {
+ if (modifier === '*') {
+ if (Array.isArray(value)) {
+ value.filter(isDefined).forEach(function (value) {
+ result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null));
+ });
+ } else {
+ Object.keys(value).forEach(function (k) {
+ if (isDefined(value[k])) {
+ result.push(encodeValue(operator, value[k], k));
+ }
+ });
+ }
+ } else {
+ var tmp = [];
+
+ if (Array.isArray(value)) {
+ value.filter(isDefined).forEach(function (value) {
+ tmp.push(encodeValue(operator, value));
+ });
+ } else {
+ Object.keys(value).forEach(function (k) {
+ if (isDefined(value[k])) {
+ tmp.push(encodeURIComponent(k));
+ tmp.push(encodeValue(operator, value[k].toString()));
+ }
+ });
+ }
+
+ if (isKeyOperator(operator)) {
+ result.push(encodeURIComponent(key) + '=' + tmp.join(','));
+ } else if (tmp.length !== 0) {
+ result.push(tmp.join(','));
+ }
+ }
+ }
+ } else {
+ if (operator === ';') {
+ result.push(encodeURIComponent(key));
+ } else if (value === '' && (operator === '&' || operator === '?')) {
+ result.push(encodeURIComponent(key) + '=');
+ } else if (value === '') {
+ result.push('');
+ }
+ }
+
+ return result;
+ }
+
+ function isDefined(value) {
+ return value !== undefined && value !== null;
+ }
+
+ function isKeyOperator(operator) {
+ return operator === ';' || operator === '&' || operator === '?';
+ }
+
+ function encodeValue(operator, value, key) {
+
+ value = operator === '+' || operator === '#' ? encodeReserved(value) : encodeURIComponent(value);
+
+ if (key) {
+ return encodeURIComponent(key) + '=' + value;
+ } else {
+ return value;
+ }
+ }
+
+ function encodeReserved(str) {
+ return str.split(/(%[0-9A-Fa-f]{2})/g).map(function (part) {
+ if (!/%[0-9A-Fa-f]/.test(part)) {
+ part = encodeURI(part);
+ }
+ return part;
+ }).join('');
+ }
+
+ function template (options) {
+
+ var variables = [],
+ url = expand(options.url, options.params, variables);
+
+ variables.forEach(function (key) {
+ delete options.params[key];
+ });
+
+ return url;
+ }
+
+ /**
+ * Service for URL templating.
+ */
+
+ var ie = document.documentMode;
+ var el = document.createElement('a');
+
+ function Url(url, params) {
+
+ var self = this || {},
+ options = url,
+ transform;
+
+ if (isString(url)) {
+ options = { url: url, params: params };
+ }
+
+ options = merge({}, Url.options, self.$options, options);
+
+ Url.transforms.forEach(function (handler) {
+ transform = factory(handler, transform, self.$vm);
+ });
+
+ return transform(options);
+ }
+
+ /**
+ * Url options.
+ */
+
+ Url.options = {
+ url: '',
+ root: null,
+ params: {}
+ };
+
+ /**
+ * Url transforms.
+ */
+
+ Url.transforms = [template, query, root];
+
+ /**
+ * Encodes a Url parameter string.
+ *
+ * @param {Object} obj
+ */
+
+ Url.params = function (obj) {
+
+ var params = [],
+ escape = encodeURIComponent;
+
+ params.add = function (key, value) {
+
+ if (isFunction(value)) {
+ value = value();
+ }
+
+ if (value === null) {
+ value = '';
+ }
+
+ this.push(escape(key) + '=' + escape(value));
+ };
+
+ serialize(params, obj);
+
+ return params.join('&').replace(/%20/g, '+');
+ };
+
+ /**
+ * Parse a URL and return its components.
+ *
+ * @param {String} url
+ */
+
+ Url.parse = function (url) {
+
+ if (ie) {
+ el.href = url;
+ url = el.href;
+ }
+
+ el.href = url;
+
+ return {
+ href: el.href,
+ protocol: el.protocol ? el.protocol.replace(/:$/, '') : '',
+ port: el.port,
+ host: el.host,
+ hostname: el.hostname,
+ pathname: el.pathname.charAt(0) === '/' ? el.pathname : '/' + el.pathname,
+ search: el.search ? el.search.replace(/^\?/, '') : '',
+ hash: el.hash ? el.hash.replace(/^#/, '') : ''
+ };
+ };
+
+ function factory(handler, next, vm) {
+ return function (options) {
+ return handler.call(vm, options, next);
+ };
+ }
+
+ function serialize(params, obj, scope) {
+
+ var array = isArray(obj),
+ plain = isPlainObject(obj),
+ hash;
+
+ each(obj, function (value, key) {
+
+ hash = isObject(value) || isArray(value);
+
+ if (scope) {
+ key = scope + '[' + (plain || hash ? key : '') + ']';
+ }
+
+ if (!scope && array) {
+ params.add(value.name, value.value);
+ } else if (hash) {
+ serialize(params, value, key);
+ } else {
+ params.add(key, value);
+ }
+ });
+ }
+
+ function xdrClient (request) {
+ return new Promise$1(function (resolve) {
+
+ var xdr = new XDomainRequest(),
+ handler = function (event) {
+
+ var response = request.respondWith(xdr.responseText, {
+ status: xdr.status,
+ statusText: xdr.statusText
+ });
+
+ resolve(response);
+ };
+
+ request.abort = function () {
+ return xdr.abort();
+ };
+
+ xdr.open(request.method, request.getUrl(), true);
+ xdr.timeout = 0;
+ xdr.onload = handler;
+ xdr.onerror = handler;
+ xdr.ontimeout = function () {};
+ xdr.onprogress = function () {};
+ xdr.send(request.getBody());
+ });
+ }
+
+ var ORIGIN_URL = Url.parse(location.href);
+ var SUPPORTS_CORS = 'withCredentials' in new XMLHttpRequest();
+
+ function cors (request, next) {
+
+ if (!isBoolean(request.crossOrigin) && crossOrigin(request)) {
+ request.crossOrigin = true;
+ }
+
+ if (request.crossOrigin) {
+
+ if (!SUPPORTS_CORS) {
+ request.client = xdrClient;
+ }
+
+ delete request.emulateHTTP;
+ }
+
+ next();
+ }
+
+ function crossOrigin(request) {
+
+ var requestUrl = Url.parse(Url(request));
+
+ return requestUrl.protocol !== ORIGIN_URL.protocol || requestUrl.host !== ORIGIN_URL.host;
+ }
+
+ function body (request, next) {
+
+ if (request.emulateJSON && isPlainObject(request.body)) {
+ request.body = Url.params(request.body);
+ request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
+ }
+
+ if (isFormData(request.body)) {
+ delete request.headers['Content-Type'];
+ }
+
+ if (isPlainObject(request.body)) {
+ request.body = JSON.stringify(request.body);
+ }
+
+ next(function (response) {
+
+ var contentType = response.headers['Content-Type'];
+
+ if (isString(contentType) && contentType.indexOf('application/json') === 0) {
+
+ try {
+ response.data = response.json();
+ } catch (e) {
+ response.data = null;
+ }
+ } else {
+ response.data = response.text();
+ }
+ });
+ }
+
+ function jsonpClient (request) {
+ return new Promise$1(function (resolve) {
+
+ var name = request.jsonp || 'callback',
+ callback = '_jsonp' + Math.random().toString(36).substr(2),
+ body = null,
+ handler,
+ script;
+
+ handler = function (event) {
+
+ var status = 0;
+
+ if (event.type === 'load' && body !== null) {
+ status = 200;
+ } else if (event.type === 'error') {
+ status = 404;
+ }
+
+ resolve(request.respondWith(body, { status: status }));
+
+ delete window[callback];
+ document.body.removeChild(script);
+ };
+
+ request.params[name] = callback;
+
+ window[callback] = function (result) {
+ body = JSON.stringify(result);
+ };
+
+ script = document.createElement('script');
+ script.src = request.getUrl();
+ script.type = 'text/javascript';
+ script.async = true;
+ script.onload = handler;
+ script.onerror = handler;
+
+ document.body.appendChild(script);
+ });
+ }
+
+ function jsonp (request, next) {
+
+ if (request.method == 'JSONP') {
+ request.client = jsonpClient;
+ }
+
+ next(function (response) {
+
+ if (request.method == 'JSONP') {
+ response.data = response.json();
+ }
+ });
+ }
+
+ function before (request, next) {
+
+ if (isFunction(request.before)) {
+ request.before.call(this, request);
+ }
+
+ next();
+ }
+
+ /**
+ * HTTP method override Interceptor.
+ */
+
+ function method (request, next) {
+
+ if (request.emulateHTTP && /^(PUT|PATCH|DELETE)$/i.test(request.method)) {
+ request.headers['X-HTTP-Method-Override'] = request.method;
+ request.method = 'POST';
+ }
+
+ next();
+ }
+
+ function header (request, next) {
+
+ request.method = request.method.toUpperCase();
+ request.headers = assign({}, Http.headers.common, !request.crossOrigin ? Http.headers.custom : {}, Http.headers[request.method.toLowerCase()], request.headers);
+
+ next();
+ }
+
+ /**
+ * Timeout Interceptor.
+ */
+
+ function timeout (request, next) {
+
+ var timeout;
+
+ if (request.timeout) {
+ timeout = setTimeout(function () {
+ request.abort();
+ }, request.timeout);
+ }
+
+ next(function (response) {
+
+ clearTimeout(timeout);
+ });
+ }
+
+ function xhrClient (request) {
+ return new Promise$1(function (resolve) {
+
+ var xhr = new XMLHttpRequest(),
+ handler = function (event) {
+
+ var response = request.respondWith('response' in xhr ? xhr.response : xhr.responseText, {
+ status: xhr.status === 1223 ? 204 : xhr.status, // IE9 status bug
+ statusText: xhr.status === 1223 ? 'No Content' : trim(xhr.statusText),
+ headers: parseHeaders(xhr.getAllResponseHeaders())
+ });
+
+ resolve(response);
+ };
+
+ request.abort = function () {
+ return xhr.abort();
+ };
+
+ xhr.open(request.method, request.getUrl(), true);
+ xhr.timeout = 0;
+ xhr.onload = handler;
+ xhr.onerror = handler;
+
+ if (request.progress) {
+ if (request.method === 'GET') {
+ xhr.addEventListener('progress', request.progress);
+ } else if (/^(POST|PUT)$/i.test(request.method)) {
+ xhr.upload.addEventListener('progress', request.progress);
+ }
+ }
+
+ if (request.credentials === true) {
+ xhr.withCredentials = true;
+ }
+
+ each(request.headers || {}, function (value, header) {
+ xhr.setRequestHeader(header, value);
+ });
+
+ xhr.send(request.getBody());
+ });
+ }
+
+ function parseHeaders(str) {
+
+ var headers = {},
+ value,
+ name,
+ i;
+
+ each(trim(str).split('\n'), function (row) {
+
+ i = row.indexOf(':');
+ name = trim(row.slice(0, i));
+ value = trim(row.slice(i + 1));
+
+ if (headers[name]) {
+
+ if (isArray(headers[name])) {
+ headers[name].push(value);
+ } else {
+ headers[name] = [headers[name], value];
+ }
+ } else {
+
+ headers[name] = value;
+ }
+ });
+
+ return headers;
+ }
+
+ function Client (context) {
+
+ var reqHandlers = [sendRequest],
+ resHandlers = [],
+ handler;
+
+ if (!isObject(context)) {
+ context = null;
+ }
+
+ function Client(request) {
+ return new Promise$1(function (resolve) {
+
+ function exec() {
+
+ handler = reqHandlers.pop();
+
+ if (isFunction(handler)) {
+ handler.call(context, request, next);
+ } else {
+ warn('Invalid interceptor of type ' + typeof handler + ', must be a function');
+ next();
+ }
+ }
+
+ function next(response) {
+
+ if (isFunction(response)) {
+
+ resHandlers.unshift(response);
+ } else if (isObject(response)) {
+
+ resHandlers.forEach(function (handler) {
+ response = when(response, function (response) {
+ return handler.call(context, response) || response;
+ });
+ });
+
+ when(response, resolve);
+
+ return;
+ }
+
+ exec();
+ }
+
+ exec();
+ }, context);
+ }
+
+ Client.use = function (handler) {
+ reqHandlers.push(handler);
+ };
+
+ return Client;
+ }
+
+ function sendRequest(request, resolve) {
+
+ var client = request.client || xhrClient;
+
+ resolve(client(request));
+ }
+
+ var classCallCheck = function (instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError("Cannot call a class as a function");
+ }
+ };
+
+ /**
+ * HTTP Response.
+ */
+
+ var Response = function () {
+ function Response(body, _ref) {
+ var url = _ref.url;
+ var headers = _ref.headers;
+ var status = _ref.status;
+ var statusText = _ref.statusText;
+ classCallCheck(this, Response);
+
+
+ this.url = url;
+ this.body = body;
+ this.headers = headers || {};
+ this.status = status || 0;
+ this.statusText = statusText || '';
+ this.ok = status >= 200 && status < 300;
+ }
+
+ Response.prototype.text = function text() {
+ return this.body;
+ };
+
+ Response.prototype.blob = function blob() {
+ return new Blob([this.body]);
+ };
+
+ Response.prototype.json = function json() {
+ return JSON.parse(this.body);
+ };
+
+ return Response;
+ }();
+
+ var Request = function () {
+ function Request(options) {
+ classCallCheck(this, Request);
+
+
+ this.method = 'GET';
+ this.body = null;
+ this.params = {};
+ this.headers = {};
+
+ assign(this, options);
+ }
+
+ Request.prototype.getUrl = function getUrl() {
+ return Url(this);
+ };
+
+ Request.prototype.getBody = function getBody() {
+ return this.body;
+ };
+
+ Request.prototype.respondWith = function respondWith(body, options) {
+ return new Response(body, assign(options || {}, { url: this.getUrl() }));
+ };
+
+ return Request;
+ }();
+
+ /**
+ * Service for sending network requests.
+ */
+
+ var CUSTOM_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' };
+ var COMMON_HEADERS = { 'Accept': 'application/json, text/plain, */*' };
+ var JSON_CONTENT_TYPE = { 'Content-Type': 'application/json;charset=utf-8' };
+
+ function Http(options) {
+
+ var self = this || {},
+ client = Client(self.$vm);
+
+ defaults(options || {}, self.$options, Http.options);
+
+ Http.interceptors.forEach(function (handler) {
+ client.use(handler);
+ });
+
+ return client(new Request(options)).then(function (response) {
+
+ return response.ok ? response : Promise$1.reject(response);
+ }, function (response) {
+
+ if (response instanceof Error) {
+ error(response);
+ }
+
+ return Promise$1.reject(response);
+ });
+ }
+
+ Http.options = {};
+
+ Http.headers = {
+ put: JSON_CONTENT_TYPE,
+ post: JSON_CONTENT_TYPE,
+ patch: JSON_CONTENT_TYPE,
+ delete: JSON_CONTENT_TYPE,
+ custom: CUSTOM_HEADERS,
+ common: COMMON_HEADERS
+ };
+
+ Http.interceptors = [before, timeout, method, body, jsonp, header, cors];
+
+ ['get', 'delete', 'head', 'jsonp'].forEach(function (method) {
+
+ Http[method] = function (url, options) {
+ return this(assign(options || {}, { url: url, method: method }));
+ };
+ });
+
+ ['post', 'put', 'patch'].forEach(function (method) {
+
+ Http[method] = function (url, body, options) {
+ return this(assign(options || {}, { url: url, method: method, body: body }));
+ };
+ });
+
+ function Resource(url, params, actions, options) {
+
+ var self = this || {},
+ resource = {};
+
+ actions = assign({}, Resource.actions, actions);
+
+ each(actions, function (action, name) {
+
+ action = merge({ url: url, params: params || {} }, options, action);
+
+ resource[name] = function () {
+ return (self.$http || Http)(opts(action, arguments));
+ };
+ });
+
+ return resource;
+ }
+
+ function opts(action, args) {
+
+ var options = assign({}, action),
+ params = {},
+ body;
+
+ switch (args.length) {
+
+ case 2:
+
+ params = args[0];
+ body = args[1];
+
+ break;
+
+ case 1:
+
+ if (/^(POST|PUT|PATCH)$/i.test(options.method)) {
+ body = args[0];
+ } else {
+ params = args[0];
+ }
+
+ break;
+
+ case 0:
+
+ break;
+
+ default:
+
+ throw 'Expected up to 4 arguments [params, body], got ' + args.length + ' arguments';
+ }
+
+ options.body = body;
+ options.params = assign({}, options.params, params);
+
+ return options;
+ }
+
+ Resource.actions = {
+
+ get: { method: 'GET' },
+ save: { method: 'POST' },
+ query: { method: 'GET' },
+ update: { method: 'PUT' },
+ remove: { method: 'DELETE' },
+ delete: { method: 'DELETE' }
+
+ };
+
+ function plugin(Vue) {
+
+ if (plugin.installed) {
+ return;
+ }
+
+ Util(Vue);
+
+ Vue.url = Url;
+ Vue.http = Http;
+ Vue.resource = Resource;
+ Vue.Promise = Promise$1;
+
+ Object.defineProperties(Vue.prototype, {
+
+ $url: {
+ get: function () {
+ return options(Vue.url, this, this.$options.url);
+ }
+ },
+
+ $http: {
+ get: function () {
+ return options(Vue.http, this, this.$options.http);
+ }
+ },
+
+ $resource: {
+ get: function () {
+ return Vue.resource.bind(this);
+ }
+ },
+
+ $promise: {
+ get: function () {
+ var _this = this;
+
+ return function (executor) {
+ return new Vue.Promise(executor, _this);
+ };
+ }
+ }
+
+ });
+ }
+
+ if (typeof window !== 'undefined' && window.Vue) {
+ window.Vue.use(plugin);
+ }
+
+ return plugin;
+
+}));
\ No newline at end of file
diff --git a/vendor/assets/javascripts/vue-resource.js.erb b/vendor/assets/javascripts/vue-resource.js.erb
new file mode 100644
index 00000000000..8001775ce98
--- /dev/null
+++ b/vendor/assets/javascripts/vue-resource.js.erb
@@ -0,0 +1,2 @@
+<% type = Rails.env.development? ? 'full' : 'min' %>
+<%= File.read(Rails.root.join("vendor/assets/javascripts/vue-resource.#{type}.js")) %>
diff --git a/vendor/assets/javascripts/vue-resource.min.js b/vendor/assets/javascripts/vue-resource.min.js
new file mode 100644
index 00000000000..6bff73a2a67
--- /dev/null
+++ b/vendor/assets/javascripts/vue-resource.min.js
@@ -0,0 +1,7 @@
+/*!
+ * vue-resource v0.9.3
+ * https://github.com/vuejs/vue-resource
+ * Released under the MIT License.
+ */
+
+!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):t.VueResource=n()}(this,function(){"use strict";function t(t){this.state=Z,this.value=void 0,this.deferred=[];var n=this;try{t(function(t){n.resolve(t)},function(t){n.reject(t)})}catch(e){n.reject(e)}}function n(t,n){t instanceof nt?this.promise=t:this.promise=new nt(t.bind(n)),this.context=n}function e(t){rt=t.util,ot=t.config.debug||!t.config.silent}function o(t){"undefined"!=typeof console&&ot&&console.warn("[VueResource warn]: "+t)}function r(t){"undefined"!=typeof console&&console.error(t)}function i(t,n){return rt.nextTick(t,n)}function u(t){return t.replace(/^\s*|\s*$/g,"")}function s(t){return"string"==typeof t}function c(t){return t===!0||t===!1}function a(t){return"function"==typeof t}function f(t){return null!==t&&"object"==typeof t}function h(t){return f(t)&&Object.getPrototypeOf(t)==Object.prototype}function p(t){return"undefined"!=typeof FormData&&t instanceof FormData}function l(t,e,o){var r=n.resolve(t);return arguments.length<2?r:r.then(e,o)}function d(t,n,e){return e=e||{},a(e)&&(e=e.call(n)),v(t.bind({$vm:n,$options:e}),t,{$options:e})}function m(t,n){var e,o;if("number"==typeof t.length)for(e=0;e=200&&i<300}return t.prototype.text=function(){return this.body},t.prototype.blob=function(){return new Blob([this.body])},t.prototype.json=function(){return JSON.parse(this.body)},t}(),dt=function(){function t(n){pt(this,t),this.method="GET",this.body=null,this.params={},this.headers={},st(this,n)}return t.prototype.getUrl=function(){return R(this)},t.prototype.getBody=function(){return this.body},t.prototype.respondWith=function(t,n){return new lt(t,st(n||{},{url:this.getUrl()}))},t}(),mt={"X-Requested-With":"XMLHttpRequest"},vt={Accept:"application/json, text/plain, */*"},yt={"Content-Type":"application/json;charset=utf-8"};return V.options={},V.headers={put:yt,post:yt,patch:yt,"delete":yt,custom:mt,common:vt},V.interceptors=[D,X,J,L,N,M,H],["get","delete","head","jsonp"].forEach(function(t){V[t]=function(n,e){return this(st(e||{},{url:n,method:t}))}}),["post","put","patch"].forEach(function(t){V[t]=function(n,e,o){return this(st(o||{},{url:n,method:t,body:e}))}}),_.actions={get:{method:"GET"},save:{method:"POST"},query:{method:"GET"},update:{method:"PUT"},remove:{method:"DELETE"},"delete":{method:"DELETE"}},"undefined"!=typeof window&&window.Vue&&window.Vue.use(K),K});
\ No newline at end of file
diff --git a/vendor/assets/javascripts/vue.full.js b/vendor/assets/javascripts/vue.full.js
new file mode 100644
index 00000000000..7ae95897a01
--- /dev/null
+++ b/vendor/assets/javascripts/vue.full.js
@@ -0,0 +1,10073 @@
+/*!
+ * Vue.js v1.0.26
+ * (c) 2016 Evan You
+ * Released under the MIT License.
+ */
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global.Vue = factory());
+}(this, function () { 'use strict';
+
+ function set(obj, key, val) {
+ if (hasOwn(obj, key)) {
+ obj[key] = val;
+ return;
+ }
+ if (obj._isVue) {
+ set(obj._data, key, val);
+ return;
+ }
+ var ob = obj.__ob__;
+ if (!ob) {
+ obj[key] = val;
+ return;
+ }
+ ob.convert(key, val);
+ ob.dep.notify();
+ if (ob.vms) {
+ var i = ob.vms.length;
+ while (i--) {
+ var vm = ob.vms[i];
+ vm._proxy(key);
+ vm._digest();
+ }
+ }
+ return val;
+ }
+
+ /**
+ * Delete a property and trigger change if necessary.
+ *
+ * @param {Object} obj
+ * @param {String} key
+ */
+
+ function del(obj, key) {
+ if (!hasOwn(obj, key)) {
+ return;
+ }
+ delete obj[key];
+ var ob = obj.__ob__;
+ if (!ob) {
+ if (obj._isVue) {
+ delete obj._data[key];
+ obj._digest();
+ }
+ return;
+ }
+ ob.dep.notify();
+ if (ob.vms) {
+ var i = ob.vms.length;
+ while (i--) {
+ var vm = ob.vms[i];
+ vm._unproxy(key);
+ vm._digest();
+ }
+ }
+ }
+
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
+ /**
+ * Check whether the object has the property.
+ *
+ * @param {Object} obj
+ * @param {String} key
+ * @return {Boolean}
+ */
+
+ function hasOwn(obj, key) {
+ return hasOwnProperty.call(obj, key);
+ }
+
+ /**
+ * Check if an expression is a literal value.
+ *
+ * @param {String} exp
+ * @return {Boolean}
+ */
+
+ var literalValueRE = /^\s?(true|false|-?[\d\.]+|'[^']*'|"[^"]*")\s?$/;
+
+ function isLiteral(exp) {
+ return literalValueRE.test(exp);
+ }
+
+ /**
+ * Check if a string starts with $ or _
+ *
+ * @param {String} str
+ * @return {Boolean}
+ */
+
+ function isReserved(str) {
+ var c = (str + '').charCodeAt(0);
+ return c === 0x24 || c === 0x5F;
+ }
+
+ /**
+ * Guard text output, make sure undefined outputs
+ * empty string
+ *
+ * @param {*} value
+ * @return {String}
+ */
+
+ function _toString(value) {
+ return value == null ? '' : value.toString();
+ }
+
+ /**
+ * Check and convert possible numeric strings to numbers
+ * before setting back to data
+ *
+ * @param {*} value
+ * @return {*|Number}
+ */
+
+ function toNumber(value) {
+ if (typeof value !== 'string') {
+ return value;
+ } else {
+ var parsed = Number(value);
+ return isNaN(parsed) ? value : parsed;
+ }
+ }
+
+ /**
+ * Convert string boolean literals into real booleans.
+ *
+ * @param {*} value
+ * @return {*|Boolean}
+ */
+
+ function toBoolean(value) {
+ return value === 'true' ? true : value === 'false' ? false : value;
+ }
+
+ /**
+ * Strip quotes from a string
+ *
+ * @param {String} str
+ * @return {String | false}
+ */
+
+ function stripQuotes(str) {
+ var a = str.charCodeAt(0);
+ var b = str.charCodeAt(str.length - 1);
+ return a === b && (a === 0x22 || a === 0x27) ? str.slice(1, -1) : str;
+ }
+
+ /**
+ * Camelize a hyphen-delmited string.
+ *
+ * @param {String} str
+ * @return {String}
+ */
+
+ var camelizeRE = /-(\w)/g;
+
+ function camelize(str) {
+ return str.replace(camelizeRE, toUpper);
+ }
+
+ function toUpper(_, c) {
+ return c ? c.toUpperCase() : '';
+ }
+
+ /**
+ * Hyphenate a camelCase string.
+ *
+ * @param {String} str
+ * @return {String}
+ */
+
+ var hyphenateRE = /([a-z\d])([A-Z])/g;
+
+ function hyphenate(str) {
+ return str.replace(hyphenateRE, '$1-$2').toLowerCase();
+ }
+
+ /**
+ * Converts hyphen/underscore/slash delimitered names into
+ * camelized classNames.
+ *
+ * e.g. my-component => MyComponent
+ * some_else => SomeElse
+ * some/comp => SomeComp
+ *
+ * @param {String} str
+ * @return {String}
+ */
+
+ var classifyRE = /(?:^|[-_\/])(\w)/g;
+
+ function classify(str) {
+ return str.replace(classifyRE, toUpper);
+ }
+
+ /**
+ * Simple bind, faster than native
+ *
+ * @param {Function} fn
+ * @param {Object} ctx
+ * @return {Function}
+ */
+
+ function bind(fn, ctx) {
+ return function (a) {
+ var l = arguments.length;
+ return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx);
+ };
+ }
+
+ /**
+ * Convert an Array-like object to a real Array.
+ *
+ * @param {Array-like} list
+ * @param {Number} [start] - start index
+ * @return {Array}
+ */
+
+ function toArray(list, start) {
+ start = start || 0;
+ var i = list.length - start;
+ var ret = new Array(i);
+ while (i--) {
+ ret[i] = list[i + start];
+ }
+ return ret;
+ }
+
+ /**
+ * Mix properties into target object.
+ *
+ * @param {Object} to
+ * @param {Object} from
+ */
+
+ function extend(to, from) {
+ var keys = Object.keys(from);
+ var i = keys.length;
+ while (i--) {
+ to[keys[i]] = from[keys[i]];
+ }
+ return to;
+ }
+
+ /**
+ * Quick object check - this is primarily used to tell
+ * Objects from primitive values when we know the value
+ * is a JSON-compliant type.
+ *
+ * @param {*} obj
+ * @return {Boolean}
+ */
+
+ function isObject(obj) {
+ return obj !== null && typeof obj === 'object';
+ }
+
+ /**
+ * Strict object type check. Only returns true
+ * for plain JavaScript objects.
+ *
+ * @param {*} obj
+ * @return {Boolean}
+ */
+
+ var toString = Object.prototype.toString;
+ var OBJECT_STRING = '[object Object]';
+
+ function isPlainObject(obj) {
+ return toString.call(obj) === OBJECT_STRING;
+ }
+
+ /**
+ * Array type check.
+ *
+ * @param {*} obj
+ * @return {Boolean}
+ */
+
+ var isArray = Array.isArray;
+
+ /**
+ * Define a property.
+ *
+ * @param {Object} obj
+ * @param {String} key
+ * @param {*} val
+ * @param {Boolean} [enumerable]
+ */
+
+ function def(obj, key, val, enumerable) {
+ Object.defineProperty(obj, key, {
+ value: val,
+ enumerable: !!enumerable,
+ writable: true,
+ configurable: true
+ });
+ }
+
+ /**
+ * Debounce a function so it only gets called after the
+ * input stops arriving after the given wait period.
+ *
+ * @param {Function} func
+ * @param {Number} wait
+ * @return {Function} - the debounced function
+ */
+
+ function _debounce(func, wait) {
+ var timeout, args, context, timestamp, result;
+ var later = function later() {
+ var last = Date.now() - timestamp;
+ if (last < wait && last >= 0) {
+ timeout = setTimeout(later, wait - last);
+ } else {
+ timeout = null;
+ result = func.apply(context, args);
+ if (!timeout) context = args = null;
+ }
+ };
+ return function () {
+ context = this;
+ args = arguments;
+ timestamp = Date.now();
+ if (!timeout) {
+ timeout = setTimeout(later, wait);
+ }
+ return result;
+ };
+ }
+
+ /**
+ * Manual indexOf because it's slightly faster than
+ * native.
+ *
+ * @param {Array} arr
+ * @param {*} obj
+ */
+
+ function indexOf(arr, obj) {
+ var i = arr.length;
+ while (i--) {
+ if (arr[i] === obj) return i;
+ }
+ return -1;
+ }
+
+ /**
+ * Make a cancellable version of an async callback.
+ *
+ * @param {Function} fn
+ * @return {Function}
+ */
+
+ function cancellable(fn) {
+ var cb = function cb() {
+ if (!cb.cancelled) {
+ return fn.apply(this, arguments);
+ }
+ };
+ cb.cancel = function () {
+ cb.cancelled = true;
+ };
+ return cb;
+ }
+
+ /**
+ * Check if two values are loosely equal - that is,
+ * if they are plain objects, do they have the same shape?
+ *
+ * @param {*} a
+ * @param {*} b
+ * @return {Boolean}
+ */
+
+ function looseEqual(a, b) {
+ /* eslint-disable eqeqeq */
+ return a == b || (isObject(a) && isObject(b) ? JSON.stringify(a) === JSON.stringify(b) : false);
+ /* eslint-enable eqeqeq */
+ }
+
+ var hasProto = ('__proto__' in {});
+
+ // Browser environment sniffing
+ var inBrowser = typeof window !== 'undefined' && Object.prototype.toString.call(window) !== '[object Object]';
+
+ // detect devtools
+ var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__;
+
+ // UA sniffing for working around browser-specific quirks
+ var UA = inBrowser && window.navigator.userAgent.toLowerCase();
+ var isIE = UA && UA.indexOf('trident') > 0;
+ var isIE9 = UA && UA.indexOf('msie 9.0') > 0;
+ var isAndroid = UA && UA.indexOf('android') > 0;
+ var isIos = UA && /(iphone|ipad|ipod|ios)/i.test(UA);
+ var iosVersionMatch = isIos && UA.match(/os ([\d_]+)/);
+ var iosVersion = iosVersionMatch && iosVersionMatch[1].split('_');
+
+ // detecting iOS UIWebView by indexedDB
+ var hasMutationObserverBug = iosVersion && Number(iosVersion[0]) >= 9 && Number(iosVersion[1]) >= 3 && !window.indexedDB;
+
+ var transitionProp = undefined;
+ var transitionEndEvent = undefined;
+ var animationProp = undefined;
+ var animationEndEvent = undefined;
+
+ // Transition property/event sniffing
+ if (inBrowser && !isIE9) {
+ var isWebkitTrans = window.ontransitionend === undefined && window.onwebkittransitionend !== undefined;
+ var isWebkitAnim = window.onanimationend === undefined && window.onwebkitanimationend !== undefined;
+ transitionProp = isWebkitTrans ? 'WebkitTransition' : 'transition';
+ transitionEndEvent = isWebkitTrans ? 'webkitTransitionEnd' : 'transitionend';
+ animationProp = isWebkitAnim ? 'WebkitAnimation' : 'animation';
+ animationEndEvent = isWebkitAnim ? 'webkitAnimationEnd' : 'animationend';
+ }
+
+ /**
+ * Defer a task to execute it asynchronously. Ideally this
+ * should be executed as a microtask, so we leverage
+ * MutationObserver if it's available, and fallback to
+ * setTimeout(0).
+ *
+ * @param {Function} cb
+ * @param {Object} ctx
+ */
+
+ var nextTick = (function () {
+ var callbacks = [];
+ var pending = false;
+ var timerFunc;
+ function nextTickHandler() {
+ pending = false;
+ var copies = callbacks.slice(0);
+ callbacks = [];
+ for (var i = 0; i < copies.length; i++) {
+ copies[i]();
+ }
+ }
+
+ /* istanbul ignore if */
+ if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
+ var counter = 1;
+ var observer = new MutationObserver(nextTickHandler);
+ var textNode = document.createTextNode(counter);
+ observer.observe(textNode, {
+ characterData: true
+ });
+ timerFunc = function () {
+ counter = (counter + 1) % 2;
+ textNode.data = counter;
+ };
+ } else {
+ // webpack attempts to inject a shim for setImmediate
+ // if it is used as a global, so we have to work around that to
+ // avoid bundling unnecessary code.
+ var context = inBrowser ? window : typeof global !== 'undefined' ? global : {};
+ timerFunc = context.setImmediate || setTimeout;
+ }
+ return function (cb, ctx) {
+ var func = ctx ? function () {
+ cb.call(ctx);
+ } : cb;
+ callbacks.push(func);
+ if (pending) return;
+ pending = true;
+ timerFunc(nextTickHandler, 0);
+ };
+ })();
+
+ var _Set = undefined;
+ /* istanbul ignore if */
+ if (typeof Set !== 'undefined' && Set.toString().match(/native code/)) {
+ // use native Set when available.
+ _Set = Set;
+ } else {
+ // a non-standard Set polyfill that only works with primitive keys.
+ _Set = function () {
+ this.set = Object.create(null);
+ };
+ _Set.prototype.has = function (key) {
+ return this.set[key] !== undefined;
+ };
+ _Set.prototype.add = function (key) {
+ this.set[key] = 1;
+ };
+ _Set.prototype.clear = function () {
+ this.set = Object.create(null);
+ };
+ }
+
+ function Cache(limit) {
+ this.size = 0;
+ this.limit = limit;
+ this.head = this.tail = undefined;
+ this._keymap = Object.create(null);
+ }
+
+ var p = Cache.prototype;
+
+ /**
+ * Put into the cache associated with .
+ * Returns the entry which was removed to make room for
+ * the new entry. Otherwise undefined is returned.
+ * (i.e. if there was enough room already).
+ *
+ * @param {String} key
+ * @param {*} value
+ * @return {Entry|undefined}
+ */
+
+ p.put = function (key, value) {
+ var removed;
+
+ var entry = this.get(key, true);
+ if (!entry) {
+ if (this.size === this.limit) {
+ removed = this.shift();
+ }
+ entry = {
+ key: key
+ };
+ this._keymap[key] = entry;
+ if (this.tail) {
+ this.tail.newer = entry;
+ entry.older = this.tail;
+ } else {
+ this.head = entry;
+ }
+ this.tail = entry;
+ this.size++;
+ }
+ entry.value = value;
+
+ return removed;
+ };
+
+ /**
+ * Purge the least recently used (oldest) entry from the
+ * cache. Returns the removed entry or undefined if the
+ * cache was empty.
+ */
+
+ p.shift = function () {
+ var entry = this.head;
+ if (entry) {
+ this.head = this.head.newer;
+ this.head.older = undefined;
+ entry.newer = entry.older = undefined;
+ this._keymap[entry.key] = undefined;
+ this.size--;
+ }
+ return entry;
+ };
+
+ /**
+ * Get and register recent use of . Returns the value
+ * associated with or undefined if not in cache.
+ *
+ * @param {String} key
+ * @param {Boolean} returnEntry
+ * @return {Entry|*}
+ */
+
+ p.get = function (key, returnEntry) {
+ var entry = this._keymap[key];
+ if (entry === undefined) return;
+ if (entry === this.tail) {
+ return returnEntry ? entry : entry.value;
+ }
+ // HEAD--------------TAIL
+ // <.older .newer>
+ // <--- add direction --
+ // A B C E
+ if (entry.newer) {
+ if (entry === this.head) {
+ this.head = entry.newer;
+ }
+ entry.newer.older = entry.older; // C <-- E.
+ }
+ if (entry.older) {
+ entry.older.newer = entry.newer; // C. --> E
+ }
+ entry.newer = undefined; // D --x
+ entry.older = this.tail; // D. --> E
+ if (this.tail) {
+ this.tail.newer = entry; // E. <-- D
+ }
+ this.tail = entry;
+ return returnEntry ? entry : entry.value;
+ };
+
+ var cache$1 = new Cache(1000);
+ var filterTokenRE = /[^\s'"]+|'[^']*'|"[^"]*"/g;
+ var reservedArgRE = /^in$|^-?\d+/;
+
+ /**
+ * Parser state
+ */
+
+ var str;
+ var dir;
+ var c;
+ var prev;
+ var i;
+ var l;
+ var lastFilterIndex;
+ var inSingle;
+ var inDouble;
+ var curly;
+ var square;
+ var paren;
+ /**
+ * Push a filter to the current directive object
+ */
+
+ function pushFilter() {
+ var exp = str.slice(lastFilterIndex, i).trim();
+ var filter;
+ if (exp) {
+ filter = {};
+ var tokens = exp.match(filterTokenRE);
+ filter.name = tokens[0];
+ if (tokens.length > 1) {
+ filter.args = tokens.slice(1).map(processFilterArg);
+ }
+ }
+ if (filter) {
+ (dir.filters = dir.filters || []).push(filter);
+ }
+ lastFilterIndex = i + 1;
+ }
+
+ /**
+ * Check if an argument is dynamic and strip quotes.
+ *
+ * @param {String} arg
+ * @return {Object}
+ */
+
+ function processFilterArg(arg) {
+ if (reservedArgRE.test(arg)) {
+ return {
+ value: toNumber(arg),
+ dynamic: false
+ };
+ } else {
+ var stripped = stripQuotes(arg);
+ var dynamic = stripped === arg;
+ return {
+ value: dynamic ? arg : stripped,
+ dynamic: dynamic
+ };
+ }
+ }
+
+ /**
+ * Parse a directive value and extract the expression
+ * and its filters into a descriptor.
+ *
+ * Example:
+ *
+ * "a + 1 | uppercase" will yield:
+ * {
+ * expression: 'a + 1',
+ * filters: [
+ * { name: 'uppercase', args: null }
+ * ]
+ * }
+ *
+ * @param {String} s
+ * @return {Object}
+ */
+
+ function parseDirective(s) {
+ var hit = cache$1.get(s);
+ if (hit) {
+ return hit;
+ }
+
+ // reset parser state
+ str = s;
+ inSingle = inDouble = false;
+ curly = square = paren = 0;
+ lastFilterIndex = 0;
+ dir = {};
+
+ for (i = 0, l = str.length; i < l; i++) {
+ prev = c;
+ c = str.charCodeAt(i);
+ if (inSingle) {
+ // check single quote
+ if (c === 0x27 && prev !== 0x5C) inSingle = !inSingle;
+ } else if (inDouble) {
+ // check double quote
+ if (c === 0x22 && prev !== 0x5C) inDouble = !inDouble;
+ } else if (c === 0x7C && // pipe
+ str.charCodeAt(i + 1) !== 0x7C && str.charCodeAt(i - 1) !== 0x7C) {
+ if (dir.expression == null) {
+ // first filter, end of expression
+ lastFilterIndex = i + 1;
+ dir.expression = str.slice(0, i).trim();
+ } else {
+ // already has filter
+ pushFilter();
+ }
+ } else {
+ switch (c) {
+ case 0x22:
+ inDouble = true;break; // "
+ case 0x27:
+ inSingle = true;break; // '
+ case 0x28:
+ paren++;break; // (
+ case 0x29:
+ paren--;break; // )
+ case 0x5B:
+ square++;break; // [
+ case 0x5D:
+ square--;break; // ]
+ case 0x7B:
+ curly++;break; // {
+ case 0x7D:
+ curly--;break; // }
+ }
+ }
+ }
+
+ if (dir.expression == null) {
+ dir.expression = str.slice(0, i).trim();
+ } else if (lastFilterIndex !== 0) {
+ pushFilter();
+ }
+
+ cache$1.put(s, dir);
+ return dir;
+ }
+
+var directive = Object.freeze({
+ parseDirective: parseDirective
+ });
+
+ var regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g;
+ var cache = undefined;
+ var tagRE = undefined;
+ var htmlRE = undefined;
+ /**
+ * Escape a string so it can be used in a RegExp
+ * constructor.
+ *
+ * @param {String} str
+ */
+
+ function escapeRegex(str) {
+ return str.replace(regexEscapeRE, '\\$&');
+ }
+
+ function compileRegex() {
+ var open = escapeRegex(config.delimiters[0]);
+ var close = escapeRegex(config.delimiters[1]);
+ var unsafeOpen = escapeRegex(config.unsafeDelimiters[0]);
+ var unsafeClose = escapeRegex(config.unsafeDelimiters[1]);
+ tagRE = new RegExp(unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '|' + open + '((?:.|\\n)+?)' + close, 'g');
+ htmlRE = new RegExp('^' + unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '$');
+ // reset cache
+ cache = new Cache(1000);
+ }
+
+ /**
+ * Parse a template text string into an array of tokens.
+ *
+ * @param {String} text
+ * @return {Array