summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock8
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js.es621
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_util.js11
-rw-r--r--app/assets/javascripts/merge_request_widget.js.es674
-rw-r--r--app/assets/javascripts/pipelines.js.es611
-rw-r--r--app/assets/javascripts/smart_interval.js.es669
-rw-r--r--app/assets/stylesheets/pages/notifications.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss4
-rw-r--r--app/assets/stylesheets/pages/snippets.scss20
-rw-r--r--app/controllers/projects/snippets_controller.rb8
-rw-r--r--app/controllers/sessions_controller.rb6
-rw-r--r--app/finders/snippets_finder.rb47
-rw-r--r--app/helpers/gitlab_routing_helper.rb5
-rw-r--r--app/helpers/snippets_helper.rb11
-rw-r--r--app/models/project_services/buildkite_service.rb3
-rw-r--r--app/models/project_services/drone_ci_service.rb3
-rw-r--r--app/models/project_services/emails_on_push_service.rb18
-rw-r--r--app/models/project_services/hipchat_service.rb6
-rw-r--r--app/models/project_services/irker_service.rb3
-rw-r--r--app/policies/personal_snippet_policy.rb5
-rw-r--r--app/services/commits/change_service.rb2
-rw-r--r--app/views/dashboard/_snippets_head.html.haml20
-rw-r--r--app/views/dashboard/snippets/index.html.haml42
-rw-r--r--app/views/explore/snippets/index.html.haml10
-rw-r--r--app/views/groups/issues.html.haml6
-rw-r--r--app/views/groups/merge_requests.html.haml5
-rw-r--r--app/views/profiles/notifications/show.html.haml2
-rw-r--r--app/views/projects/ci/builds/_build_pipeline.html.haml4
-rw-r--r--app/views/projects/commit/_change.html.haml9
-rw-r--r--app/views/projects/commit/_pipeline.html.haml2
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml2
-rw-r--r--app/views/projects/notes/_note.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/snippets/_actions.html.haml14
-rw-r--r--app/views/projects/snippets/index.html.haml20
-rw-r--r--app/views/search/results/_snippet_title.html.haml2
-rw-r--r--app/views/shared/notifications/_button.html.haml3
-rw-r--r--app/views/shared/notifications/_notification_dropdown.html.haml3
-rw-r--r--app/views/shared/snippets/_header.html.haml12
-rw-r--r--app/views/shared/snippets/_snippet.html.haml31
-rw-r--r--app/views/snippets/_actions.html.haml14
-rw-r--r--app/views/snippets/_snippets.html.haml3
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml31
-rw-r--r--changelogs/unreleased/19550-fix-contributer-graph-duplicates.yml4
-rw-r--r--changelogs/unreleased/24807-stop-ddosing-ourselves.yml4
-rw-r--r--changelogs/unreleased/25106-hide-issue-mr-button-for-not-loggedin.yml4
-rw-r--r--changelogs/unreleased/25294-remove-signed-out-msg.yml4
-rw-r--r--changelogs/unreleased/25483-broken-tabs.yml4
-rw-r--r--changelogs/unreleased/features-api-snippets.yml4
-rw-r--r--changelogs/unreleased/issue_25030.yml4
-rw-r--r--changelogs/unreleased/unescape-relative-path.yml4
-rw-r--r--doc/README.md2
-rw-r--r--doc/api/merge_requests.md2
-rw-r--r--doc/api/services.md112
-rw-r--r--doc/api/snippets.md232
-rw-r--r--doc/ci/review_apps/index.md2
-rw-r--r--features/steps/project/snippets.rb2
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/branches.rb16
-rw-r--r--lib/api/entities.rb13
-rw-r--r--lib/api/groups.rb11
-rw-r--r--lib/api/helpers.rb11
-rw-r--r--lib/api/services.rb626
-rw-r--r--lib/api/snippets.rb137
-rw-r--r--lib/banzai/filter/relative_link_filter.rb14
-rw-r--r--lib/gitlab/url_builder.rb2
-rw-r--r--spec/features/dashboard/datetime_on_tooltips_spec.rb2
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb67
-rw-r--r--spec/finders/snippets_finder_spec.rb84
-rw-r--r--spec/javascripts/fixtures/pipeline_graph.html.haml15
-rw-r--r--spec/javascripts/pipelines_spec.js.es625
-rw-r--r--spec/javascripts/smart_interval_spec.js.es635
-rw-r--r--spec/lib/banzai/filter/relative_link_filter_spec.rb2
-rw-r--r--spec/requests/api/branches_spec.rb31
-rw-r--r--spec/requests/api/groups_spec.rb11
-rw-r--r--spec/requests/api/services_spec.rb5
-rw-r--r--spec/requests/api/snippets_spec.rb157
-rw-r--r--spec/support/login_helpers.rb3
-rw-r--r--spec/support/services_shared_context.rb6
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb2
81 files changed, 1854 insertions, 386 deletions
diff --git a/Gemfile b/Gemfile
index f27d6363e3d..2cc7764e6b8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -132,7 +132,7 @@ gem 'after_commit_queue', '~> 1.3.0'
gem 'acts-as-taggable-on', '~> 4.0'
# Background jobs
-gem 'sidekiq', '~> 4.2'
+gem 'sidekiq', '~> 4.2.7'
gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4'
diff --git a/Gemfile.lock b/Gemfile.lock
index c464ff70587..3de1a7cbf26 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,7 +126,7 @@ GEM
coffee-script-source (1.10.0)
colorize (0.7.7)
concurrent-ruby (1.0.2)
- connection_pool (2.2.0)
+ connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
creole (0.5.0)
@@ -648,10 +648,10 @@ GEM
rack
shoulda-matchers (2.8.0)
activesupport (>= 3.0.0)
- sidekiq (4.2.1)
+ sidekiq (4.2.7)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
- rack-protection (~> 1.5)
+ rack-protection (>= 1.5.0)
redis (~> 3.2, >= 3.2.1)
sidekiq-cron (0.4.4)
redis-namespace (>= 1.5.2)
@@ -928,7 +928,7 @@ DEPENDENCIES
settingslogic (~> 2.0.9)
sham_rack (~> 1.3.6)
shoulda-matchers (~> 2.8.0)
- sidekiq (~> 4.2)
+ sidekiq (~> 4.2.7)
sidekiq-cron (~> 0.4.4)
sidekiq-limit_fetch (~> 3.4)
simplecov (= 0.12.0)
diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6
index 6f9d6283071..2f3da745119 100644
--- a/app/assets/javascripts/gfm_auto_complete.js.es6
+++ b/app/assets/javascripts/gfm_auto_complete.js.es6
@@ -52,6 +52,10 @@
return $.fn.atwho["default"].callbacks.filter(query, data, searchKey);
},
beforeInsert: function(value) {
+ if (value && !this.setting.skipSpecialCharacterTest) {
+ var withoutAt = value.substring(1);
+ if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"';
+ }
if (!GitLab.GfmAutoComplete.dataLoaded) {
return this.at;
} else {
@@ -117,6 +121,7 @@
insertTpl: ':${name}:',
data: ['loading'],
startWithSpace: false,
+ skipSpecialCharacterTest: true,
callbacks: {
sorter: this.DefaultOptions.sorter,
filter: this.DefaultOptions.filter,
@@ -141,6 +146,7 @@
data: ['loading'],
startWithSpace: false,
alwaysHighlightFirst: true,
+ skipSpecialCharacterTest: true,
callbacks: {
sorter: this.DefaultOptions.sorter,
filter: this.DefaultOptions.filter,
@@ -219,12 +225,13 @@
}
};
})(this),
- insertTpl: '${atwho-at}"${title}"',
+ insertTpl: '${atwho-at}${title}',
data: ['loading'],
startWithSpace: false,
callbacks: {
matcher: this.DefaultOptions.matcher,
sorter: this.DefaultOptions.sorter,
+ beforeInsert: this.DefaultOptions.beforeInsert,
beforeSave: function(milestones) {
return $.map(milestones, function(m) {
if (m.title == null) {
@@ -284,18 +291,11 @@
callbacks: {
matcher: this.DefaultOptions.matcher,
sorter: this.DefaultOptions.sorter,
+ beforeInsert: this.DefaultOptions.beforeInsert,
beforeSave: function(merges) {
- var sanitizeLabelTitle;
- sanitizeLabelTitle = function(title) {
- if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
- return "\"" + (sanitize(title)) + "\"";
- } else {
- return sanitize(title);
- }
- };
return $.map(merges, function(m) {
return {
- title: sanitizeLabelTitle(m.title),
+ title: sanitize(m.title),
color: m.color,
search: "" + m.title
};
@@ -308,6 +308,7 @@
at: '/',
alias: 'commands',
searchKey: 'search',
+ skipSpecialCharacterTest: true,
displayTpl: function(value) {
var tpl = '<li>/${name}';
if (value.aliases.length > 0) {
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/graphs/stat_graph_contributors_util.js
index 051ff98c774..1982f4af939 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors_util.js
@@ -2,7 +2,7 @@
(function() {
window.ContributorsStatGraphUtil = {
parse_log: function(log) {
- var by_author, by_email, data, entry, i, len, total;
+ var by_author, by_email, data, entry, i, len, total, normalized_email;
total = {};
by_author = {};
by_email = {};
@@ -11,7 +11,8 @@
if (total[entry.date] == null) {
this.add_date(entry.date, total);
}
- data = by_author[entry.author_name] || by_email[entry.author_email];
+ normalized_email = entry.author_email.toLowerCase();
+ data = by_author[entry.author_name] || by_email[normalized_email];
if (data == null) {
data = this.add_author(entry, by_author, by_email);
}
@@ -32,12 +33,14 @@
return collection[date].date = date;
},
add_author: function(author, by_author, by_email) {
- var data;
+ var data, normalized_email;
data = {};
data.author_name = author.author_name;
data.author_email = author.author_email;
+ normalized_email = author.author_email.toLowerCase();
by_author[author.author_name] = data;
- return by_email[author.author_email] = data;
+ by_email[normalized_email] = data;
+ return data;
},
store_data: function(entry, total, by_author) {
this.store_commits(total, by_author);
diff --git a/app/assets/javascripts/merge_request_widget.js.es6 b/app/assets/javascripts/merge_request_widget.js.es6
index d9495e50388..7022aa1263b 100644
--- a/app/assets/javascripts/merge_request_widget.js.es6
+++ b/app/assets/javascripts/merge_request_widget.js.es6
@@ -40,19 +40,26 @@
$('#modal_merge_info').modal({
show: false
});
- this.firstCICheck = true;
- this.readyForCICheck = false;
- this.readyForCIEnvironmentCheck = false;
- this.cancel = false;
- clearInterval(this.fetchBuildStatusInterval);
- clearInterval(this.fetchBuildEnvironmentStatusInterval);
this.clearEventListeners();
this.addEventListeners();
this.getCIStatus(false);
- this.getCIEnvironmentsStatus();
this.retrieveSuccessIcon();
- this.pollCIStatus();
- this.pollCIEnvironmentsStatus();
+
+ this.ciStatusInterval = new global.SmartInterval({
+ callback: this.getCIStatus.bind(this, true),
+ startingInterval: 10000,
+ maxInterval: 30000,
+ hiddenInterval: 120000,
+ incrementByFactorOf: 5000,
+ });
+ this.ciEnvironmentStatusInterval = new global.SmartInterval({
+ callback: this.getCIEnvironmentsStatus.bind(this),
+ startingInterval: 30000,
+ maxInterval: 120000,
+ hiddenInterval: 240000,
+ incrementByFactorOf: 15000,
+ immediateExecution: true,
+ });
notifyPermissions();
}
@@ -60,10 +67,6 @@
return $(document).off('page:change.merge_request');
};
- MergeRequestWidget.prototype.cancelPolling = function() {
- return this.cancel = true;
- };
-
MergeRequestWidget.prototype.addEventListeners = function() {
var allowedPages;
allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes'];
@@ -72,9 +75,6 @@
var page;
page = $('body').data('page').split(':').last();
if (allowedPages.indexOf(page) < 0) {
- clearInterval(_this.fetchBuildStatusInterval);
- clearInterval(_this.fetchBuildEnvironmentStatusInterval);
- _this.cancelPolling();
return _this.clearEventListeners();
}
};
@@ -114,6 +114,11 @@
});
};
+ MergeRequestWidget.prototype.cancelPolling = function () {
+ this.ciStatusInterval.cancel();
+ this.ciEnvironmentStatusInterval.cancel();
+ };
+
MergeRequestWidget.prototype.getMergeStatus = function() {
return $.get(this.opts.merge_check_url, function(data) {
return $('.mr-state-widget').replaceWith(data);
@@ -131,18 +136,6 @@
}
};
- MergeRequestWidget.prototype.pollCIStatus = function() {
- return this.fetchBuildStatusInterval = setInterval(((function(_this) {
- return function() {
- if (!_this.readyForCICheck) {
- return;
- }
- _this.getCIStatus(true);
- return _this.readyForCICheck = false;
- };
- })(this)), 10000);
- };
-
MergeRequestWidget.prototype.getCIStatus = function(showNotification) {
var _this;
_this = this;
@@ -150,23 +143,17 @@
return $.getJSON(this.opts.ci_status_url, (function(_this) {
return function(data) {
var message, status, title;
- if (_this.cancel) {
- return;
- }
- _this.readyForCICheck = true;
if (data.status === '') {
return;
}
if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
- if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) {
+ if (data.status !== _this.opts.ci_status && (data.status != null)) {
_this.opts.ci_status = data.status;
_this.showCIStatus(data.status);
if (data.coverage) {
_this.showCICoverage(data.coverage);
}
- // The first check should only update the UI, a notification
- // should only be displayed on status changes
- if (showNotification && !_this.firstCICheck) {
+ if (showNotification) {
status = _this.ciLabelForStatus(data.status);
if (status === "preparing") {
title = _this.opts.ci_title.preparing;
@@ -184,24 +171,13 @@
return Turbolinks.visit(_this.opts.builds_path);
});
}
- return _this.firstCICheck = false;
}
};
})(this));
};
- MergeRequestWidget.prototype.pollCIEnvironmentsStatus = function() {
- this.fetchBuildEnvironmentStatusInterval = setInterval(() => {
- if (!this.readyForCIEnvironmentCheck) return;
- this.getCIEnvironmentsStatus();
- this.readyForCIEnvironmentCheck = false;
- }, 300000);
- };
-
MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() {
$.getJSON(this.opts.ci_environments_status_url, (environments) => {
- if (this.cancel) return;
- this.readyForCIEnvironmentCheck = true;
if (environments && environments.length) this.renderEnvironments(environments);
});
};
@@ -212,11 +188,11 @@
if ($(`.mr-state-widget #${ environment.id }`).length) return;
const $template = $(DEPLOYMENT_TEMPLATE);
if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove();
-
+
if (!environment.stop_url) {
$('.js-stop-env-link', $template).remove();
}
-
+
if (environment.deployed_at && environment.deployed_at_formatted) {
environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
} else {
diff --git a/app/assets/javascripts/pipelines.js.es6 b/app/assets/javascripts/pipelines.js.es6
index 72c6c4a1fcd..fb95648e1c7 100644
--- a/app/assets/javascripts/pipelines.js.es6
+++ b/app/assets/javascripts/pipelines.js.es6
@@ -4,7 +4,7 @@
((global) => {
class Pipelines {
- constructor(options) {
+ constructor(options = {}) {
if (options.initTabs && options.tabsOptions) {
new global.LinkedTabs(options.tabsOptions);
@@ -14,9 +14,11 @@
}
addMarginToBuildColumns() {
- this.pipelineGraph = document.querySelector('.pipeline-graph');
- const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)');
- for (buildNodeIndex in secondChildBuildNodes) {
+ this.pipelineGraph = document.querySelector('.js-pipeline-graph');
+
+ const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
+
+ for (const buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling;
if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue;
@@ -28,6 +30,7 @@
const columnBuilds = previousColumn.querySelectorAll('.build');
if (columnBuilds.length === 1) previousColumn.classList.add('no-margin');
}
+
this.pipelineGraph.classList.remove('hidden');
}
}
diff --git a/app/assets/javascripts/smart_interval.js.es6 b/app/assets/javascripts/smart_interval.js.es6
index 5eb15dba79b..40f67637c7c 100644
--- a/app/assets/javascripts/smart_interval.js.es6
+++ b/app/assets/javascripts/smart_interval.js.es6
@@ -7,24 +7,31 @@
(() => {
class SmartInterval {
/**
- * @param { function } callback Function to be called on each iteration (required)
- * @param { milliseconds } startingInterval `currentInterval` is set to this initially
- * @param { milliseconds } maxInterval `currentInterval` will be incremented to this
- * @param { integer } incrementByFactorOf `currentInterval` is incremented by this factor
- * @param { boolean } lazyStart Configure if timer is initialized on instantiation or lazily
+ * @param { function } opts.callback Function to be called on each iteration (required)
+ * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
+ * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
+ * when the page is hidden
+ * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } opts.lazyStart Configure if timer is initialized on
+ * instantiation or lazily
+ * @param { boolean } opts.immediateExecution Configure if callback should
+ * be executed before the first interval.
*/
- constructor({ callback, startingInterval, maxInterval, incrementByFactorOf, lazyStart }) {
+ constructor(opts = {}) {
this.cfg = {
- callback,
- startingInterval,
- maxInterval,
- incrementByFactorOf,
- lazyStart,
+ callback: opts.callback,
+ startingInterval: opts.startingInterval,
+ maxInterval: opts.maxInterval,
+ hiddenInterval: opts.hiddenInterval,
+ incrementByFactorOf: opts.incrementByFactorOf,
+ lazyStart: opts.lazyStart,
+ immediateExecution: opts.immediateExecution,
};
this.state = {
intervalId: null,
- currentInterval: startingInterval,
+ currentInterval: this.cfg.startingInterval,
pageVisibility: 'visible',
};
@@ -36,6 +43,11 @@
const cfg = this.cfg;
const state = this.state;
+ if (cfg.immediateExecution) {
+ cfg.immediateExecution = false;
+ cfg.callback();
+ }
+
state.intervalId = window.setInterval(() => {
cfg.callback();
@@ -54,14 +66,29 @@
this.stopTimer();
}
+ onVisibilityHidden() {
+ if (this.cfg.hiddenInterval) {
+ this.setCurrentInterval(this.cfg.hiddenInterval);
+ this.resume();
+ } else {
+ this.cancel();
+ }
+ }
+
// start a timer, using the existing interval
resume() {
this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
this.start();
}
+ onVisibilityVisible() {
+ this.cancel();
+ this.start();
+ }
+
destroy() {
this.cancel();
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
$(document).off('visibilitychange').off('page:before-unload');
}
@@ -80,11 +107,7 @@
initVisibilityChangeHandling() {
// cancel interval when tab no longer shown (prevents cached pages from polling)
- $(document)
- .off('visibilitychange').on('visibilitychange', (e) => {
- this.state.pageVisibility = e.target.visibilityState;
- this.handleVisibilityChange();
- });
+ document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
initPageUnloadHandling() {
@@ -92,10 +115,11 @@
$(document).on('page:before-unload', () => this.cancel());
}
- handleVisibilityChange() {
- const state = this.state;
-
- const intervalAction = state.pageVisibility === 'hidden' ? this.cancel : this.resume;
+ handleVisibilityChange(e) {
+ this.state.pageVisibility = e.target.visibilityState;
+ const intervalAction = this.isPageVisible() ?
+ this.onVisibilityVisible :
+ this.onVisibilityHidden;
intervalAction.apply(this);
}
@@ -111,6 +135,7 @@
incrementInterval() {
const cfg = this.cfg;
const currentInterval = this.getCurrentInterval();
+ if (cfg.hiddenInterval && !this.isPageVisible()) return;
let nextInterval = currentInterval * cfg.incrementByFactorOf;
if (nextInterval > cfg.maxInterval) {
@@ -120,6 +145,8 @@
this.setCurrentInterval(nextInterval);
}
+ isPageVisible() { return this.state.pageVisibility === 'visible'; }
+
stopTimer() {
const state = this.state;
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index 94fbbef3c77..7d61390a439 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -1,5 +1,9 @@
.notification-list-item {
line-height: 34px;
+
+ .dropdown-menu {
+ @extend .dropdown-menu-align-right;
+ }
}
.notification {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 72b6685d940..6e0f6b1cd81 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -188,6 +188,10 @@
margin-left: 10px;
}
+ .notification-dropdown .dropdown-menu {
+ @extend .dropdown-menu-align-right;
+ }
+
.download-button {
@media (max-width: $screen-md-max) {
margin-left: 0;
diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss
index 857eb76131a..ff13b86acf0 100644
--- a/app/assets/stylesheets/pages/snippets.scss
+++ b/app/assets/stylesheets/pages/snippets.scss
@@ -1,3 +1,13 @@
+.snippet-row {
+ .title {
+ margin-bottom: 2px;
+ }
+
+ .snippet-filename {
+ padding: 0 2px;
+ }
+}
+
.snippet-form-holder .file-holder .file-title {
padding: 2px;
}
@@ -24,11 +34,17 @@
padding-bottom: $gl-padding;
}
+.snippet-header {
+ padding: $gl-padding 0;
+}
+
.snippet-title {
font-size: 24px;
font-weight: 600;
- padding: $gl-padding;
- padding-left: 0;
+}
+
+.snippet-edited-ago {
+ color: $gray-darkest;
}
.snippet-actions {
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index e290a0eadda..0720be2e55d 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -19,10 +19,12 @@ class Projects::SnippetsController < Projects::ApplicationController
respond_to :html
def index
- @snippets = SnippetsFinder.new.execute(current_user, {
+ @snippets = SnippetsFinder.new.execute(
+ current_user,
filter: :by_project,
- project: @project
- })
+ project: @project,
+ scope: params[:scope]
+ )
@snippets = @snippets.page(params[:page])
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 38e7c6f4a48..8c698695202 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -37,6 +37,12 @@ class SessionsController < Devise::SessionsController
end
end
+ def destroy
+ super
+ # hide the signed_out notice
+ flash[:notice] = nil
+ end
+
private
# Handle an "initial setup" state, where there's only one user, it's an admin,
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index 00ff1611039..da6e6e87a6f 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -1,14 +1,17 @@
class SnippetsFinder
def execute(current_user, params = {})
filter = params[:filter]
+ user = params.fetch(:user, current_user)
case filter
when :all then
snippets(current_user).fresh
+ when :public then
+ Snippet.are_public.fresh
when :by_user then
- by_user(current_user, params[:user], params[:scope])
+ by_user(current_user, user, params[:scope])
when :by_project
- by_project(current_user, params[:project])
+ by_project(current_user, params[:project], params[:scope])
end
end
@@ -29,35 +32,35 @@ class SnippetsFinder
def by_user(current_user, user, scope)
snippets = user.snippets.fresh
- return snippets.are_public unless current_user
-
- if user == current_user
- case scope
- when 'are_internal' then
- snippets.are_internal
- when 'are_private' then
- snippets.are_private
- when 'are_public' then
- snippets.are_public
- else
- snippets
- end
+ if current_user
+ include_private = user == current_user
+ by_scope(snippets, scope, include_private)
else
- snippets.public_and_internal
+ snippets.are_public
end
end
- def by_project(current_user, project)
+ def by_project(current_user, project, scope)
snippets = project.snippets.fresh
if current_user
- if project.team.member?(current_user) || current_user.admin?
- snippets
- else
- snippets.public_and_internal
- end
+ include_private = project.team.member?(current_user) || current_user.admin?
+ by_scope(snippets, scope, include_private)
else
snippets.are_public
end
end
+
+ def by_scope(snippets, scope = nil, include_private = false)
+ case scope.to_s
+ when 'are_private'
+ include_private ? snippets.are_private : Snippet.none
+ when 'are_internal'
+ snippets.are_internal
+ when 'are_public'
+ snippets.are_public
+ else
+ include_private ? snippets : snippets.public_and_internal
+ end
+ end
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index af9087d8326..99db73c9ee0 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -159,6 +159,11 @@ module GitlabRoutingHelper
resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
+ # Snippets
+ def personal_snippet_url(snippet, *args)
+ snippet_url(snippet)
+ end
+
# Groups
## Members
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 7e33a562077..8c02b4061ca 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -8,6 +8,17 @@ module SnippetsHelper
end
end
+ # Return the path of a snippets index for a user or for a project
+ #
+ # @returns String, path to snippet index
+ def subject_snippets_path(subject = nil, opts = nil)
+ if subject.is_a?(Project)
+ namespace_project_snippets_path(subject.namespace, subject, opts)
+ else # assume subject === User
+ dashboard_snippets_path(opts)
+ end
+ end
+
# Get an array of line numbers surrounding a matching
# line, bounded by min/max.
#
diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb
index 86a06321e21..fe6d7aabb22 100644
--- a/app/models/project_services/buildkite_service.rb
+++ b/app/models/project_services/buildkite_service.rb
@@ -3,7 +3,8 @@ require "addressable/uri"
class BuildkiteService < CiService
ENDPOINT = "https://buildkite.com"
- prop_accessor :project_url, :token, :enable_ssl_verification
+ prop_accessor :project_url, :token
+ boolean_accessor :enable_ssl_verification
validates :project_url, presence: true, url: true, if: :activated?
validates :token, presence: true, if: :activated?
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 5e4dd101c53..adc78a427ee 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -1,5 +1,6 @@
class DroneCiService < CiService
- prop_accessor :drone_url, :token, :enable_ssl_verification
+ prop_accessor :drone_url, :token
+ boolean_accessor :enable_ssl_verification
validates :drone_url, presence: true, url: true, if: :activated?
validates :token, presence: true, if: :activated?
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index e0083c43adb..79285cbd26d 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -1,6 +1,6 @@
class EmailsOnPushService < Service
- prop_accessor :send_from_committer_email
- prop_accessor :disable_diffs
+ boolean_accessor :send_from_committer_email
+ boolean_accessor :disable_diffs
prop_accessor :recipients
validates :recipients, presence: true, if: :activated?
@@ -24,20 +24,20 @@ class EmailsOnPushService < Service
return unless supported_events.include?(push_data[:object_kind])
EmailsOnPushWorker.perform_async(
- project_id,
- recipients,
- push_data,
- send_from_committer_email: send_from_committer_email?,
- disable_diffs: disable_diffs?
+ project_id,
+ recipients,
+ push_data,
+ send_from_committer_email: send_from_committer_email?,
+ disable_diffs: disable_diffs?
)
end
def send_from_committer_email?
- self.send_from_committer_email == "1"
+ Gitlab::Utils.to_boolean(self.send_from_committer_email)
end
def disable_diffs?
- self.disable_diffs == "1"
+ Gitlab::Utils.to_boolean(self.disable_diffs)
end
def fields
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 660a8ae3421..915f6fed74c 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -8,8 +8,8 @@ class HipchatService < Service
ul ol li dl dt dd
]
- prop_accessor :token, :room, :server, :notify, :color, :api_version
- boolean_accessor :notify_only_broken_builds
+ prop_accessor :token, :room, :server, :color, :api_version
+ boolean_accessor :notify_only_broken_builds, :notify
validates :token, presence: true, if: :activated?
def initialize_properties
@@ -75,7 +75,7 @@ class HipchatService < Service
end
def message_options(data = nil)
- { notify: notify.present? && notify == '1', color: message_color(data) }
+ { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) }
end
def create_message(data)
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index ce7d1c5d5b1..7355918feab 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -2,7 +2,8 @@ require 'uri'
class IrkerService < Service
prop_accessor :server_host, :server_port, :default_irc_uri
- prop_accessor :colorize_messages, :recipients, :channels
+ prop_accessor :recipients, :channels
+ boolean_accessor :colorize_messages
validates :recipients, presence: true, if: :activated?
before_validation :get_channels
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index 46c5aa1a5be..d3913986cd8 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -6,9 +6,14 @@ class PersonalSnippetPolicy < BasePolicy
if @subject.author == @user
can! :read_personal_snippet
can! :update_personal_snippet
+ can! :destroy_personal_snippet
can! :admin_personal_snippet
end
+ unless @user.external?
+ can! :create_personal_snippet
+ end
+
if @subject.internal? && !@user.external?
can! :read_personal_snippet
end
diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb
index db5f2bf9b2e..4d410f66c55 100644
--- a/app/services/commits/change_service.rb
+++ b/app/services/commits/change_service.rb
@@ -35,7 +35,7 @@ module Commits
success
else
error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically.
- It may have already been #{action.to_s.dasherize}, or a more recent commit may have updated some of its content."
+ A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content."
raise ChangeError, error_msg
end
end
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index b25e8ea1f0c..02e90bbfa55 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -1,7 +1,13 @@
-%ul.nav-links
- = nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do
- = link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do
- Your Snippets
- = nav_link(page: explore_snippets_path) do
- = link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do
- Explore Snippets
+.top-area
+ %ul.nav-links
+ = nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do
+ = link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do
+ Your Snippets
+ = nav_link(page: explore_snippets_path) do
+ = link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do
+ Explore Snippets
+
+ - if current_user
+ .nav-controls.hidden-xs
+ = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do
+ New snippet
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index b2af438ea57..85cbe0bf0e6 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -2,41 +2,11 @@
- header_title "Snippets", dashboard_snippets_path
= render 'dashboard/snippets_head'
+= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true }
-.nav-block
- .controls.hidden-xs
- = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do
- = icon('plus')
- New snippet
+.visible-xs
+ &nbsp;
+ = link_to new_snippet_path, class: "btn btn-new btn-block", title: "New snippet" do
+ New snippet
- .nav-links.snippet-scope-menu
- %li{ class: ("active" unless params[:scope]) }
- = link_to dashboard_snippets_path do
- All
- %span.badge
- = current_user.snippets.count
-
- %li{ class: ("active" if params[:scope] == "are_private") }
- = link_to dashboard_snippets_path(scope: 'are_private') do
- Private
- %span.badge
- = current_user.snippets.are_private.count
-
- %li{ class: ("active" if params[:scope] == "are_internal") }
- = link_to dashboard_snippets_path(scope: 'are_internal') do
- Internal
- %span.badge
- = current_user.snippets.are_internal.count
-
- %li{ class: ("active" if params[:scope] == "are_public") }
- = link_to dashboard_snippets_path(scope: 'are_public') do
- Public
- %span.badge
- = current_user.snippets.are_public.count
-
- .visible-xs
- = link_to new_snippet_path, class: "btn btn-new btn-block", title: "New snippet" do
- = icon('plus')
- New snippet
-
-= render 'snippets/snippets'
+= render partial: 'snippets/snippets', locals: { link_project: true }
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index 7def9eacdc9..e5706d04736 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -6,12 +6,4 @@
- else
= render 'explore/head'
-.row-content-block
- - if current_user
- = link_to new_snippet_path, class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do
- New snippet
-
- .oneline
- Public snippets created by you and other users are listed here
-
-= render 'snippets/snippets'
+= render partial: 'snippets/snippets', locals: { link_project: true }
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 324a116a50e..b4aa4f24d9e 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -6,13 +6,13 @@
- if group_issues(@group).exists?
.top-area
= render 'shared/issuable/nav', type: :issues
- .nav-controls
- - if current_user
+ - if current_user
+ .nav-controls
= link_to url_for(params.merge(format: :atom, private_token: current_user.private_token)), class: 'btn' do
= icon('rss')
%span.icon-label
Subscribe
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
= render 'shared/issuable/filter', type: :issues
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index e6953d94531..dbbdb583a24 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -2,8 +2,9 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- .nav-controls
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New Merge Request"
+ - if current_user
+ .nav-controls
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New Merge Request"
= render 'shared/issuable/filter', type: :merge_requests
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 844fce59704..d79a1a9f368 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -30,7 +30,7 @@
%br
.clearfix
.form-group.pull-left.global-notification-setting
- = render 'shared/notifications/button', notification_setting: @global_notification_setting, left_align: true
+ = render 'shared/notifications/button', notification_setting: @global_notification_setting
.clearfix
diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml
index 423a1282eb2..ad1a7360a8b 100644
--- a/app/views/projects/ci/builds/_build_pipeline.html.haml
+++ b/app/views/projects/ci/builds/_build_pipeline.html.haml
@@ -1,10 +1,10 @@
- is_playable = subject.playable? && can?(current_user, :update_build, @project)
- if is_playable
- = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, data: { toggle: 'tooltip', title: "#{subject.name} - play", container: '.pipeline-graph', placement: 'bottom' } do
+ = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, data: { toggle: 'tooltip', title: "#{subject.name} - play", container: '.js-pipeline-graph', placement: 'bottom' } do
= ci_icon_for_status('play')
.ci-status-text= subject.name
- elsif can?(current_user, :read_build, @project)
- = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject), data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.pipeline-graph', placement: 'bottom' } do
+ = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject), data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.js-pipeline-graph', placement: 'bottom' } do
%span{class: "ci-status-icon ci-status-icon-#{subject.status}"}
= ci_icon_for_status(subject.status)
.ci-status-text= subject.name
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index f6e3d5e76f5..782f558e8b0 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -13,7 +13,7 @@
%a.close{href: "#", "data-dismiss" => "modal"} ×
%h3.page-title== #{label} this #{commit.change_type_title(current_user)}
.modal-body
- = form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: 'form-horizontal js-#{type}-form js-requires-input' do
+ = form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
.form-group.branch
= label_tag 'target_branch', target_label, class: 'control-label'
.col-sm-10
@@ -23,12 +23,11 @@
- if can?(current_user, :push_code, @project)
.js-create-merge-request-container
.checkbox
- - nonce = SecureRandom.hex
- = label_tag "create_merge_request-#{nonce}" do
- = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}"
+ = label_tag do
+ = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: nil
Start a <strong>new merge request</strong> with these changes
- else
- = hidden_field_tag 'create_merge_request', 1
+ = hidden_field_tag 'create_merge_request', 1, id: nil
.form-actions
= submit_tag label, class: 'btn btn-create'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
index c7b5c1124b3..08d3443b3d0 100644
--- a/app/views/projects/commit/_pipeline.html.haml
+++ b/app/views/projects/commit/_pipeline.html.haml
@@ -24,7 +24,7 @@
in
= time_interval_in_words pipeline.duration
- .row-content-block.build-content.middle-block.hidden
+ .row-content-block.build-content.middle-block.js-pipeline-graph.hidden
= render "projects/pipelines/graph", pipeline: pipeline
- if pipeline.yaml_errors.present?
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml
index 7b82d913d29..1bba0443154 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml
@@ -1,4 +1,4 @@
-%a{ data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.pipeline-graph', placement: 'bottom' } }
+%a{ data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.js-pipeline-graph', placement: 'bottom' } }
- if subject.target_url
= link_to subject.target_url do
%span{class: "ci-status-icon ci-status-icon-#{subject.status}"}
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index ba8895438c5..778a32e6345 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -65,7 +65,7 @@
.note-text.md
= preserve do
= note.redacted_note_html
- = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true)
+ = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true)
- if note_editable
= render 'projects/notes/edit_form', note: note
.note-awards
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 739e5930822..88af41aa835 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -12,7 +12,7 @@
.tab-content
#js-tab-pipeline.tab-pane
- .build-content.middle-block
+ .build-content.middle-block.js-pipeline-graph
= render "projects/pipelines/graph", pipeline: pipeline
#js-tab-builds.tab-pane
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 32e1f8a21b0..068a6610350 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -1,13 +1,13 @@
.hidden-xs
- - if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-create new-snippet-link', title: "New snippet" do
- New snippet
- - if can?(current_user, :update_project_snippet, @snippet)
- = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do
- Delete
- if can?(current_user, :update_project_snippet, @snippet)
- = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped snippable-edit" do
+ = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do
Edit
+ - if can?(current_user, :update_project_snippet, @snippet)
+ = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
+ Delete
+ - if can?(current_user, :create_project_snippet, @project)
+ = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do
+ New snippet
- if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet)
.visible-xs-block.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index e77e1b026f6..84e05cd6d88 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -1,11 +1,19 @@
- page_title "Snippets"
-.sub-header-block
- - if can?(current_user, :create_project_snippet, @project)
- = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do
- New snippet
+- if current_user
+ .top-area
+ - include_private = @project.team.member?(current_user) || current_user.admin?
+ = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
+
+ .nav-controls.hidden-xs
+ - if can?(current_user, :create_project_snippet, @project)
+ = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet" do
+ New snippet
- .oneline
- Share code pastes with others out of git repository
+- if can?(current_user, :create_project_snippet, @project)
+ .visible-xs
+ &nbsp;
+ = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-block", title: "New snippet" do
+ New snippet
= render 'snippets/snippets'
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index c414acb6a11..027d42396b4 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -14,7 +14,7 @@
= link_to snippet_title.project.name_with_namespace, namespace_project_path(snippet_title.project.namespace, snippet_title.project)
.snippet-info
- = "##{snippet_title.id}"
+ = snippet_title.to_reference
%span
by
= link_to user_snippets_path(snippet_title.author) do
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 1f7df0bcd19..fbad0d05de3 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -1,4 +1,3 @@
-- left_align = local_assigns[:left_align]
- if notification_setting
.dropdown.notification-dropdown
= form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
@@ -19,7 +18,7 @@
= notification_title(notification_setting.level)
= icon("caret-down")
- = render "shared/notifications/notification_dropdown", notification_setting: notification_setting, left_align: left_align
+ = render "shared/notifications/notification_dropdown", notification_setting: notification_setting
= content_for :scripts_body do
= render "shared/notifications/custom_notifications", notification_setting: notification_setting
diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml
index d3258ee64cb..85ad74f9a39 100644
--- a/app/views/shared/notifications/_notification_dropdown.html.haml
+++ b/app/views/shared/notifications/_notification_dropdown.html.haml
@@ -1,5 +1,4 @@
-- left_align = local_assigns[:left_align]
-%ul.dropdown-menu.dropdown-menu-no-wrap.dropdown-menu-selectable.dropdown-menu-large{ role: "menu", class: [notifications_menu_identifier("dropdown", notification_setting), ("dropdown-menu-align-right" unless left_align)] }
+%ul.dropdown-menu.dropdown-menu-no-wrap.dropdown-menu-selectable.dropdown-menu-large{ role: "menu", class: [notifications_menu_identifier("dropdown", notification_setting)] }
- NotificationSetting.levels.each_key do |level|
- next if level == "custom"
- next if level == "global" && notification_setting.source.nil?
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index d7506e07ff6..d084f5e9684 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -8,10 +8,6 @@
%span.creator
authored
= time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago')
- - if @snippet.updated_at != @snippet.created_at
- %span
- = icon('edit', title: 'edited')
- = time_ago_with_tooltip(@snippet.updated_at, placement: 'bottom', html_class: 'snippet_edited_ago')
by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "hidden-xs")}
.snippet-actions
@@ -20,5 +16,9 @@
- else
= render "snippets/actions"
-%h2.snippet-title.prepend-top-0.append-bottom-0
- = markdown_field(@snippet, :title)
+.snippet-header
+ %h2.snippet-title.prepend-top-0.append-bottom-0
+ = markdown_field(@snippet, :title)
+
+ - if @snippet.updated_at != @snippet.created_at
+ = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago')
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index ea17bec8677..5d2d2317f22 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,17 +1,16 @@
+- link_project = local_assigns.fetch(:link_project, false)
+
%li.snippet-row
= image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: ''
.title
= link_to reliable_snippet_path(snippet) do
= snippet.title
- - if snippet.private?
- %span.label.label-gray.hidden-xs
- = icon('lock')
- private
- %span.monospace.pull-right.hidden-xs
- = snippet.file_name
+ - if snippet.file_name
+ %span.snippet-filename.monospace.hidden-xs
+ = snippet.file_name
- %ul.controls.visible-xs
+ %ul.controls
%li
- note_count = snippet.notes.user.count
= link_to reliable_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do
@@ -22,11 +21,17 @@
= visibility_level_label(snippet.visibility_level)
= visibility_level_icon(snippet.visibility_level, fw: false)
- %small.pull-right.cgray.hidden-xs
- - if snippet.project_id?
- = link_to snippet.project.name_with_namespace, namespace_project_path(snippet.project.namespace, snippet.project)
-
- .snippet-info.hidden-xs
+ .snippet-info
+ #{snippet.to_reference} &middot;
+ authored #{time_ago_with_tooltip(snippet.created_at, placement: 'bottom', html_class: 'snippet-created-ago')}
+ by
= link_to user_snippets_path(snippet.author) do
= snippet.author_name
- authored #{time_ago_with_tooltip(snippet.created_at)}
+ - if link_project && snippet.project_id?
+ %span.hidden-xs
+ in
+ = link_to namespace_project_path(snippet.project.namespace, snippet.project) do
+ = snippet.project.name_with_namespace
+
+ .pull-right.snippet-updated-at
+ %span updated #{time_ago_with_tooltip(snippet.updated_at, placement: 'bottom')}
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 1d0e549ed3d..95fc7198104 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -1,13 +1,13 @@
.hidden-xs
- - if current_user
- = link_to new_snippet_path, class: "btn btn-grouped btn-create new-snippet-link", title: "New snippet" do
- New snippet
- - if can?(current_user, :admin_personal_snippet, @snippet)
- = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do
- Delete
- if can?(current_user, :update_personal_snippet, @snippet)
- = link_to edit_snippet_path(@snippet), class: "btn btn-grouped snippable-edit" do
+ = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do
Edit
+ - if can?(current_user, :admin_personal_snippet, @snippet)
+ = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do
+ Delete
+ - if current_user
+ = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do
+ New snippet
- if current_user
.visible-xs-block.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 77b66ca74b6..ac3701233ad 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,8 +1,9 @@
- remote = local_assigns.fetch(:remote, false)
+- link_project = local_assigns.fetch(:link_project, false)
.snippets-list-holder
%ul.content-list
- = render partial: 'shared/snippets/snippet', collection: @snippets
+ = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
.nothing-here-block Nothing here.
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
new file mode 100644
index 00000000000..2dda5fed647
--- /dev/null
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -0,0 +1,31 @@
+- subject = local_assigns.fetch(:subject, current_user)
+- include_private = local_assigns.fetch(:include_private, false)
+
+.nav-links.snippet-scope-menu
+ %li{ class: ("active" unless params[:scope]) }
+ = link_to subject_snippets_path(subject) do
+ All
+ %span.badge
+ - if include_private
+ = subject.snippets.count
+ - else
+ = subject.snippets.public_and_internal.count
+
+ - if include_private
+ %li{ class: ("active" if params[:scope] == "are_private") }
+ = link_to subject_snippets_path(subject, scope: 'are_private') do
+ Private
+ %span.badge
+ = subject.snippets.are_private.count
+
+ %li{ class: ("active" if params[:scope] == "are_internal") }
+ = link_to subject_snippets_path(subject, scope: 'are_internal') do
+ Internal
+ %span.badge
+ = subject.snippets.are_internal.count
+
+ %li{ class: ("active" if params[:scope] == "are_public") }
+ = link_to subject_snippets_path(subject, scope: 'are_public') do
+ Public
+ %span.badge
+ = subject.snippets.are_public.count
diff --git a/changelogs/unreleased/19550-fix-contributer-graph-duplicates.yml b/changelogs/unreleased/19550-fix-contributer-graph-duplicates.yml
new file mode 100644
index 00000000000..742b10e72aa
--- /dev/null
+++ b/changelogs/unreleased/19550-fix-contributer-graph-duplicates.yml
@@ -0,0 +1,4 @@
+---
+title: group authors in contribution graph with case insensitive email handle comparison
+merge_request: 8021
+author:
diff --git a/changelogs/unreleased/24807-stop-ddosing-ourselves.yml b/changelogs/unreleased/24807-stop-ddosing-ourselves.yml
new file mode 100644
index 00000000000..49e6c5e56e5
--- /dev/null
+++ b/changelogs/unreleased/24807-stop-ddosing-ourselves.yml
@@ -0,0 +1,4 @@
+---
+title: Use SmartInterval for MR widget and improve visibilitychange functionality
+merge_request: 7762
+author:
diff --git a/changelogs/unreleased/25106-hide-issue-mr-button-for-not-loggedin.yml b/changelogs/unreleased/25106-hide-issue-mr-button-for-not-loggedin.yml
new file mode 100644
index 00000000000..62030d3fc45
--- /dev/null
+++ b/changelogs/unreleased/25106-hide-issue-mr-button-for-not-loggedin.yml
@@ -0,0 +1,4 @@
+---
+title: Prevent user creating issue or MR without signing in for a group
+merge_request: 7902
+author:
diff --git a/changelogs/unreleased/25294-remove-signed-out-msg.yml b/changelogs/unreleased/25294-remove-signed-out-msg.yml
new file mode 100644
index 00000000000..567294fe5f7
--- /dev/null
+++ b/changelogs/unreleased/25294-remove-signed-out-msg.yml
@@ -0,0 +1,4 @@
+---
+title: 'fix: removed signed_out notification'
+merge_request: 7958
+author: jnoortheen
diff --git a/changelogs/unreleased/25483-broken-tabs.yml b/changelogs/unreleased/25483-broken-tabs.yml
new file mode 100644
index 00000000000..d6c92014bea
--- /dev/null
+++ b/changelogs/unreleased/25483-broken-tabs.yml
@@ -0,0 +1,4 @@
+---
+title: Fix TypeError: Cannot read property 'initTabs' on commit builds tab
+merge_request: 8009
+author:
diff --git a/changelogs/unreleased/features-api-snippets.yml b/changelogs/unreleased/features-api-snippets.yml
new file mode 100644
index 00000000000..80c7bb75359
--- /dev/null
+++ b/changelogs/unreleased/features-api-snippets.yml
@@ -0,0 +1,4 @@
+---
+title: 'API: Endpoint to expose personal snippets as /snippets'
+merge_request: 6373
+author: Bernard Guyzmo Pratz
diff --git a/changelogs/unreleased/issue_25030.yml b/changelogs/unreleased/issue_25030.yml
new file mode 100644
index 00000000000..e18b8d6a79b
--- /dev/null
+++ b/changelogs/unreleased/issue_25030.yml
@@ -0,0 +1,4 @@
+---
+title: Allow branch names with dots on API endpoint
+merge_request:
+author:
diff --git a/changelogs/unreleased/unescape-relative-path.yml b/changelogs/unreleased/unescape-relative-path.yml
new file mode 100644
index 00000000000..755b0379a16
--- /dev/null
+++ b/changelogs/unreleased/unescape-relative-path.yml
@@ -0,0 +1,4 @@
+---
+title: Avoid escaping relative links in Markdown twice
+merge_request: 7940
+author: winniehell
diff --git a/doc/README.md b/doc/README.md
index 66c8c26e4f0..eba1e9845b1 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,4 +1,4 @@
-# Documentation
+# GitLab Community Edition documentation
## User documentation
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 81df55ab4ab..662cc9da733 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -429,7 +429,7 @@ DELETE /projects/:id/merge_requests/:merge_request_id
| `merge_request_id` | integer | yes | The ID of a project's merge request |
```bash
-curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/merge_request/85
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/merge_requests/85
```
## Accept MR
diff --git a/doc/api/services.md b/doc/api/services.md
index a5d733fe6c7..3dad953cd1e 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -139,6 +139,43 @@ Get Buildkite service settings for a project.
GET /projects/:id/services/buildkite
```
+## Build-Emails
+
+Get emails for GitLab CI builds.
+
+### Create/Edit Build-Emails service
+
+Set Build-Emails service for a project.
+
+```
+PUT /projects/:id/services/builds-email
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `recipients` | string | yes | Comma-separated list of recipient email addresses |
+| `add_pusher` | boolean | no | Add pusher to recipients list |
+| `notify_only_broken_builds` | boolean | no | Notify only broken builds |
+
+
+### Delete Build-Emails service
+
+Delete Build-Emails service for a project.
+
+```
+DELETE /projects/:id/services/builds-email
+```
+
+### Get Build-Emails service settings
+
+Get Build-Emails service settings for a project.
+
+```
+GET /projects/:id/services/builds-email
+```
+
## Campfire
Simple web-based real-time group chat
@@ -476,12 +513,11 @@ PUT /projects/:id/services/jira
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `active` | boolean| no | Enable/disable the JIRA service. |
| `url` | string | yes | The URL to the JIRA project which is being linked to this GitLab project, e.g., `https://jira.example.com`. |
| `project_key` | string | yes | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. |
| `username` | string | no | The username of the user created to be used with GitLab/JIRA. |
| `password` | string | no | The password of the user created to be used with GitLab/JIRA. |
-| `jira_issue_transition_id` | string | no | The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`. |
+| `jira_issue_transition_id` | integer | no | The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`. |
### Delete JIRA service
@@ -491,6 +527,78 @@ Remove all previously JIRA settings from a project.
DELETE /projects/:id/services/jira
```
+## Mattermost Slash Commands
+
+Ability to receive slash commands from a Mattermost chat instance.
+
+### Create/Edit Mattermost Slash Command service
+
+Set Mattermost Slash Command for a project.
+
+```
+PUT /projects/:id/services/mattermost-slash-commands
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | yes | The Mattermost token |
+
+
+### Delete Mattermost Slash Command service
+
+Delete Mattermost Slash Command service for a project.
+
+```
+DELETE /projects/:id/services/mattermost-slash-commands
+```
+
+### Get Mattermost Slash Command service settings
+
+Get Mattermost Slash Command service settings for a project.
+
+```
+GET /projects/:id/services/mattermost-slash-commands
+```
+
+## Pipeline-Emails
+
+Get emails for GitLab CI pipelines.
+
+### Create/Edit Pipeline-Emails service
+
+Set Pipeline-Emails service for a project.
+
+```
+PUT /projects/:id/services/pipelines-email
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `recipients` | string | yes | Comma-separated list of recipient email addresses |
+| `add_pusher` | boolean | no | Add pusher to recipients list |
+| `notify_only_broken_builds` | boolean | no | Notify only broken pipelines |
+
+
+### Delete Pipeline-Emails service
+
+Delete Pipeline-Emails service for a project.
+
+```
+DELETE /projects/:id/services/pipelines-email
+```
+
+### Get Pipeline-Emails service settings
+
+Get Pipeline-Emails service settings for a project.
+
+```
+GET /projects/:id/services/pipelines-email
+```
+
## PivotalTracker
Project Management Software (Source Commits Endpoint)
diff --git a/doc/api/snippets.md b/doc/api/snippets.md
new file mode 100644
index 00000000000..5a5dc162ffe
--- /dev/null
+++ b/doc/api/snippets.md
@@ -0,0 +1,232 @@
+# Snippets
+
+> [Introduced][ce-6373] in GitLab 8.15.
+
+### Snippet visibility level
+
+Snippets in GitLab can be either private, internal, or public.
+You can set it with the `visibility_level` field in the snippet.
+
+Constants for snippet visibility levels are:
+
+| Visibility | Visibility level | Description |
+| ---------- | ---------------- | ----------- |
+| Private | `0` | The snippet is visible only to the snippet creator |
+| Internal | `10` | The snippet is visible for any logged in user |
+| Public | `20` | The snippet can be accessed without any authentication |
+
+## List snippets
+
+Get a list of current user's snippets.
+
+```
+GET /snippets
+```
+
+## Single snippet
+
+Get a single snippet.
+
+```
+GET /snippets/:id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | Integer | yes | The ID of a snippet |
+
+``` bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/snippets/1
+```
+
+Example response:
+
+``` json
+{
+ "id": 1,
+ "title": "test",
+ "file_name": "add.rb",
+ "author": {
+ "id": 1,
+ "username": "john_smith",
+ "email": "john@example.com",
+ "name": "John Smith",
+ "state": "active",
+ "created_at": "2012-05-23T08:00:58Z"
+ },
+ "expires_at": null,
+ "updated_at": "2012-06-28T10:52:04Z",
+ "created_at": "2012-06-28T10:52:04Z",
+ "web_url": "http://example.com/snippets/1",
+}
+```
+
+## Create new snippet
+
+Creates a new snippet. The user must have permission to create new snippets.
+
+```
+POST /snippets
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `title` | String | yes | The title of a snippet |
+| `file_name` | String | yes | The name of a snippet file |
+| `content` | String | yes | The content of a snippet |
+| `visibility_level` | Integer | yes | The snippet's visibility |
+
+
+``` bash
+curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "file_name": "test.txt", "visibility_level": 10 }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/snippets
+```
+
+Example response:
+
+``` json
+{
+ "id": 1,
+ "title": "This is a snippet",
+ "file_name": "test.txt",
+ "author": {
+ "id": 1,
+ "username": "john_smith",
+ "email": "john@example.com",
+ "name": "John Smith",
+ "state": "active",
+ "created_at": "2012-05-23T08:00:58Z"
+ },
+ "expires_at": null,
+ "updated_at": "2012-06-28T10:52:04Z",
+ "created_at": "2012-06-28T10:52:04Z",
+ "web_url": "http://example.com/snippets/1",
+}
+```
+
+## Update snippet
+
+Updates an existing snippet. The user must have permission to change an existing snippet.
+
+```
+PUT /snippets/:id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | Integer | yes | The ID of a snippet |
+| `title` | String | no | The title of a snippet |
+| `file_name` | String | no | The name of a snippet file |
+| `content` | String | no | The content of a snippet |
+| `visibility_level` | Integer | no | The snippet's visibility |
+
+
+``` bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data '{"title": "foo", "content": "bar"}' https://gitlab.example.com/api/v3/snippets/1
+```
+
+Example response:
+
+``` json
+{
+ "id": 1,
+ "title": "test",
+ "file_name": "add.rb",
+ "author": {
+ "id": 1,
+ "username": "john_smith",
+ "email": "john@example.com",
+ "name": "John Smith",
+ "state": "active",
+ "created_at": "2012-05-23T08:00:58Z"
+ },
+ "expires_at": null,
+ "updated_at": "2012-06-28T10:52:04Z",
+ "created_at": "2012-06-28T10:52:04Z",
+ "web_url": "http://example.com/snippets/1",
+}
+```
+
+## Delete snippet
+
+Deletes an existing snippet.
+
+```
+DELETE /snippets/:id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | Integer | yes | The ID of a snippet |
+
+
+```
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/snippets/1"
+```
+
+upon successful delete a `204 No content` HTTP code shall be expected, with no data,
+but if the snippet is non-existent, a `404 Not Found` will be returned.
+
+## Explore all public snippets
+
+```
+GET /snippets/public
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `per_page` | Integer | no | number of snippets to return per page |
+| `page` | Integer | no | the page to retrieve |
+
+``` bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/snippets/public?per_page=2&page=1
+```
+
+Example response:
+
+``` json
+[
+ {
+ "author": {
+ "avatar_url": "http://www.gravatar.com/avatar/edaf55a9e363ea263e3b981d09e0f7f7?s=80&d=identicon",
+ "id": 12,
+ "name": "Libby Rolfson",
+ "state": "active",
+ "username": "elton_wehner",
+ "web_url": "http://localhost:3000/elton_wehner"
+ },
+ "created_at": "2016-11-25T16:53:34.504Z",
+ "file_name": "oconnerrice.rb",
+ "id": 49,
+ "raw_url": "http://localhost:3000/snippets/49/raw",
+ "title": "Ratione cupiditate et laborum temporibus.",
+ "updated_at": "2016-11-25T16:53:34.504Z",
+ "web_url": "http://localhost:3000/snippets/49"
+ },
+ {
+ "author": {
+ "avatar_url": "http://www.gravatar.com/avatar/36583b28626de71061e6e5a77972c3bd?s=80&d=identicon",
+ "id": 16,
+ "name": "Llewellyn Flatley",
+ "state": "active",
+ "username": "adaline",
+ "web_url": "http://localhost:3000/adaline"
+ },
+ "created_at": "2016-11-25T16:53:34.479Z",
+ "file_name": "muellershields.rb",
+ "id": 48,
+ "raw_url": "http://localhost:3000/snippets/48/raw",
+ "title": "Minus similique nesciunt vel fugiat qui ullam sunt.",
+ "updated_at": "2016-11-25T16:53:34.479Z",
+ "web_url": "http://localhost:3000/snippets/48"
+ }
+]
+```
+
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index a66165dc973..c679ea4e298 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -33,7 +33,7 @@ built and deployed under a dynamic environment and can be previewed with an
also dynamically URL.
The details of the Review Apps implementation depend widely on your real
-technology stack and on your deployment process. The simplest case it to
+technology stack and on your deployment process. The simplest case is to
deploy a simple static HTML website, but it will not be that straightforward
when your app is using a database for example. To make a branch be deployed
on a temporary instance and booting up this instance with all required software
diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb
index 5e7d539add6..a3bebfa4b71 100644
--- a/features/steps/project/snippets.rb
+++ b/features/steps/project/snippets.rb
@@ -22,7 +22,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I click link "New snippet"' do
- click_link "New snippet"
+ first(:link, "New snippet").click
end
step 'I click link "Snippet one"' do
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 67109ceeef9..cec2702e44d 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -64,6 +64,7 @@ module API
mount ::API::Session
mount ::API::Settings
mount ::API::SidekiqMetrics
+ mount ::API::Snippets
mount ::API::Subscriptions
mount ::API::SystemHooks
mount ::API::Tags
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index 73aed624ea7..0950c3d2e88 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -23,9 +23,9 @@ module API
success Entities::RepoBranch
end
params do
- requires :branch, type: String, regexp: /.+/, desc: 'The name of the branch'
+ requires :branch, type: String, desc: 'The name of the branch'
end
- get ':id/repository/branches/:branch' do
+ get ':id/repository/branches/:branch', requirements: { branch: /.+/ } do
branch = user_project.repository.find_branch(params[:branch])
not_found!("Branch") unless branch
@@ -39,11 +39,11 @@ module API
success Entities::RepoBranch
end
params do
- requires :branch, type: String, regexp: /.+/, desc: 'The name of the branch'
+ requires :branch, type: String, desc: 'The name of the branch'
optional :developers_can_push, type: Boolean, desc: 'Flag if developers can push to that branch'
optional :developers_can_merge, type: Boolean, desc: 'Flag if developers can merge to that branch'
end
- put ':id/repository/branches/:branch/protect' do
+ put ':id/repository/branches/:branch/protect', requirements: { branch: /.+/ } do
authorize_admin_project
branch = user_project.repository.find_branch(params[:branch])
@@ -76,9 +76,9 @@ module API
success Entities::RepoBranch
end
params do
- requires :branch, type: String, regexp: /.+/, desc: 'The name of the branch'
+ requires :branch, type: String, desc: 'The name of the branch'
end
- put ':id/repository/branches/:branch/unprotect' do
+ put ':id/repository/branches/:branch/unprotect', requirements: { branch: /.+/ } do
authorize_admin_project
branch = user_project.repository.find_branch(params[:branch])
@@ -112,9 +112,9 @@ module API
desc 'Delete a branch'
params do
- requires :branch, type: String, regexp: /.+/, desc: 'The name of the branch'
+ requires :branch, type: String, desc: 'The name of the branch'
end
- delete ":id/repository/branches/:branch" do
+ delete ":id/repository/branches/:branch", requirements: { branch: /.+/ } do
authorize_push_project
result = DeleteBranchService.new(user_project, current_user).
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 006d5f9f44e..01c0f5072ba 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -201,6 +201,19 @@ module API
end
end
+ class PersonalSnippet < Grape::Entity
+ expose :id, :title, :file_name
+ expose :author, using: Entities::UserBasic
+ expose :updated_at, :created_at
+
+ expose :web_url do |snippet|
+ Gitlab::UrlBuilder.build(snippet)
+ end
+ expose :raw_url do |snippet|
+ Gitlab::UrlBuilder.build(snippet) + "/raw"
+ end
+ end
+
class ProjectEntity < Grape::Entity
expose :id, :iid
expose(:project_id) { |entity| entity.project.id }
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index fbf7513302b..105d3ee342e 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -1,7 +1,7 @@
module API
class Groups < Grape::API
include PaginationParams
-
+
before { authenticate! }
helpers do
@@ -117,11 +117,20 @@ module API
success Entities::Project
end
params do
+ optional :archived, type: Boolean, default: false, desc: 'Limit by archived status'
+ optional :visibility, type: String, values: %w[public internal private],
+ desc: 'Limit by visibility'
+ optional :search, type: String, desc: 'Return list of authorized projects matching the search criteria'
+ optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at],
+ default: 'created_at', desc: 'Return projects ordered by field'
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return projects sorted in ascending and descending order'
use :pagination
end
get ":id/projects" do
group = find_group!(params[:id])
projects = GroupProjectsFinder.new(group).execute(current_user)
+ projects = filter_projects(projects)
present paginate(projects), with: Entities::Project, user: current_user
end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 8b0f8deadfa..f03d8da732e 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -96,17 +96,6 @@ module API
end
end
- def project_service(project = user_project)
- @project_service ||= project.find_or_initialize_service(params[:service_slug].underscore)
- @project_service || not_found!("Service")
- end
-
- def service_attributes
- @service_attributes ||= project_service.fields.inject([]) do |arr, hash|
- arr << hash[:name].to_sym
- end
- end
-
def find_group(id)
if id =~ /^\d+$/
Group.find_by(id: id)
diff --git a/lib/api/services.rb b/lib/api/services.rb
index bc427705777..fde2e2746f1 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -1,84 +1,602 @@
module API
- # Projects API
class Services < Grape::API
+ services = {
+ 'asana' => [
+ {
+ required: true,
+ name: :api_key,
+ type: String,
+ desc: 'User API token'
+ },
+ {
+ required: false,
+ name: :restrict_to_branch,
+ type: String,
+ desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches'
+ }
+ ],
+ 'assembla' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'The authentication token'
+ },
+ {
+ required: false,
+ name: :subdomain,
+ type: String,
+ desc: 'Subdomain setting'
+ }
+ ],
+ 'bamboo' => [
+ {
+ required: true,
+ name: :bamboo_url,
+ type: String,
+ desc: 'Bamboo root URL like https://bamboo.example.com'
+ },
+ {
+ required: true,
+ name: :build_key,
+ type: String,
+ desc: 'Bamboo build plan key like'
+ },
+ {
+ required: true,
+ name: :username,
+ type: String,
+ desc: 'A user with API access, if applicable'
+ },
+ {
+ required: true,
+ name: :password,
+ type: String,
+ desc: 'Passord of the user'
+ }
+ ],
+ 'bugzilla' => [
+ {
+ required: true,
+ name: :new_issue_url,
+ type: String,
+ desc: 'New issue URL'
+ },
+ {
+ required: true,
+ name: :issues_url,
+ type: String,
+ desc: 'Issues URL'
+ },
+ {
+ required: true,
+ name: :project_url,
+ type: String,
+ desc: 'Project URL'
+ },
+ {
+ required: false,
+ name: :description,
+ type: String,
+ desc: 'Description'
+ },
+ {
+ required: false,
+ name: :title,
+ type: String,
+ desc: 'Title'
+ }
+ ],
+ 'buildkite' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'Buildkite project GitLab token'
+ },
+ {
+ required: true,
+ name: :project_url,
+ type: String,
+ desc: 'The buildkite project URL'
+ },
+ {
+ required: false,
+ name: :enable_ssl_verification,
+ type: Boolean,
+ desc: 'Enable SSL verification for communication'
+ }
+ ],
+ 'builds-email' => [
+ {
+ required: true,
+ name: :recipients,
+ type: String,
+ desc: 'Comma-separated list of recipient email addresses'
+ },
+ {
+ required: false,
+ name: :add_pusher,
+ type: Boolean,
+ desc: 'Add pusher to recipients list'
+ },
+ {
+ required: false,
+ name: :notify_only_broken_builds,
+ type: Boolean,
+ desc: 'Notify only broken builds'
+ }
+ ],
+ 'campfire' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'Campfire token'
+ },
+ {
+ required: false,
+ name: :subdomain,
+ type: String,
+ desc: 'Campfire subdomain'
+ },
+ {
+ required: false,
+ name: :room,
+ type: String,
+ desc: 'Campfire room'
+ },
+ ],
+ 'custom-issue-tracker' => [
+ {
+ required: true,
+ name: :new_issue_url,
+ type: String,
+ desc: 'New issue URL'
+ },
+ {
+ required: true,
+ name: :issues_url,
+ type: String,
+ desc: 'Issues URL'
+ },
+ {
+ required: true,
+ name: :project_url,
+ type: String,
+ desc: 'Project URL'
+ },
+ {
+ required: false,
+ name: :description,
+ type: String,
+ desc: 'Description'
+ },
+ {
+ required: false,
+ name: :title,
+ type: String,
+ desc: 'Title'
+ }
+ ],
+ 'drone-ci' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'Drone CI token'
+ },
+ {
+ required: true,
+ name: :drone_url,
+ type: String,
+ desc: 'Drone CI URL'
+ },
+ {
+ required: false,
+ name: :enable_ssl_verification,
+ type: Boolean,
+ desc: 'Enable SSL verification for communication'
+ }
+ ],
+ 'emails-on-push' => [
+ {
+ required: true,
+ name: :recipients,
+ type: String,
+ desc: 'Comma-separated list of recipient email addresses'
+ },
+ {
+ required: false,
+ name: :disable_diffs,
+ type: Boolean,
+ desc: 'Disable code diffs'
+ },
+ {
+ required: false,
+ name: :send_from_committer_email,
+ type: Boolean,
+ desc: 'Send from committer'
+ }
+ ],
+ 'external-wiki' => [
+ {
+ required: true,
+ name: :external_wiki_url,
+ type: String,
+ desc: 'The URL of the external Wiki'
+ }
+ ],
+ 'flowdock' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'Flowdock token'
+ }
+ ],
+ 'gemnasium' => [
+ {
+ required: true,
+ name: :api_key,
+ type: String,
+ desc: 'Your personal API key on gemnasium.com'
+ },
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: "The project's slug on gemnasium.com"
+ }
+ ],
+ 'hipchat' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'The room token'
+ },
+ {
+ required: false,
+ name: :room,
+ type: String,
+ desc: 'The room name or ID'
+ },
+ {
+ required: false,
+ name: :color,
+ type: String,
+ desc: 'The room color'
+ },
+ {
+ required: false,
+ name: :notify,
+ type: Boolean,
+ desc: 'Enable notifications'
+ },
+ {
+ required: false,
+ name: :api_version,
+ type: String,
+ desc: 'Leave blank for default (v2)'
+ },
+ {
+ required: false,
+ name: :server,
+ type: String,
+ desc: 'Leave blank for default. https://hipchat.example.com'
+ }
+ ],
+ 'irker' => [
+ {
+ required: true,
+ name: :recipients,
+ type: String,
+ desc: 'Recipients/channels separated by whitespaces'
+ },
+ {
+ required: false,
+ name: :default_irc_uri,
+ type: String,
+ desc: 'Default: irc://irc.network.net:6697'
+ },
+ {
+ required: false,
+ name: :server_host,
+ type: String,
+ desc: 'Server host. Default localhost'
+ },
+ {
+ required: false,
+ name: :server_port,
+ type: Integer,
+ desc: 'Server port. Default 6659'
+ },
+ {
+ required: false,
+ name: :colorize_messages,
+ type: Boolean,
+ desc: 'Colorize messages'
+ }
+ ],
+ 'jira' => [
+ {
+ required: true,
+ name: :url,
+ type: String,
+ desc: 'The URL to the JIRA project which is being linked to this GitLab project, e.g., https://jira.example.com'
+ },
+ {
+ required: true,
+ name: :project_key,
+ type: String,
+ desc: 'The short identifier for your JIRA project, all uppercase, e.g., PROJ'
+ },
+ {
+ required: false,
+ name: :username,
+ type: String,
+ desc: 'The username of the user created to be used with GitLab/JIRA'
+ },
+ {
+ required: false,
+ name: :password,
+ type: String,
+ desc: 'The password of the user created to be used with GitLab/JIRA'
+ },
+ {
+ required: false,
+ name: :jira_issue_transition_id,
+ type: Integer,
+ desc: 'The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`'
+ }
+ ],
+ 'mattermost-slash-commands' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'The Mattermost token'
+ }
+ ],
+ 'pipelines-email' => [
+ {
+ required: true,
+ name: :recipients,
+ type: String,
+ desc: 'Comma-separated list of recipient email addresses'
+ },
+ {
+ required: false,
+ name: :notify_only_broken_builds,
+ type: Boolean,
+ desc: 'Notify only broken builds'
+ }
+ ],
+ 'pivotaltracker' => [
+ {
+ required: true,
+ name: :token,
+ type: String,
+ desc: 'The Pivotaltracker token'
+ },
+ {
+ required: false,
+ name: :restrict_to_branch,
+ type: String,
+ desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.'
+ }
+ ],
+ 'pushover' => [
+ {
+ required: true,
+ name: :api_key,
+ type: String,
+ desc: 'The application key'
+ },
+ {
+ required: true,
+ name: :user_key,
+ type: String,
+ desc: 'The user key'
+ },
+ {
+ required: true,
+ name: :priority,
+ type: String,
+ desc: 'The priority'
+ },
+ {
+ required: true,
+ name: :device,
+ type: String,
+ desc: 'Leave blank for all active devices'
+ },
+ {
+ required: true,
+ name: :sound,
+ type: String,
+ desc: 'The sound of the notification'
+ }
+ ],
+ 'redmine' => [
+ {
+ required: true,
+ name: :new_issue_url,
+ type: String,
+ desc: 'The new issue URL'
+ },
+ {
+ required: true,
+ name: :project_url,
+ type: String,
+ desc: 'The project URL'
+ },
+ {
+ required: true,
+ name: :issues_url,
+ type: String,
+ desc: 'The issues URL'
+ },
+ {
+ required: false,
+ name: :description,
+ type: String,
+ desc: 'The description of the tracker'
+ }
+ ],
+ 'slack' => [
+ {
+ required: true,
+ name: :webhook,
+ type: String,
+ desc: 'The Slack webhook. e.g. https://hooks.slack.com/services/...'
+ },
+ {
+ required: false,
+ name: :new_issue_url,
+ type: String,
+ desc: 'The user name'
+ },
+ {
+ required: false,
+ name: :channel,
+ type: String,
+ desc: 'The channel name'
+ }
+ ],
+ 'teamcity' => [
+ {
+ required: true,
+ name: :teamcity_url,
+ type: String,
+ desc: 'TeamCity root URL like https://teamcity.example.com'
+ },
+ {
+ required: true,
+ name: :build_type,
+ type: String,
+ desc: 'Build configuration ID'
+ },
+ {
+ required: true,
+ name: :username,
+ type: String,
+ desc: 'A user with permissions to trigger a manual build'
+ },
+ {
+ required: true,
+ name: :password,
+ type: String,
+ desc: 'The password of the user'
+ }
+ ]
+ }.freeze
+
+ trigger_services = {
+ 'mattermost-slash-commands' => [
+ {
+ name: :token,
+ type: String,
+ desc: 'The Mattermost token'
+ }
+ ]
+ }.freeze
+
resource :projects do
before { authenticate! }
before { authorize_admin_project }
- # Set <service_slug> service for project
- #
- # Example Request:
- #
- # PUT /projects/:id/services/gitlab-ci
- #
- put ':id/services/:service_slug' do
- if project_service
- validators = project_service.class.validators.select do |s|
- s.class == ActiveRecord::Validations::PresenceValidator &&
- s.attributes != [:project_id]
+ helpers do
+ def service_attributes(service)
+ service.fields.inject([]) do |arr, hash|
+ arr << hash[:name].to_sym
end
+ end
+ end
- required_attributes! validators.map(&:attributes).flatten.uniq
- attrs = attributes_for_keys service_attributes
+ services.each do |service_slug, settings|
+ desc "Set #{service_slug} service for project"
+ params do
+ settings.each do |setting|
+ if setting[:required]
+ requires setting[:name], type: setting[:type], desc: setting[:desc]
+ else
+ optional setting[:name], type: setting[:type], desc: setting[:desc]
+ end
+ end
+ end
+ put ":id/services/#{service_slug}" do
+ service = user_project.find_or_initialize_service(service_slug.underscore)
+ service_params = declared_params(include_missing: false).merge(active: true)
- if project_service.update_attributes(attrs.merge(active: true))
+ if service.update_attributes(service_params)
true
else
- not_found!
+ render_api_error!('400 Bad Request', 400)
end
end
end
- # Delete <service_slug> service for project
- #
- # Example Request:
- #
- # DELETE /project/:id/services/gitlab-ci
- #
- delete ':id/services/:service_slug' do
- if project_service
- attrs = service_attributes.inject({}) do |hash, key|
- hash.merge!(key => nil)
- end
+ desc "Delete a service for project"
+ params do
+ requires :service_slug, type: String, values: services.keys, desc: 'The name of the service'
+ end
+ delete ":id/services/:service_slug" do
+ service = user_project.find_or_initialize_service(params[:service_slug].underscore)
- if project_service.update_attributes(attrs.merge(active: false))
- true
- else
- not_found!
- end
+ attrs = service_attributes(service).inject({}) do |hash, key|
+ hash.merge!(key => nil)
+ end
+
+ if service.update_attributes(attrs.merge(active: false))
+ true
+ else
+ render_api_error!('400 Bad Request', 400)
end
end
- # Get <service_slug> service settings for project
- #
- # Example Request:
- #
- # GET /project/:id/services/gitlab-ci
- #
- get ':id/services/:service_slug' do
- present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
+ desc 'Get the service settings for project' do
+ success Entities::ProjectService
+ end
+ params do
+ requires :service_slug, type: String, values: services.keys, desc: 'The name of the service'
+ end
+ get ":id/services/:service_slug" do
+ service = user_project.find_or_initialize_service(params[:service_slug].underscore)
+ present service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
end
end
- resource :projects do
- desc 'Trigger a slash command' do
- detail 'Added in GitLab 8.13'
+ trigger_services.each do |service_slug, settings|
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
end
- post ':id/services/:service_slug/trigger' do
- project = find_project(params[:id])
+ resource :projects do
+ desc "Trigger a slash command for #{service_slug}" do
+ detail 'Added in GitLab 8.13'
+ end
+ params do
+ settings.each do |setting|
+ requires setting[:name], type: setting[:type], desc: setting[:desc]
+ end
+ end
+ post ":id/services/#{service_slug.underscore}/trigger" do
+ project = find_project(params[:id])
- # This is not accurate, but done to prevent leakage of the project names
- not_found!('Service') unless project
+ # This is not accurate, but done to prevent leakage of the project names
+ not_found!('Service') unless project
- service = project_service(project)
+ service = project.find_or_initialize_service(service_slug.underscore)
- result = service.try(:active?) && service.try(:trigger, params)
+ result = service.try(:active?) && service.try(:trigger, params)
- if result
- status result[:status] || 200
- present result
- else
- not_found!('Service')
+ if result
+ status result[:status] || 200
+ present result
+ else
+ not_found!('Service')
+ end
end
end
end
diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb
new file mode 100644
index 00000000000..e096e636806
--- /dev/null
+++ b/lib/api/snippets.rb
@@ -0,0 +1,137 @@
+module API
+ # Snippets API
+ class Snippets < Grape::API
+ include PaginationParams
+
+ before { authenticate! }
+
+ resource :snippets do
+ helpers do
+ def snippets_for_current_user
+ SnippetsFinder.new.execute(current_user, filter: :by_user, user: current_user)
+ end
+
+ def public_snippets
+ SnippetsFinder.new.execute(current_user, filter: :public)
+ end
+ end
+
+ desc 'Get a snippets list for authenticated user' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ use :pagination
+ end
+ get do
+ present paginate(snippets_for_current_user), with: Entities::PersonalSnippet
+ end
+
+ desc 'List all public snippets current_user has access to' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ use :pagination
+ end
+ get 'public' do
+ present paginate(public_snippets), with: Entities::PersonalSnippet
+ end
+
+ desc 'Get a single snippet' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of a snippet'
+ end
+ get ':id' do
+ snippet = snippets_for_current_user.find(params[:id])
+ present snippet, with: Entities::PersonalSnippet
+ end
+
+ desc 'Create new snippet' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ requires :title, type: String, desc: 'The title of a snippet'
+ requires :file_name, type: String, desc: 'The name of a snippet file'
+ requires :content, type: String, desc: 'The content of a snippet'
+ optional :visibility_level, type: Integer,
+ values: Gitlab::VisibilityLevel.values,
+ default: Gitlab::VisibilityLevel::INTERNAL,
+ desc: 'The visibility level of the snippet'
+ end
+ post do
+ attrs = declared_params(include_missing: false)
+ snippet = CreateSnippetService.new(nil, current_user, attrs).execute
+
+ if snippet.persisted?
+ present snippet, with: Entities::PersonalSnippet
+ else
+ render_validation_error!(snippet)
+ end
+ end
+
+ desc 'Update an existing snippet' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of a snippet'
+ optional :title, type: String, desc: 'The title of a snippet'
+ optional :file_name, type: String, desc: 'The name of a snippet file'
+ optional :content, type: String, desc: 'The content of a snippet'
+ optional :visibility_level, type: Integer,
+ values: Gitlab::VisibilityLevel.values,
+ desc: 'The visibility level of the snippet'
+ at_least_one_of :title, :file_name, :content, :visibility_level
+ end
+ put ':id' do
+ snippet = snippets_for_current_user.find_by(id: params.delete(:id))
+ return not_found!('Snippet') unless snippet
+ authorize! :update_personal_snippet, snippet
+
+ attrs = declared_params(include_missing: false)
+
+ UpdateSnippetService.new(nil, current_user, snippet, attrs).execute
+ if snippet.persisted?
+ present snippet, with: Entities::PersonalSnippet
+ else
+ render_validation_error!(snippet)
+ end
+ end
+
+ desc 'Remove snippet' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ success Entities::PersonalSnippet
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of a snippet'
+ end
+ delete ':id' do
+ snippet = snippets_for_current_user.find_by(id: params.delete(:id))
+ return not_found!('Snippet') unless snippet
+ authorize! :destroy_personal_snippet, snippet
+ snippet.destroy
+ no_content!
+ end
+
+ desc 'Get a raw snippet' do
+ detail 'This feature was introduced in GitLab 8.15.'
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of a snippet'
+ end
+ get ":id/raw" do
+ snippet = snippets_for_current_user.find_by(id: params.delete(:id))
+ return not_found!('Snippet') unless snippet
+
+ env['api.format'] = :txt
+ content_type 'text/plain'
+ present snippet.content
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb
index f09d78be0ce..9e23c8f8c55 100644
--- a/lib/banzai/filter/relative_link_filter.rb
+++ b/lib/banzai/filter/relative_link_filter.rb
@@ -46,7 +46,7 @@ module Banzai
end
def rebuild_relative_uri(uri)
- file_path = relative_file_path(uri.path)
+ file_path = relative_file_path(uri)
uri.path = [
relative_url_root,
@@ -59,8 +59,10 @@ module Banzai
uri
end
- def relative_file_path(path)
- nested_path = build_relative_path(path, context[:requested_path])
+ def relative_file_path(uri)
+ path = Addressable::URI.unescape(uri.path)
+ request_path = Addressable::URI.unescape(context[:requested_path])
+ nested_path = build_relative_path(path, request_path)
file_exists?(nested_path) ? nested_path : path
end
@@ -108,11 +110,7 @@ module Banzai
end
def uri_type(path)
- @uri_types[path] ||= begin
- unescaped_path = Addressable::URI.unescape(path)
-
- current_commit.uri_type(unescaped_path)
- end
+ @uri_types[path] ||= current_commit.uri_type(path)
end
def current_commit
diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb
index 99d0c28e749..ccb456bcc94 100644
--- a/lib/gitlab/url_builder.rb
+++ b/lib/gitlab/url_builder.rb
@@ -24,6 +24,8 @@ module Gitlab
wiki_page_url
when ProjectSnippet
project_snippet_url(object)
+ when Snippet
+ personal_snippet_url(object)
else
raise NotImplementedError.new("No URL builder defined for #{object.class}")
end
diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb
index 365cb445df1..44dfc2dff45 100644
--- a/spec/features/dashboard/datetime_on_tooltips_spec.rb
+++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb
@@ -36,7 +36,7 @@ feature 'Tooltips on .timeago dates', feature: true, js: true do
visit user_snippets_path(user)
wait_for_ajax()
- page.find('.js-timeago').hover
+ page.find('.js-timeago.snippet-created-ago').hover
end
it 'has the datetime formated correctly' do
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index c421da97d76..cd0512a37e6 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -2,8 +2,9 @@ require 'rails_helper'
feature 'GFM autocomplete', feature: true, js: true do
include WaitForAjax
- let(:user) { create(:user) }
+ let(:user) { create(:user, username: 'someone.special') }
let(:project) { create(:project) }
+ let(:label) { create(:label, project: project, title: 'special+') }
let(:issue) { create(:issue, project: project) }
before do
@@ -23,21 +24,69 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).to have_selector('.atwho-container')
end
- it 'opens autocomplete menu when field is prefixed with non-text character' do
+ it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
+ find('#note_note').native.send_keys('testing')
find('#note_note').native.send_keys('@')
end
- expect(page).to have_selector('.atwho-container')
+ expect(page).not_to have_selector('.atwho-view')
end
- it 'doesnt open autocomplete menu character is prefixed with text' do
- page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('testing')
- find('#note_note').native.send_keys('@')
+ context 'if a selected value has special characters' do
+ it 'wraps the result in double quotes' do
+ note = find('#note_note')
+ page.within '.timeline-content-form' do
+ note.native.send_keys('')
+ note.native.send_keys("~#{label.title[0]}")
+ sleep 1
+ note.click
+ end
+
+ label_item = find('.atwho-view li', text: label.title)
+
+ expect_to_wrap(true, label_item, note, label.title)
end
- expect(page).not_to have_selector('.atwho-view')
+ it 'doesn\'t wrap for assignee values' do
+ note = find('#note_note')
+ page.within '.timeline-content-form' do
+ note.native.send_keys('')
+ note.native.send_keys("@#{user.username[0]}")
+ sleep 1
+ note.click
+ end
+
+ user_item = find('.atwho-view li', text: user.username)
+
+ expect_to_wrap(false, user_item, note, user.username)
+ end
+
+ it 'doesn\'t wrap for emoji values' do
+ note = find('#note_note')
+ page.within '.timeline-content-form' do
+ note.native.send_keys('')
+ note.native.send_keys(":cartwheel")
+ sleep 1
+ note.click
+ end
+
+ emoji_item = find('.atwho-view li', text: 'cartwheel_tone1')
+
+ expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1')
+ end
+
+ def expect_to_wrap(should_wrap, item, note, value)
+ expect(item).to have_content(value)
+ expect(item).not_to have_content("\"#{value}\"")
+
+ item.click
+
+ if should_wrap
+ expect(note.value).to include("\"#{value}\"")
+ else
+ expect(note.value).not_to include("\"#{value}\"")
+ end
+ end
end
end
diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb
index 28bdc18e840..975e99c5807 100644
--- a/spec/finders/snippets_finder_spec.rb
+++ b/spec/finders/snippets_finder_spec.rb
@@ -9,65 +9,74 @@ describe SnippetsFinder do
let(:project2) { create(:empty_project, :private, group: group) }
context ':all filter' do
- before do
- @snippet1 = create(:personal_snippet, :private)
- @snippet2 = create(:personal_snippet, :internal)
- @snippet3 = create(:personal_snippet, :public)
- end
+ let!(:snippet1) { create(:personal_snippet, :private) }
+ let!(:snippet2) { create(:personal_snippet, :internal) }
+ let!(:snippet3) { create(:personal_snippet, :public) }
it "returns all private and internal snippets" do
snippets = SnippetsFinder.new.execute(user, filter: :all)
- expect(snippets).to include(@snippet2, @snippet3)
- expect(snippets).not_to include(@snippet1)
+ expect(snippets).to include(snippet2, snippet3)
+ expect(snippets).not_to include(snippet1)
end
it "returns all public snippets" do
snippets = SnippetsFinder.new.execute(nil, filter: :all)
- expect(snippets).to include(@snippet3)
- expect(snippets).not_to include(@snippet1, @snippet2)
+ expect(snippets).to include(snippet3)
+ expect(snippets).not_to include(snippet1, snippet2)
end
end
- context ':by_user filter' do
- before do
- @snippet1 = create(:personal_snippet, :private, author: user)
- @snippet2 = create(:personal_snippet, :internal, author: user)
- @snippet3 = create(:personal_snippet, :public, author: user)
+ context ':public filter' do
+ let!(:snippet1) { create(:personal_snippet, :private) }
+ let!(:snippet2) { create(:personal_snippet, :internal) }
+ let!(:snippet3) { create(:personal_snippet, :public) }
+
+ it "returns public public snippets" do
+ snippets = SnippetsFinder.new.execute(nil, filter: :public)
+
+ expect(snippets).to include(snippet3)
+ expect(snippets).not_to include(snippet1, snippet2)
end
+ end
+
+ context ':by_user filter' do
+ let!(:snippet1) { create(:personal_snippet, :private, author: user) }
+ let!(:snippet2) { create(:personal_snippet, :internal, author: user) }
+ let!(:snippet3) { create(:personal_snippet, :public, author: user) }
it "returns all public and internal snippets" do
snippets = SnippetsFinder.new.execute(user1, filter: :by_user, user: user)
- expect(snippets).to include(@snippet2, @snippet3)
- expect(snippets).not_to include(@snippet1)
+ expect(snippets).to include(snippet2, snippet3)
+ expect(snippets).not_to include(snippet1)
end
it "returns internal snippets" do
snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_internal")
- expect(snippets).to include(@snippet2)
- expect(snippets).not_to include(@snippet1, @snippet3)
+ expect(snippets).to include(snippet2)
+ expect(snippets).not_to include(snippet1, snippet3)
end
it "returns private snippets" do
snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_private")
- expect(snippets).to include(@snippet1)
- expect(snippets).not_to include(@snippet2, @snippet3)
+ expect(snippets).to include(snippet1)
+ expect(snippets).not_to include(snippet2, snippet3)
end
it "returns public snippets" do
snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user, scope: "are_public")
- expect(snippets).to include(@snippet3)
- expect(snippets).not_to include(@snippet1, @snippet2)
+ expect(snippets).to include(snippet3)
+ expect(snippets).not_to include(snippet1, snippet2)
end
it "returns all snippets" do
snippets = SnippetsFinder.new.execute(user, filter: :by_user, user: user)
- expect(snippets).to include(@snippet1, @snippet2, @snippet3)
+ expect(snippets).to include(snippet1, snippet2, snippet3)
end
it "returns only public snippets if unauthenticated user" do
snippets = SnippetsFinder.new.execute(nil, filter: :by_user, user: user)
- expect(snippets).to include(@snippet3)
- expect(snippets).not_to include(@snippet2, @snippet1)
+ expect(snippets).to include(snippet3)
+ expect(snippets).not_to include(snippet2, snippet1)
end
end
@@ -84,16 +93,39 @@ describe SnippetsFinder do
expect(snippets).not_to include(@snippet1, @snippet2)
end
- it "returns public and internal snippets for none project members" do
+ it "returns public and internal snippets for non project members" do
snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1)
expect(snippets).to include(@snippet2, @snippet3)
expect(snippets).not_to include(@snippet1)
end
+ it "returns public snippets for non project members" do
+ snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_public")
+ expect(snippets).to include(@snippet3)
+ expect(snippets).not_to include(@snippet1, @snippet2)
+ end
+
+ it "returns internal snippets for non project members" do
+ snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_internal")
+ expect(snippets).to include(@snippet2)
+ expect(snippets).not_to include(@snippet1, @snippet3)
+ end
+
+ it "does not return private snippets for non project members" do
+ snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private")
+ expect(snippets).not_to include(@snippet1, @snippet2, @snippet3)
+ end
+
it "returns all snippets for project members" do
project1.team << [user, :developer]
snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1)
expect(snippets).to include(@snippet1, @snippet2, @snippet3)
end
+
+ it "returns private snippets for project members" do
+ project1.team << [user, :developer]
+ snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private")
+ expect(snippets).to include(@snippet1)
+ end
end
end
diff --git a/spec/javascripts/fixtures/pipeline_graph.html.haml b/spec/javascripts/fixtures/pipeline_graph.html.haml
new file mode 100644
index 00000000000..deca50ceaa7
--- /dev/null
+++ b/spec/javascripts/fixtures/pipeline_graph.html.haml
@@ -0,0 +1,15 @@
+%div.pipeline-visualization.js-pipeline-graph
+ %ul.stage-column-list
+ %li.stage-column
+ .stage-name
+ %a{:href => "/"}
+ Test
+ .builds-container
+ %ul
+ %li.build
+ .curve
+ .build-content
+ %a
+ %svg
+ .ci-status-text
+ stop_review
diff --git a/spec/javascripts/pipelines_spec.js.es6 b/spec/javascripts/pipelines_spec.js.es6
new file mode 100644
index 00000000000..85c9cf4b4f1
--- /dev/null
+++ b/spec/javascripts/pipelines_spec.js.es6
@@ -0,0 +1,25 @@
+//= require pipelines
+
+(() => {
+ describe('Pipelines', () => {
+ fixture.preload('pipeline_graph');
+
+ beforeEach(() => {
+ fixture.load('pipeline_graph');
+ });
+
+ it('should be defined', () => {
+ expect(window.gl.Pipelines).toBeDefined();
+ });
+
+ it('should create a `Pipelines` instance without options', () => {
+ expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line
+ });
+
+ it('should create a `Pipelines` instance with options', () => {
+ const pipelines = new window.gl.Pipelines({ foo: 'bar' });
+
+ expect(pipelines.pipelineGraph).toBeDefined();
+ });
+ });
+})();
diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js.es6
index ed6166a25a8..1b7ca97cde4 100644
--- a/spec/javascripts/smart_interval_spec.js.es6
+++ b/spec/javascripts/smart_interval_spec.js.es6
@@ -14,8 +14,9 @@
startingInterval: DEFAULT_STARTING_INTERVAL,
maxInterval: DEFAULT_MAX_INTERVAL,
incrementByFactorOf: DEFAULT_INCREMENT_FACTOR,
- delayStartBy: 0,
lazyStart: false,
+ immediateExecution: false,
+ hiddenInterval: null,
};
if (config) {
@@ -114,14 +115,31 @@
expect(interval.state.intervalId).toBeTruthy();
// simulates triggering of visibilitychange event
- interval.state.pageVisibility = 'hidden';
- interval.handleVisibilityChange();
+ interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } });
expect(interval.state.intervalId).toBeUndefined();
done();
}, DEFAULT_SHORT_TIMEOUT);
});
+ it('should change to the hidden interval when page is not visible', function (done) {
+ const HIDDEN_INTERVAL = 1500;
+ const interval = createDefaultSmartInterval({ hiddenInterval: HIDDEN_INTERVAL });
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+ expect(interval.getCurrentInterval() >= DEFAULT_STARTING_INTERVAL &&
+ interval.getCurrentInterval() <= DEFAULT_MAX_INTERVAL).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } });
+
+ expect(interval.state.intervalId).toBeTruthy();
+ expect(interval.getCurrentInterval()).toBe(HIDDEN_INTERVAL);
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
it('should resume when page is becomes visible at the previous interval', function (done) {
const interval = this.smartInterval;
@@ -129,14 +147,12 @@
expect(interval.state.intervalId).toBeTruthy();
// simulates triggering of visibilitychange event
- interval.state.pageVisibility = 'hidden';
- interval.handleVisibilityChange();
+ interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } });
expect(interval.state.intervalId).toBeUndefined();
// simulates triggering of visibilitychange event
- interval.state.pageVisibility = 'visible';
- interval.handleVisibilityChange();
+ interval.handleVisibilityChange({ target: { visibilityState: 'visible' } });
expect(interval.state.intervalId).toBeTruthy();
@@ -154,6 +170,11 @@
done();
}, DEFAULT_SHORT_TIMEOUT);
});
+
+ it('should execute callback before first interval', function () {
+ const interval = createDefaultSmartInterval({ immediateExecution: true });
+ expect(interval.cfg.immediateExecution).toBeFalsy();
+ });
});
});
})(window.gl || (window.gl = {}));
diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb
index 2bfa51deb20..df2dd173b57 100644
--- a/spec/lib/banzai/filter/relative_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb
@@ -175,7 +175,7 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do
allow_any_instance_of(described_class).to receive(:uri_type).and_return(:raw)
doc = filter(image(escaped))
- expect(doc.at_css('img')['src']).to match '/raw/'
+ expect(doc.at_css('img')['src']).to eq "/#{project_path}/raw/#{Addressable::URI.escape(ref)}/#{escaped}"
end
context 'when requested path is a file in the repo' do
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 55b8c8c0c69..2878e0cb59b 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -11,6 +11,7 @@ describe API::Branches, api: true do
let!(:guest) { create(:project_member, :guest, user: user2, project: project) }
let!(:branch_name) { 'feature' }
let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
+ let!(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master") }
describe "GET /projects/:id/repository/branches" do
it "returns an array of project branches" do
@@ -37,6 +38,13 @@ describe API::Branches, api: true do
expect(json_response['developers_can_merge']).to eq(false)
end
+ it "returns the branch information for a single branch with dots in the name" do
+ get api("/projects/#{project.id}/repository/branches/with.1.2.3", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['name']).to eq("with.1.2.3")
+ end
+
context 'on a merged branch' do
it "returns the branch information for a single branch" do
get api("/projects/#{project.id}/repository/branches/merge-test", user)
@@ -71,6 +79,14 @@ describe API::Branches, api: true do
expect(json_response['developers_can_merge']).to eq(false)
end
+ it "protects a single branch with dots in the name" do
+ put api("/projects/#{project.id}/repository/branches/with.1.2.3/protect", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['name']).to eq("with.1.2.3")
+ expect(json_response['protected']).to eq(true)
+ end
+
it 'protects a single branch and developers can push' do
put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user),
developers_can_push: true
@@ -220,6 +236,14 @@ describe API::Branches, api: true do
expect(json_response['protected']).to eq(false)
end
+ it "update branches with dots in branch name" do
+ put api("/projects/#{project.id}/repository/branches/with.1.2.3/unprotect", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['name']).to eq("with.1.2.3")
+ expect(json_response['protected']).to eq(false)
+ end
+
it "returns success when unprotect branch" do
put api("/projects/#{project.id}/repository/branches/unknown/unprotect", user)
expect(response).to have_http_status(404)
@@ -292,6 +316,13 @@ describe API::Branches, api: true do
expect(json_response['branch_name']).to eq(branch_name)
end
+ it "removes a branch with dots in the branch name" do
+ delete api("/projects/#{project.id}/repository/branches/with.1.2.3", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['branch_name']).to eq("with.1.2.3")
+ end
+
it 'returns 404 if branch not exists' do
delete api("/projects/#{project.id}/repository/branches/foobar", user)
expect(response).to have_http_status(404)
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 548ed8e1892..15647b262b6 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -245,6 +245,17 @@ describe API::Groups, api: true do
expect(project_names).to match_array([project1.name, project3.name])
end
+ it 'filters the groups projects' do
+ public_projet = create(:project, :public, path: 'test1', group: group1)
+
+ get api("/groups/#{group1.id}/projects", user1), visibility: 'public'
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['name']).to eq(public_projet.name)
+ end
+
it "does not return a non existing group" do
get api("/groups/1328/projects", user1)
expect(response).to have_http_status(404)
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index d30361f53d4..668e39f9dba 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -2,6 +2,7 @@ require "spec_helper"
describe API::Services, api: true do
include ApiHelpers
+
let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:user2) { create(:user) }
@@ -98,7 +99,7 @@ describe API::Services, api: true do
post api("/projects/#{project.id}/services/idonotexist/trigger")
expect(response).to have_http_status(404)
- expect(json_response["message"]).to eq("404 Service Not Found")
+ expect(json_response["error"]).to eq("404 Not Found")
end
end
@@ -114,7 +115,7 @@ describe API::Services, api: true do
end
it 'when the service is inactive' do
- post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger")
+ post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params
expect(response).to have_http_status(404)
end
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
new file mode 100644
index 00000000000..f6fb6ea5506
--- /dev/null
+++ b/spec/requests/api/snippets_spec.rb
@@ -0,0 +1,157 @@
+require 'rails_helper'
+
+describe API::Snippets, api: true do
+ include ApiHelpers
+ let!(:user) { create(:user) }
+
+ describe 'GET /snippets/' do
+ it 'returns snippets available' do
+ public_snippet = create(:personal_snippet, :public, author: user)
+ private_snippet = create(:personal_snippet, :private, author: user)
+ internal_snippet = create(:personal_snippet, :internal, author: user)
+
+ get api("/snippets/", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly(
+ public_snippet.id,
+ internal_snippet.id,
+ private_snippet.id)
+ expect(json_response.last).to have_key('web_url')
+ expect(json_response.last).to have_key('raw_url')
+ end
+
+ it 'hides private snippets from regular user' do
+ create(:personal_snippet, :private)
+
+ get api("/snippets/", user)
+ expect(response).to have_http_status(200)
+ expect(json_response.size).to eq(0)
+ end
+ end
+
+ describe 'GET /snippets/public' do
+ let!(:other_user) { create(:user) }
+ let!(:public_snippet) { create(:personal_snippet, :public, author: user) }
+ let!(:private_snippet) { create(:personal_snippet, :private, author: user) }
+ let!(:internal_snippet) { create(:personal_snippet, :internal, author: user) }
+ let!(:public_snippet_other) { create(:personal_snippet, :public, author: other_user) }
+ let!(:private_snippet_other) { create(:personal_snippet, :private, author: other_user) }
+ let!(:internal_snippet_other) { create(:personal_snippet, :internal, author: other_user) }
+
+ it 'returns all snippets with public visibility from all users' do
+ get api("/snippets/public", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly(
+ public_snippet.id,
+ public_snippet_other.id)
+ expect(json_response.map{ |snippet| snippet['web_url']} ).to include(
+ "http://localhost/snippets/#{public_snippet.id}",
+ "http://localhost/snippets/#{public_snippet_other.id}")
+ expect(json_response.map{ |snippet| snippet['raw_url']} ).to include(
+ "http://localhost/snippets/#{public_snippet.id}/raw",
+ "http://localhost/snippets/#{public_snippet_other.id}/raw")
+ end
+ end
+
+ describe 'GET /snippets/:id/raw' do
+ let(:snippet) { create(:personal_snippet, author: user) }
+
+ it 'returns raw text' do
+ get api("/snippets/#{snippet.id}/raw", user)
+
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq 'text/plain'
+ expect(response.body).to eq(snippet.content)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+ end
+
+ describe 'POST /snippets/' do
+ let(:params) do
+ {
+ title: 'Test Title',
+ file_name: 'test.rb',
+ content: 'puts "hello world"',
+ visibility_level: Gitlab::VisibilityLevel::PUBLIC
+ }
+ end
+
+ it 'creates a new snippet' do
+ expect do
+ post api("/snippets/", user), params
+ end.to change { PersonalSnippet.count }.by(1)
+
+ expect(response).to have_http_status(201)
+ expect(json_response['title']).to eq(params[:title])
+ expect(json_response['file_name']).to eq(params[:file_name])
+ end
+
+ it 'returns 400 for missing parameters' do
+ params.delete(:title)
+
+ post api("/snippets/", user), params
+
+ expect(response).to have_http_status(400)
+ end
+ end
+
+ describe 'PUT /snippets/:id' do
+ let(:other_user) { create(:user) }
+ let(:public_snippet) { create(:personal_snippet, :public, author: user) }
+ it 'updates snippet' do
+ new_content = 'New content'
+
+ put api("/snippets/#{public_snippet.id}", user), content: new_content
+
+ expect(response).to have_http_status(200)
+ public_snippet.reload
+ expect(public_snippet.content).to eq(new_content)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ put api("/snippets/1234", user), title: 'foo'
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+
+ it "returns 404 for another user's snippet" do
+ put api("/snippets/#{public_snippet.id}", other_user), title: 'fubar'
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+
+ it 'returns 400 for missing parameters' do
+ put api("/snippets/1234", user)
+
+ expect(response).to have_http_status(400)
+ end
+ end
+
+ describe 'DELETE /snippets/:id' do
+ let!(:public_snippet) { create(:personal_snippet, :public, author: user) }
+ it 'deletes snippet' do
+ expect do
+ delete api("/snippets/#{public_snippet.id}", user)
+
+ expect(response).to have_http_status(204)
+ end.to change { PersonalSnippet.count }.by(-1)
+ end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/snippets/1234", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+ end
+end
diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb
index c0b3e83244d..ad1eed5b369 100644
--- a/spec/support/login_helpers.rb
+++ b/spec/support/login_helpers.rb
@@ -75,7 +75,8 @@ module LoginHelpers
def logout
find(".header-user-dropdown-toggle").click
click_link "Sign out"
- expect(page).to have_content('Signed out successfully')
+ # check the sign_in button
+ expect(page).to have_button('Sign in')
end
# Logout without JavaScript driver
diff --git a/spec/support/services_shared_context.rb b/spec/support/services_shared_context.rb
index d1c999cad4d..66c93890e31 100644
--- a/spec/support/services_shared_context.rb
+++ b/spec/support/services_shared_context.rb
@@ -16,8 +16,14 @@ Service.available_services_names.each do |service|
hash.merge!(k => 'secrettoken')
elsif k =~ /^(.*_url|url|webhook)/
hash.merge!(k => "http://example.com")
+ elsif service_klass.method_defined?("#{k}?")
+ hash.merge!(k => true)
elsif service == 'irker' && k == :recipients
hash.merge!(k => 'irc://irc.network.net:666/#channel')
+ elsif service == 'irker' && k == :server_port
+ hash.merge!(k => 1234)
+ elsif service == 'jira' && k == :jira_issue_transition_id
+ hash.merge!(k => 1234)
else
hash.merge!(k => "someword")
end
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
index bf027499c94..a066ea078e6 100644
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/show.html.haml_spec.rb
@@ -28,7 +28,7 @@ describe 'projects/pipelines/show' do
it 'shows a graph with grouped stages' do
render
- expect(rendered).to have_css('.pipeline-graph')
+ expect(rendered).to have_css('.js-pipeline-graph')
expect(rendered).to have_css('.grouped-pipeline-dropdown')
# stages