summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorConnor Shea <connor.james.shea@gmail.com>2016-08-17 12:10:09 -0600
committerConnor Shea <connor.james.shea@gmail.com>2016-08-17 12:10:09 -0600
commitd8654744cd6c8fa4cbe4617c2428cd79d4bd5179 (patch)
tree159d92727944184792c939210b5b73aaf348336a
parent4a13aa9f34ab4114bc485e1ca8fa0db8daa0394b (diff)
parent46dc00631aeae34c1964888625c5ccd04da6b4c1 (diff)
downloadgitlab-ce-d8654744cd6c8fa4cbe4617c2428cd79d4bd5179.tar.gz
Merge branch 'master' into diff-line-comment-vuejs
-rw-r--r--CHANGELOG4
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js.es657
-rw-r--r--app/assets/javascripts/boards/components/board.js.es685
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js.es649
-rw-r--r--app/assets/javascripts/boards/components/board_card.js.es643
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js.es619
-rw-r--r--app/assets/javascripts/boards/components/board_list.js.es689
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js.es654
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js.es625
-rw-r--r--app/assets/javascripts/boards/models/issue.js.es644
-rw-r--r--app/assets/javascripts/boards/models/label.js.es69
-rw-r--r--app/assets/javascripts/boards/models/list.js.es6125
-rw-r--r--app/assets/javascripts/boards/models/user.js.es68
-rw-r--r--app/assets/javascripts/boards/services/board_service.js.es661
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js.es6112
-rwxr-xr-xapp/assets/javascripts/boards/test_utils/simulate_drag.js119
-rw-r--r--app/assets/javascripts/boards/vue_resource_interceptor.js.es68
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js1
-rw-r--r--app/assets/javascripts/create_label.js.es6126
-rw-r--r--app/assets/javascripts/dispatcher.js2
-rw-r--r--app/assets/javascripts/labels_select.js90
-rw-r--r--app/assets/javascripts/merge_conflict_data_provider.js.es6341
-rw-r--r--app/assets/javascripts/merge_conflict_resolver.js.es685
-rw-r--r--app/assets/javascripts/merge_request_tabs.js22
-rw-r--r--app/assets/javascripts/merge_request_widget.js4
-rw-r--r--app/assets/javascripts/milestone_select.js8
-rw-r--r--app/assets/javascripts/users_select.js9
-rw-r--r--app/assets/stylesheets/framework/mixins.scss7
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/highlight/dark.scss7
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss7
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss7
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss13
-rw-r--r--app/assets/stylesheets/highlight/white.scss13
-rw-r--r--app/assets/stylesheets/pages/boards.scss329
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss238
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss12
-rw-r--r--app/controllers/projects/board_lists_controller.rb65
-rw-r--r--app/controllers/projects/boards/application_controller.rb15
-rw-r--r--app/controllers/projects/boards/issues_controller.rb56
-rw-r--r--app/controllers/projects/boards/lists_controller.rb81
-rw-r--r--app/controllers/projects/boards_controller.rb15
-rw-r--r--app/controllers/projects/merge_requests_controller.rb72
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/commits_helper.rb45
-rw-r--r--app/helpers/nav_helper.rb1
-rw-r--r--app/models/ability.rb5
-rw-r--r--app/models/board.rb7
-rw-r--r--app/models/diff_note.rb4
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/list.rb34
-rw-r--r--app/models/merge_request.rb37
-rw-r--r--app/models/project.rb2
-rw-r--r--app/models/repository.rb28
-rw-r--r--app/services/boards/base_service.rb5
-rw-r--r--app/services/boards/create_service.rb16
-rw-r--r--app/services/boards/issues/list_service.rb63
-rw-r--r--app/services/boards/issues/move_service.rb59
-rw-r--r--app/services/boards/lists/create_service.rb22
-rw-r--r--app/services/boards/lists/destroy_service.rb25
-rw-r--r--app/services/boards/lists/generate_service.rb36
-rw-r--r--app/services/boards/lists/move_service.rb51
-rw-r--r--app/services/merge_requests/resolve_service.rb31
-rw-r--r--app/views/layouts/_page.html.haml5
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/projects/boards/components/_blank_state.html.haml15
-rw-r--r--app/views/projects/boards/components/_board.html.haml44
-rw-r--r--app/views/projects/boards/components/_card.html.haml34
-rw-r--r--app/views/projects/boards/show.html.haml19
-rw-r--r--app/views/projects/ci/pipelines/_pipeline.html.haml20
-rw-r--r--app/views/projects/commit/_pipelines_list.haml17
-rw-r--r--app/views/projects/issues/_head.html.haml5
-rw-r--r--app/views/projects/merge_requests/_show.html.haml14
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml29
-rw-r--r--app/views/projects/merge_requests/conflicts/_commit_stats.html.haml20
-rw-r--r--app/views/projects/merge_requests/conflicts/_inline_view.html.haml28
-rw-r--r--app/views/projects/merge_requests/conflicts/_parallel_view.html.haml27
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml15
-rw-r--r--app/views/projects/merge_requests/show/_builds.html.haml1
-rw-r--r--app/views/projects/merge_requests/show/_pipelines.html.haml1
-rw-r--r--app/views/projects/merge_requests/widget/_merged.html.haml2
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml10
-rw-r--r--app/views/projects/merge_requests/widget/_show.html.haml3
-rw-r--r--app/views/projects/merge_requests/widget/open/_conflicts.html.haml13
-rw-r--r--app/views/shared/issuable/_filter.html.haml12
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml7
-rw-r--r--app/workers/repository_fork_worker.rb4
-rw-r--r--app/workers/repository_import_worker.rb4
-rw-r--r--config/application.rb2
-rw-r--r--config/routes.rb17
-rw-r--r--db/fixtures/development/14_builds.rb32
-rw-r--r--db/migrate/20160727191041_create_boards.rb13
-rw-r--r--db/migrate/20160727193336_create_lists.rb16
-rw-r--r--db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb15
-rw-r--r--db/schema.rb24
-rw-r--r--doc/monitoring/performance/influxdb_schema.md9
-rw-r--r--doc/user/project/merge_requests/img/conflict_section.pngbin0 -> 247537 bytes
-rw-r--r--doc/user/project/merge_requests/img/merge_request_widget.pngbin0 -> 32292 bytes
-rw-r--r--doc/user/project/merge_requests/resolve_conflicts.md41
-rw-r--r--lib/ci/api/builds.rb8
-rw-r--r--lib/gitlab/conflict/file.rb186
-rw-r--r--lib/gitlab/conflict/file_collection.rb57
-rw-r--r--lib/gitlab/conflict/parser.rb62
-rw-r--r--lib/gitlab/diff/line.rb20
-rw-r--r--lib/gitlab/metrics.rb9
-rw-r--r--lib/gitlab/metrics/metric.rb9
-rw-r--r--lib/gitlab/metrics/rack_middleware.rb4
-rw-r--r--lib/gitlab/metrics/sidekiq_middleware.rb4
-rw-r--r--lib/gitlab/metrics/transaction.rb21
-rw-r--r--spec/controllers/projects/boards/issues_controller_spec.rb120
-rw-r--r--spec/controllers/projects/boards/lists_controller_spec.rb241
-rw-r--r--spec/controllers/projects/boards_controller_spec.rb43
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb136
-rw-r--r--spec/factories/boards.rb5
-rw-r--r--spec/factories/lists.rb20
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/features/boards/boards_spec.rb598
-rw-r--r--spec/features/merge_requests/conflicts_spec.rb72
-rw-r--r--spec/features/merge_requests/pipelines_spec.rb48
-rw-r--r--spec/fixtures/api/schemas/issue.json40
-rw-r--r--spec/fixtures/api/schemas/issues.json4
-rw-r--r--spec/fixtures/api/schemas/list.json39
-rw-r--r--spec/fixtures/api/schemas/lists.json4
-rw-r--r--spec/javascripts/boards/boards_store_spec.js.es6164
-rw-r--r--spec/javascripts/boards/issue_spec.js.es683
-rw-r--r--spec/javascripts/boards/list_spec.js.es689
-rw-r--r--spec/javascripts/boards/mock_data.js.es653
-rw-r--r--spec/lib/gitlab/conflict/file_collection_spec.rb24
-rw-r--r--spec/lib/gitlab/conflict/file_spec.rb261
-rw-r--r--spec/lib/gitlab/conflict/parser_spec.rb188
-rw-r--r--spec/lib/gitlab/metrics/metric_spec.rb18
-rw-r--r--spec/lib/gitlab/metrics/rack_middleware_spec.rb9
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb24
-rw-r--r--spec/lib/gitlab/metrics/transaction_spec.rb57
-rw-r--r--spec/lib/gitlab/metrics_spec.rb24
-rw-r--r--spec/models/board_spec.rb12
-rw-r--r--spec/models/label_spec.rb2
-rw-r--r--spec/models/list_spec.rb117
-rw-r--r--spec/models/merge_request_spec.rb79
-rw-r--r--spec/models/project_spec.rb1
-rw-r--r--spec/services/boards/create_service_spec.rb35
-rw-r--r--spec/services/boards/issues/list_service_spec.rb73
-rw-r--r--spec/services/boards/issues/move_service_spec.rb140
-rw-r--r--spec/services/boards/lists/create_service_spec.rb54
-rw-r--r--spec/services/boards/lists/destroy_service_spec.rb47
-rw-r--r--spec/services/boards/lists/generate_service_spec.rb40
-rw-r--r--spec/services/boards/lists/move_service_spec.rb110
-rw-r--r--spec/support/api/schema_matcher.rb8
-rw-r--r--spec/support/test_env.rb44
-rw-r--r--vendor/assets/javascripts/Sortable.js1285
-rw-r--r--vendor/assets/javascripts/clipboard.js20
153 files changed, 8177 insertions, 179 deletions
diff --git a/CHANGELOG b/CHANGELOG
index 837e9e27aba..580abe71bcc 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -9,6 +9,7 @@ v 8.11.0 (unreleased)
- Fix the title of the toggle dropdown button. !5515 (herminiotorres)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948
+ - Add Issues Board !5548
- Improve diff performance by eliminating redundant checks for text blobs
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps)
@@ -16,6 +17,7 @@ v 8.11.0 (unreleased)
- API: List access requests, request access, approve, and deny access requests to a project or a group. !4833
- Use long options for curl examples in documentation !5703 (winniehell)
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
+ - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
- Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
- Ignore URLs starting with // in Markdown links !5677 (winniehell)
- Fix CI status icon link underline (ClemMakesApps)
@@ -90,6 +92,7 @@ v 8.11.0 (unreleased)
- Allow branch names ending with .json for graph and network page !5579 (winniehell)
- Add the `sprockets-es6` gem
- Improve OAuth2 client documentation (muteor)
+ - Fix diff comments inverted toggle bug (ClemMakesApps)
- Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska)
- Profile requests when a header is passed
- Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab.
@@ -120,6 +123,7 @@ v 8.11.0 (unreleased)
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter
- Speed up todos queries by limiting the projects set we join with
- Ensure file editing in UI does not overwrite commited changes without warning user
+ - Eliminate unneeded calls to Repository#blob_at when listing commits with no path
v 8.10.6
- Upgrade Rails to 4.2.7.1 for security fixes. !5781
diff --git a/Gemfile b/Gemfile
index 8b44b54e22c..a6fcc3575ff 100644
--- a/Gemfile
+++ b/Gemfile
@@ -314,6 +314,7 @@ end
group :test do
gem 'shoulda-matchers', '~> 2.8.0', require: false
gem 'email_spec', '~> 1.6.0'
+ gem 'json-schema', '~> 2.6.2'
gem 'webmock', '~> 1.21.0'
gem 'test_after_commit', '~> 0.4.2'
gem 'sham_rack', '~> 1.3.6'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2244c20203b..58c84c47575 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -356,6 +356,8 @@ GEM
jquery-ui-rails (5.0.5)
railties (>= 3.2.16)
json (1.8.3)
+ json-schema (2.6.2)
+ addressable (~> 2.3.8)
jwt (1.5.4)
kaminari (0.17.0)
actionpack (>= 3.0.0)
@@ -873,6 +875,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
jquery-turbolinks (~> 2.1.0)
jquery-ui-rails (~> 5.0.0)
+ json-schema (~> 2.6.2)
jwt
kaminari (~> 0.17.0)
knapsack (~> 1.11.0)
diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6
new file mode 100644
index 00000000000..2c65d4427be
--- /dev/null
+++ b/app/assets/javascripts/boards/boards_bundle.js.es6
@@ -0,0 +1,57 @@
+//= require vue
+//= require vue-resource
+//= require Sortable
+//= require_tree ./models
+//= require_tree ./stores
+//= require_tree ./services
+//= require_tree ./mixins
+//= require ./components/board
+//= require ./components/new_list_dropdown
+//= require ./vue_resource_interceptor
+
+$(() => {
+ const $boardApp = document.getElementById('board-app'),
+ Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+
+ if (gl.IssueBoardsApp) {
+ gl.IssueBoardsApp.$destroy(true);
+ }
+
+ gl.IssueBoardsApp = new Vue({
+ el: $boardApp,
+ components: {
+ 'board': gl.issueBoards.Board
+ },
+ data: {
+ state: Store.state,
+ loading: true,
+ endpoint: $boardApp.dataset.endpoint,
+ disabled: $boardApp.dataset.disabled === 'true',
+ issueLinkBase: $boardApp.dataset.issueLinkBase
+ },
+ init: Store.create.bind(Store),
+ created () {
+ gl.boardService = new BoardService(this.endpoint);
+ },
+ ready () {
+ Store.disabled = this.disabled;
+ gl.boardService.all()
+ .then((resp) => {
+ resp.json().forEach((board) => {
+ const list = Store.addList(board);
+
+ if (list.type === 'done') {
+ list.position = Infinity;
+ } else if (list.type === 'backlog') {
+ list.position = -1;
+ }
+ });
+
+ Store.addBlankState();
+ this.loading = false;
+ });
+ }
+ });
+});
diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6
new file mode 100644
index 00000000000..e17784e7948
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board.js.es6
@@ -0,0 +1,85 @@
+//= require ./board_blank_state
+//= require ./board_delete
+//= require ./board_list
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.Board = Vue.extend({
+ components: {
+ 'board-list': gl.issueBoards.BoardList,
+ 'board-delete': gl.issueBoards.BoardDelete,
+ 'board-blank-state': gl.issueBoards.BoardBlankState
+ },
+ props: {
+ list: Object,
+ disabled: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ query: '',
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ query () {
+ this.list.filters = this.getFilterData();
+ this.list.getIssues(true);
+ },
+ filters: {
+ handler () {
+ this.list.page = 1;
+ this.list.getIssues(true);
+ },
+ deep: true
+ }
+ },
+ methods: {
+ getFilterData () {
+ const filters = this.filters;
+ let queryData = { search: this.query };
+
+ Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; });
+
+ return queryData;
+ }
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ disabled: this.disabled,
+ group: 'boards',
+ draggable: '.is-draggable',
+ handle: '.js-board-handle',
+ onEnd: (e) => {
+ document.body.classList.remove('is-dragging');
+
+ if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
+ const order = this.sortable.toArray(),
+ $board = this.$parent.$refs.board[e.oldIndex + 1],
+ list = $board.list;
+
+ $board.$destroy(true);
+
+ this.$nextTick(() => {
+ Store.state.lists.splice(e.newIndex, 0, list);
+ Store.moveList(list, order);
+ });
+ }
+ }
+ });
+
+ if (bp.getBreakpointSize() === 'xs') {
+ options.handle = '.js-board-drag-handle';
+ }
+
+ this.sortable = Sortable.create(this.$el.parentNode, options);
+ },
+ beforeDestroy () {
+ Store.state.lists.$remove(this.list);
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6
new file mode 100644
index 00000000000..63d72d857d9
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6
@@ -0,0 +1,49 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardBlankState = Vue.extend({
+ data () {
+ return {
+ predefinedLabels: [
+ new ListLabel({ title: 'Development', color: '#5CB85C' }),
+ new ListLabel({ title: 'Testing', color: '#F0AD4E' }),
+ new ListLabel({ title: 'Production', color: '#FF5F00' }),
+ new ListLabel({ title: 'Ready', color: '#FF0000' })
+ ]
+ }
+ },
+ methods: {
+ addDefaultLists () {
+ this.clearBlankState();
+
+ this.predefinedLabels.forEach((label, i) => {
+ Store.addList({
+ title: label.title,
+ position: i,
+ list_type: 'label',
+ label: {
+ title: label.title,
+ color: label.color
+ }
+ });
+ });
+
+ // Save the labels
+ gl.boardService.generateDefaultLists()
+ .then((resp) => {
+ resp.json().forEach((listObj) => {
+ const list = Store.findList('title', listObj.title);
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ list.getIssues();
+ });
+ });
+ },
+ clearBlankState: Store.removeBlankState.bind(Store)
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6
new file mode 100644
index 00000000000..4a7cfeaeab2
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card.js.es6
@@ -0,0 +1,43 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardCard = Vue.extend({
+ props: {
+ list: Object,
+ issue: Object,
+ issueLinkBase: String,
+ disabled: Boolean,
+ index: Number
+ },
+ methods: {
+ filterByLabel (label, e) {
+ let labelToggleText = label.title;
+ const labelIndex = Store.state.filters['label_name'].indexOf(label.title);
+ $(e.target).tooltip('hide');
+
+ if (labelIndex === -1) {
+ Store.state.filters['label_name'].push(label.title);
+ $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`);
+ } else {
+ Store.state.filters['label_name'].splice(labelIndex, 1);
+ labelToggleText = Store.state.filters['label_name'][0];
+ $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove();
+ }
+
+ const selectedLabels = Store.state.filters['label_name'];
+ if (selectedLabels.length === 0) {
+ labelToggleText = 'Label';
+ } else if (selectedLabels.length > 1) {
+ labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`;
+ }
+
+ $('.labels-filter .dropdown-toggle-text').text(labelToggleText);
+
+ Store.updateFiltersUrl();
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6
new file mode 100644
index 00000000000..34653cd48ef
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_delete.js.es6
@@ -0,0 +1,19 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardDelete = Vue.extend({
+ props: {
+ list: Object
+ },
+ methods: {
+ deleteBoard () {
+ $(this.$el).tooltip('hide');
+
+ if (confirm('Are you sure you want to delete this list?')) {
+ this.list.destroy();
+ }
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6
new file mode 100644
index 00000000000..1503d14c508
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.js.es6
@@ -0,0 +1,89 @@
+//= require ./board_card
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardList = Vue.extend({
+ components: {
+ 'board-card': gl.issueBoards.BoardCard
+ },
+ props: {
+ disabled: Boolean,
+ list: Object,
+ issues: Array,
+ loading: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ scrollOffset: 250,
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ filters: {
+ handler () {
+ this.list.loadingMore = false;
+ this.$els.list.scrollTop = 0;
+ },
+ deep: true
+ }
+ },
+ methods: {
+ listHeight () {
+ return this.$els.list.getBoundingClientRect().height;
+ },
+ scrollHeight () {
+ return this.$els.list.scrollHeight;
+ },
+ scrollTop () {
+ return this.$els.list.scrollTop + this.listHeight();
+ },
+ loadNextPage () {
+ const getIssues = this.list.nextPage();
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues.then(() => {
+ this.list.loadingMore = false;
+ });
+ }
+ },
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ group: 'issues',
+ sort: false,
+ disabled: this.disabled,
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ Store.moving.issue = card.issue;
+ Store.moving.list = card.list;
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
+ },
+ onRemove: (e) => {
+ this.$refs.issue[e.oldIndex].$destroy(true);
+ }
+ });
+
+ if (bp.getBreakpointSize() === 'xs') {
+ options.handle = '.js-card-drag-handle';
+ }
+
+ this.sortable = Sortable.create(this.$els.list, options);
+
+ // Scroll event on list to load more
+ this.$els.list.onscroll = () => {
+ if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
+ this.loadNextPage();
+ }
+ };
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
new file mode 100644
index 00000000000..1a4d8157970
--- /dev/null
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
@@ -0,0 +1,54 @@
+$(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ $('.js-new-board-list').each(function () {
+ const $this = $(this);
+
+ new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id'));
+
+ $this.glDropdown({
+ data(term, callback) {
+ $.get($this.attr('data-labels'))
+ .then((resp) => {
+ callback(resp);
+ });
+ },
+ renderRow (label) {
+ const active = Store.findList('title', label.title),
+ $li = $('<li />'),
+ $a = $('<a />', {
+ class: (active ? `is-active js-board-list-${active.id}` : ''),
+ text: label.title,
+ href: '#'
+ }),
+ $labelColor = $('<span />', {
+ class: 'dropdown-label-box',
+ style: `background-color: ${label.color}`
+ });
+
+ return $li.append($a.prepend($labelColor));
+ },
+ search: {
+ fields: ['title']
+ },
+ filterable: true,
+ selectable: true,
+ clicked (label, $el, e) {
+ e.preventDefault();
+
+ if (!Store.findList('title', label.title)) {
+ Store.new({
+ title: label.title,
+ position: Store.state.lists.length - 2,
+ list_type: 'label',
+ label: {
+ id: label.id,
+ title: label.title,
+ color: label.color
+ }
+ });
+ }
+ }
+ });
+ });
+});
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
new file mode 100644
index 00000000000..b7afe4897b6
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
@@ -0,0 +1,25 @@
+((w) => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
+ let defaultSortOptions = {
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+ filter: '.has-tooltip',
+ scrollSensitivity: 100,
+ scrollSpeed: 20,
+ onStart () {
+ document.body.classList.add('is-dragging');
+ },
+ onEnd () {
+ document.body.classList.remove('is-dragging');
+ }
+ }
+
+ Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
+ return defaultSortOptions;
+ };
+})(window);
diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6
new file mode 100644
index 00000000000..eb082103de9
--- /dev/null
+++ b/app/assets/javascripts/boards/models/issue.js.es6
@@ -0,0 +1,44 @@
+class ListIssue {
+ constructor (obj) {
+ this.id = obj.iid;
+ this.title = obj.title;
+ this.confidential = obj.confidential;
+ this.labels = [];
+
+ if (obj.assignee) {
+ this.assignee = new ListUser(obj.assignee);
+ }
+
+ obj.labels.forEach((label) => {
+ this.labels.push(new ListLabel(label));
+ });
+
+ this.priority = this.labels.reduce((max, label) => {
+ return (label.priority < max) ? label.priority : max;
+ }, Infinity);
+ }
+
+ addLabel (label) {
+ if (!this.findLabel(label)) {
+ this.labels.push(new ListLabel(label));
+ }
+ }
+
+ findLabel (findLabel) {
+ return this.labels.filter( label => label.title === findLabel.title )[0];
+ }
+
+ removeLabel (removeLabel) {
+ if (removeLabel) {
+ this.labels = this.labels.filter( label => removeLabel.title !== label.title );
+ }
+ }
+
+ removeLabels (labels) {
+ labels.forEach(this.removeLabel.bind(this));
+ }
+
+ getLists () {
+ return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
+ }
+}
diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6
new file mode 100644
index 00000000000..e81e91fe972
--- /dev/null
+++ b/app/assets/javascripts/boards/models/label.js.es6
@@ -0,0 +1,9 @@
+class ListLabel {
+ constructor (obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.color = obj.color;
+ this.description = obj.description;
+ this.priority = (obj.priority !== null) ? obj.priority : Infinity;
+ }
+}
diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6
new file mode 100644
index 00000000000..be2b8c568a8
--- /dev/null
+++ b/app/assets/javascripts/boards/models/list.js.es6
@@ -0,0 +1,125 @@
+class List {
+ constructor (obj) {
+ this.id = obj.id;
+ this._uid = this.guid();
+ this.position = obj.position;
+ this.title = obj.title;
+ this.type = obj.list_type;
+ this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1;
+ this.filters = gl.issueBoards.BoardsStore.state.filters;
+ this.page = 1;
+ this.loading = true;
+ this.loadingMore = false;
+ this.issues = [];
+
+ if (obj.label) {
+ this.label = new ListLabel(obj.label);
+ }
+
+ if (this.type !== 'blank' && this.id) {
+ this.getIssues();
+ }
+ }
+
+ guid() {
+ const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
+ return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
+ }
+
+ save () {
+ return gl.boardService.createList(this.label.id)
+ .then((resp) => {
+ const data = resp.json();
+
+ this.id = data.id;
+ this.type = data.list_type;
+ this.position = data.position;
+
+ return this.getIssues();
+ });
+ }
+
+ destroy () {
+ gl.issueBoards.BoardsStore.state.lists.$remove(this);
+ gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
+
+ gl.boardService.destroyList(this.id);
+ }
+
+ update () {
+ gl.boardService.updateList(this.id, this.position);
+ }
+
+ nextPage () {
+ if (Math.floor(this.issues.length / 20) === this.page) {
+ this.page++;
+
+ return this.getIssues(false);
+ }
+ }
+
+ canSearch () {
+ return this.type === 'backlog';
+ }
+
+ getIssues (emptyIssues = true) {
+ const filters = this.filters;
+ let data = { page: this.page };
+
+ Object.keys(filters).forEach((key) => { data[key] = filters[key]; });
+
+ if (this.label) {
+ data.label_name = data.label_name.filter( label => label !== this.label.title );
+ }
+
+ if (emptyIssues) {
+ this.loading = true;
+ }
+
+ return gl.boardService.getIssuesForList(this.id, data)
+ .then((resp) => {
+ const data = resp.json();
+ this.loading = false;
+
+ if (emptyIssues) {
+ this.issues = [];
+ }
+
+ this.createIssues(data);
+ });
+ }
+
+ createIssues (data) {
+ data.forEach((issueObj) => {
+ this.addIssue(new ListIssue(issueObj));
+ });
+ }
+
+ addIssue (issue, listFrom) {
+ this.issues.push(issue);
+
+ if (this.label) {
+ issue.addLabel(this.label);
+ }
+
+ if (listFrom) {
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id);
+ }
+ }
+
+ findIssue (id) {
+ return this.issues.filter( issue => issue.id === id )[0];
+ }
+
+ removeIssue (removeIssue) {
+ this.issues = this.issues.filter((issue) => {
+ const matchesRemove = removeIssue.id === issue.id;
+
+ if (matchesRemove) {
+ issue.removeLabel(this.label);
+ }
+
+ return !matchesRemove;
+ });
+ }
+}
diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6
new file mode 100644
index 00000000000..904b3a68507
--- /dev/null
+++ b/app/assets/javascripts/boards/models/user.js.es6
@@ -0,0 +1,8 @@
+class ListUser {
+ constructor (user) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url;
+ }
+}
diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6
new file mode 100644
index 00000000000..9b80fb2e99f
--- /dev/null
+++ b/app/assets/javascripts/boards/services/board_service.js.es6
@@ -0,0 +1,61 @@
+class BoardService {
+ constructor (root) {
+ Vue.http.options.root = root;
+
+ this.lists = Vue.resource(`${root}/lists{/id}`, {}, {
+ generate: {
+ method: 'POST',
+ url: `${root}/lists/generate.json`
+ }
+ });
+ this.issue = Vue.resource(`${root}/issues{/id}`, {});
+ this.issues = Vue.resource(`${root}/lists{/id}/issues`, {});
+
+ Vue.http.interceptors.push((request, next) => {
+ request.headers['X-CSRF-Token'] = $.rails.csrfToken();
+ next();
+ });
+ }
+
+ all () {
+ return this.lists.get();
+ }
+
+ generateDefaultLists () {
+ return this.lists.generate({});
+ }
+
+ createList (label_id) {
+ return this.lists.save({}, {
+ list: {
+ label_id
+ }
+ });
+ }
+
+ updateList (id, position) {
+ return this.lists.update({ id }, {
+ list: {
+ position
+ }
+ });
+ }
+
+ destroyList (id) {
+ return this.lists.delete({ id });
+ }
+
+ getIssuesForList (id, filter = {}) {
+ let data = { id };
+ Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
+
+ return this.issues.get(data);
+ }
+
+ moveIssue (id, from_list_id, to_list_id) {
+ return this.issue.update({ id }, {
+ from_list_id,
+ to_list_id
+ });
+ }
+};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6
new file mode 100644
index 00000000000..18f26a1f911
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/boards_store.js.es6
@@ -0,0 +1,112 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardsStore = {
+ disabled: false,
+ state: {},
+ moving: {
+ issue: {},
+ list: {}
+ },
+ create () {
+ this.state.lists = [];
+ this.state.filters = {
+ author_id: gl.utils.getParameterValues('author_id')[0],
+ assignee_id: gl.utils.getParameterValues('assignee_id')[0],
+ milestone_title: gl.utils.getParameterValues('milestone_title')[0],
+ label_name: gl.utils.getParameterValues('label_name[]')
+ };
+ },
+ addList (listObj) {
+ const list = new List(listObj);
+ this.state.lists.push(list);
+
+ return list;
+ },
+ new (listObj) {
+ const list = this.addList(listObj),
+ backlogList = this.findList('type', 'backlog', 'backlog');
+
+ list
+ .save()
+ .then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
+ });
+ this.removeBlankState();
+ },
+ updateNewListDropdown (listId) {
+ $(`.js-board-list-${listId}`).removeClass('is-active');
+ },
+ shouldAddBlankState () {
+ // Decide whether to add the blank state
+ return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]);
+ },
+ addBlankState () {
+ if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
+
+ this.addList({
+ id: 'blank',
+ list_type: 'blank',
+ title: 'Welcome to your Issue Board!',
+ position: 0
+ });
+ },
+ removeBlankState () {
+ this.removeList('blank');
+
+ $.cookie('issue_board_welcome_hidden', 'true', {
+ expires: 365 * 10
+ });
+ },
+ welcomeIsHidden () {
+ return $.cookie('issue_board_welcome_hidden') === 'true';
+ },
+ removeList (id, type = 'blank') {
+ const list = this.findList('id', id, type);
+
+ if (!list) return;
+
+ this.state.lists = this.state.lists.filter( list => list.id !== id );
+ },
+ moveList (listFrom, orderLists) {
+ orderLists.forEach((id, i) => {
+ const list = this.findList('id', parseInt(id));
+
+ list.position = i;
+ });
+ listFrom.update();
+ },
+ moveIssueToList (listFrom, listTo, issue) {
+ const issueTo = listTo.findIssue(issue.id),
+ issueLists = issue.getLists(),
+ listLabels = issueLists.map( listIssue => listIssue.label );
+
+ // Add to new lists issues if it doesn't already exist
+ if (!issueTo) {
+ listTo.addIssue(issue, listFrom);
+ }
+
+ if (listTo.type === 'done' && listFrom.type !== 'backlog') {
+ issueLists.forEach((list) => {
+ list.removeIssue(issue);
+ })
+ issue.removeLabels(listLabels);
+ } else {
+ listFrom.removeIssue(issue);
+ }
+ },
+ findList (key, val, type = 'label') {
+ return this.state.lists.filter((list) => {
+ const byType = type ? list['type'] === type : true;
+
+ return list[key] === val && byType;
+ })[0];
+ },
+ updateFiltersUrl () {
+ history.pushState(null, null, `?${$.param(this.state.filters)}`);
+ }
+ };
+})();
diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js
new file mode 100755
index 00000000000..75f8b730195
--- /dev/null
+++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js
@@ -0,0 +1,119 @@
+(function () {
+ 'use strict';
+
+ function simulateEvent(el, type, options) {
+ var event;
+ if (!el) return;
+ var ownerDocument = el.ownerDocument;
+
+ options = options || {};
+
+ if (/^mouse/.test(type)) {
+ event = ownerDocument.createEvent('MouseEvents');
+ event.initMouseEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+ } else {
+ event = ownerDocument.createEvent('CustomEvent');
+
+ event.initCustomEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+
+ event.dataTransfer = {
+ data: {},
+
+ setData: function (type, val) {
+ this.data[type] = val;
+ },
+
+ getData: function (type) {
+ return this.data[type];
+ }
+ };
+ }
+
+ if (el.dispatchEvent) {
+ el.dispatchEvent(event);
+ } else if (el.fireEvent) {
+ el.fireEvent('on' + type, event);
+ }
+
+ return event;
+ }
+
+ function getTraget(target) {
+ var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
+ var children = el.children;
+
+ return (
+ children[target.index] ||
+ children[target.index === 'first' ? 0 : -1] ||
+ children[target.index === 'last' ? children.length - 1 : -1]
+ );
+ }
+
+ function getRect(el) {
+ var rect = el.getBoundingClientRect();
+ var width = rect.right - rect.left;
+ var height = rect.bottom - rect.top;
+
+ return {
+ x: rect.left,
+ y: rect.top,
+ cx: rect.left + width / 2,
+ cy: rect.top + height / 2,
+ w: width,
+ h: height,
+ hw: width / 2,
+ wh: height / 2
+ };
+ }
+
+ function simulateDrag(options, callback) {
+ options.to.el = options.to.el || options.from.el;
+
+ var fromEl = getTraget(options.from);
+ var toEl = getTraget(options.to);
+ var scrollable = options.scrollable;
+
+ var fromRect = getRect(fromEl);
+ var toRect = getRect(toEl);
+
+ var startTime = new Date().getTime();
+ var duration = options.duration || 1000;
+ simulateEvent(fromEl, 'mousedown', {button: 0});
+ options.ontap && options.ontap();
+ window.SIMULATE_DRAG_ACTIVE = 1;
+
+ var dragInterval = setInterval(function loop() {
+ var progress = (new Date().getTime() - startTime) / duration;
+ var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
+ var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop;
+ var overEl = fromEl.ownerDocument.elementFromPoint(x, y);
+
+ simulateEvent(overEl, 'mousemove', {
+ clientX: x,
+ clientY: y
+ });
+
+ if (progress >= 1) {
+ options.ondragend && options.ondragend();
+ simulateEvent(toEl, 'mouseup');
+ clearInterval(dragInterval);
+ window.SIMULATE_DRAG_ACTIVE = 0;
+ }
+ }, 100);
+
+ return {
+ target: fromEl,
+ fromList: fromEl.parentNode,
+ toList: toEl.parentNode
+ };
+ }
+
+
+ // Export
+ window.simulateEvent = simulateEvent;
+ window.simulateDrag = simulateDrag;
+})();
diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
new file mode 100644
index 00000000000..66f645a4b61
--- /dev/null
+++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
@@ -0,0 +1,8 @@
+Vue.http.interceptors.push((request, next) => {
+ Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
+
+ setTimeout(() => {
+ Vue.activeResources--;
+ }, 500);
+ next();
+});
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
index c82798cc6a5..c43af17442b 100644
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ b/app/assets/javascripts/copy_to_clipboard.js
@@ -34,6 +34,7 @@
$(function() {
var clipboard;
+
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError);
diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6
new file mode 100644
index 00000000000..46d1c3f00c1
--- /dev/null
+++ b/app/assets/javascripts/create_label.js.es6
@@ -0,0 +1,126 @@
+(function (w) {
+ class CreateLabelDropdown {
+ constructor ($el, projectId) {
+ this.$el = $el;
+ this.projectId = projectId;
+ this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
+ this.$cancelButton = $('.js-cancel-label-btn', this.$el);
+ this.$newLabelField = $('#new_label_name', this.$el);
+ this.$newColorField = $('#new_label_color', this.$el);
+ this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$newLabelError = $('.js-label-error', this.$el);
+ this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
+ this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
+
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.disable();
+
+ this.cleanBinding();
+ this.addBinding();
+ }
+
+ cleanBinding () {
+ this.$colorSuggestions.off('click');
+ this.$newLabelField.off('keyup change');
+ this.$newColorField.off('keyup change');
+ this.$dropdownBack.off('click');
+ this.$cancelButton.off('click');
+ this.$newLabelCreateButton.off('click');
+ }
+
+ addBinding () {
+ const self = this;
+
+ this.$colorSuggestions.on('click', function (e) {
+ const $this = $(this);
+ self.addColorValue(e, $this);
+ });
+
+ this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$dropdownBack.on('click', this.resetForm.bind(this));
+
+ this.$cancelButton.on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ self.resetForm();
+ self.$dropdownBack.trigger('click');
+ });
+
+ this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
+ }
+
+ addColorValue (e, $this) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$newColorField.val($this.data('color')).trigger('change');
+ this.$colorPreview
+ .css('background-color', $this.data('color'))
+ .parent()
+ .addClass('is-active');
+ }
+
+ enableLabelCreateButton () {
+ if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.enable();
+ } else {
+ this.$newLabelCreateButton.disable();
+ }
+ }
+
+ resetForm () {
+ this.$newLabelField
+ .val('')
+ .trigger('change');
+
+ this.$newColorField
+ .val('')
+ .trigger('change');
+
+ this.$colorPreview
+ .css('background-color', '')
+ .parent()
+ .removeClass('is-active');
+ }
+
+ saveLabel (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ Api.newLabel(this.projectId, {
+ name: this.$newLabelField.val(),
+ color: this.$newColorField.val()
+ }, (label) => {
+ this.$newLabelCreateButton.enable();
+
+ if (label.message) {
+ let errors;
+
+ if (typeof label.message === 'string') {
+ errors = label.message;
+ } else {
+ errors = label.message.map(function (value, key) {
+ return key + " " + value[0];
+ }).join("<br/>");
+ }
+
+ this.$newLabelError
+ .html(errors)
+ .show();
+ } else {
+ this.$dropdownBack.trigger('click');
+ }
+ });
+ }
+ }
+
+ if (!w.gl) {
+ w.gl = {};
+ }
+
+ gl.CreateLabelDropdown = CreateLabelDropdown;
+})(window);
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 7160fa71ce5..1163edd8547 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -88,6 +88,8 @@
new ZenMode();
new MergedButtons();
break;
+ case "projects:merge_requests:conflicts":
+ window.mcui = new MergeConflictResolver()
case 'projects:merge_requests:index':
shortcut_handler = new ShortcutsNavigation();
Issuable.init();
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 1bb0b67d0e8..0526430989f 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,7 +4,7 @@
var _this;
_this = this;
$('.js-label-select').each(function(i, dropdown) {
- var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo;
+ var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
labelUrl = $dropdown.data('labels');
@@ -13,8 +13,6 @@
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
- newLabelField = $('#new_label_name');
- newColorField = $('#new_label_color');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
defaultLabel = $dropdown.data('default-label');
@@ -24,10 +22,6 @@
$form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$value = $block.find('.value');
- $newLabelError = $('.js-label-error');
- $colorPreview = $('.js-dropdown-label-color-preview');
- $newLabelCreateButton = $('.js-new-label-btn');
- $newLabelError.hide();
$loading = $block.find('.block-loading').fadeOut();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
@@ -36,62 +30,9 @@
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
- if (newLabelField.length) {
- $('.suggest-colors-dropdown a').on("click", function(e) {
- e.preventDefault();
- e.stopPropagation();
- newColorField.val($(this).data('color')).trigger('change');
- return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active');
- });
- resetForm = function() {
- newLabelField.val('').trigger('change');
- newColorField.val('').trigger('change');
- return $colorPreview.css('background-color', '').parent().removeClass('is-active');
- };
- $('.dropdown-menu-back').on('click', function() {
- return resetForm();
- });
- $('.js-cancel-label-btn').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- resetForm();
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- });
- enableLabelCreateButton = function() {
- if (newLabelField.val() !== '' && newColorField.val() !== '') {
- $newLabelError.hide();
- return $newLabelCreateButton.enable();
- } else {
- return $newLabelCreateButton.disable();
- }
- };
- saveLabel = function() {
- return Api.newLabel(projectId, {
- name: newLabelField.val(),
- color: newColorField.val()
- }, function(label) {
- $newLabelCreateButton.enable();
- if (label.message != null) {
- var errorText = label.message;
- if (_.isObject(label.message)) {
- errorText = _.map(label.message, function(value, key) {
- return key + " " + value[0];
- }).join('<br/>');
- }
- return $newLabelError.html(errorText).show();
- } else {
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- }
- });
- };
- newLabelField.on('keyup change', enableLabelCreateButton);
- newColorField.on('keyup change', enableLabelCreateButton);
- $newLabelCreateButton.disable().on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- return saveLabel();
- });
- }
+
+ new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
+
saveLabelData = function() {
var data, selected;
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
@@ -272,6 +213,9 @@
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
$value.removeAttr('style');
+ if (page === 'projects:boards:show') {
+ return;
+ }
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
@@ -291,7 +235,7 @@
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- clicked: function(label) {
+ clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
if ($dropdown.hasClass('js-filter-bulk-update')) {
@@ -300,7 +244,23 @@
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ if (label.isAny) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
+ } else if (label.title) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
+ } else {
+ var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
+ filters = filters.filter(function (label) {
+ return label !== $el.text().trim();
+ });
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
+ }
+
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ return;
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
diff --git a/app/assets/javascripts/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6
new file mode 100644
index 00000000000..cd92df8ddc5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6
@@ -0,0 +1,341 @@
+const HEAD_HEADER_TEXT = 'HEAD//our changes';
+const ORIGIN_HEADER_TEXT = 'origin//their changes';
+const HEAD_BUTTON_TITLE = 'Use ours';
+const ORIGIN_BUTTON_TITLE = 'Use theirs';
+
+
+class MergeConflictDataProvider {
+
+ getInitialData() {
+ const diffViewType = $.cookie('diff_view');
+
+ return {
+ isLoading : true,
+ hasError : false,
+ isParallel : diffViewType === 'parallel',
+ diffViewType : diffViewType,
+ isSubmitting : false,
+ conflictsData : {},
+ resolutionData : {}
+ }
+ }
+
+
+ decorateData(vueInstance, data) {
+ this.vueInstance = vueInstance;
+
+ if (data.type === 'error') {
+ vueInstance.hasError = true;
+ data.errorMessage = data.message;
+ }
+ else {
+ data.shortCommitSha = data.commit_sha.slice(0, 7);
+ data.commitMessage = data.commit_message;
+
+ this.setParallelLines(data);
+ this.setInlineLines(data);
+ this.updateResolutionsData(data);
+ }
+
+ vueInstance.conflictsData = data;
+ vueInstance.isSubmitting = false;
+
+ const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict';
+ vueInstance.conflictsData.conflictsText = conflictsText;
+ }
+
+
+ updateResolutionsData(data) {
+ const vi = this.vueInstance;
+
+ data.files.forEach( (file) => {
+ file.sections.forEach( (section) => {
+ if (section.conflict) {
+ vi.$set(`resolutionData['${section.id}']`, false);
+ }
+ });
+ });
+ }
+
+
+ setParallelLines(data) {
+ data.files.forEach( (file) => {
+ file.filePath = this.getFilePath(file);
+ file.iconClass = `fa-${file.blob_icon}`;
+ file.blobPath = file.blob_path;
+ file.parallelLines = [];
+ const linesObj = { left: [], right: [] };
+
+ file.sections.forEach( (section) => {
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ linesObj.left.push(this.getOriginHeaderLine(id));
+ linesObj.right.push(this.getHeadHeaderLine(id));
+ }
+
+ lines.forEach( (line) => {
+ const { type } = line;
+
+ if (conflict) {
+ if (type === 'old') {
+ linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
+ }
+ else if (type === 'new') {
+ linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
+ }
+ }
+ else {
+ const lineType = type || 'context';
+
+ linesObj.left.push (this.getLineForParallelView(line, id, lineType));
+ linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
+ }
+ });
+
+ this.checkLineLengths(linesObj);
+ });
+
+ for (let i = 0, len = linesObj.left.length; i < len; i++) {
+ file.parallelLines.push([
+ linesObj.right[i],
+ linesObj.left[i]
+ ]);
+ }
+
+ });
+ }
+
+
+ checkLineLengths(linesObj) {
+ let { left, right } = linesObj;
+
+ if (left.length !== right.length) {
+ if (left.length > right.length) {
+ const diff = left.length - right.length;
+ for (let i = 0; i < diff; i++) {
+ right.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ else {
+ const diff = right.length - left.length;
+ for (let i = 0; i < diff; i++) {
+ left.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ }
+ }
+
+
+ setInlineLines(data) {
+ data.files.forEach( (file) => {
+ file.iconClass = `fa-${file.blob_icon}`;
+ file.blobPath = file.blob_path;
+ file.filePath = this.getFilePath(file);
+ file.inlineLines = []
+
+ file.sections.forEach( (section) => {
+ let currentLineType = 'new';
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ file.inlineLines.push(this.getHeadHeaderLine(id));
+ }
+
+ lines.forEach( (line) => {
+ const { type } = line;
+
+ if ((type === 'new' || type === 'old') && currentLineType !== type) {
+ currentLineType = type;
+ file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
+ }
+
+ this.decorateLineForInlineView(line, id, conflict);
+ file.inlineLines.push(line);
+ })
+
+ if (conflict) {
+ file.inlineLines.push(this.getOriginHeaderLine(id));
+ }
+ });
+ });
+ }
+
+
+ handleSelected(sectionId, selection) {
+ const vi = this.vueInstance;
+
+ vi.resolutionData[sectionId] = selection;
+ vi.conflictsData.files.forEach( (file) => {
+ file.inlineLines.forEach( (line) => {
+ if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
+ this.markLine(line, selection);
+ }
+ });
+
+ file.parallelLines.forEach( (lines) => {
+ const left = lines[0];
+ const right = lines[1];
+ const hasSameId = right.id === sectionId || left.id === sectionId;
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (hasSameId && (isLeftMatch || isRightMatch)) {
+ this.markLine(left, selection);
+ this.markLine(right, selection);
+ }
+ })
+ });
+ }
+
+
+ updateViewType(newType) {
+ const vi = this.vueInstance;
+
+ if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) {
+ return;
+ }
+
+ vi.diffView = newType;
+ vi.isParallel = newType === 'parallel';
+ $.cookie('diff_view', newType); // TODO: Make sure that cookie path added.
+ $('.content-wrapper .container-fluid').toggleClass('container-limited');
+ }
+
+
+ markLine(line, selection) {
+ if (selection === 'head' && line.isHead) {
+ line.isSelected = true;
+ line.isUnselected = false;
+ }
+ else if (selection === 'origin' && line.isOrigin) {
+ line.isSelected = true;
+ line.isUnselected = false;
+ }
+ else {
+ line.isSelected = false;
+ line.isUnselected = true;
+ }
+ }
+
+
+ getConflictsCount() {
+ return Object.keys(this.vueInstance.resolutionData).length;
+ }
+
+
+ getResolvedCount() {
+ let count = 0;
+ const data = this.vueInstance.resolutionData;
+
+ for (const id in data) {
+ const resolution = data[id];
+ if (resolution) {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+
+ isReadyToCommit() {
+ const { conflictsData, isSubmitting } = this.vueInstance
+ const allResolved = this.getConflictsCount() === this.getResolvedCount();
+ const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
+
+ return !isSubmitting && hasCommitMessage && allResolved;
+ }
+
+
+ getCommitButtonText() {
+ const initial = 'Commit conflict resolution';
+ const inProgress = 'Committing...';
+ const vue = this.vueInstance;
+
+ return vue ? vue.isSubmitting ? inProgress : initial : initial;
+ }
+
+
+ decorateLineForInlineView(line, id, conflict) {
+ const { type } = line;
+ line.id = id;
+ line.hasConflict = conflict;
+ line.isHead = type === 'new';
+ line.isOrigin = type === 'old';
+ line.hasMatch = type === 'match';
+ line.richText = line.rich_text;
+ line.isSelected = false;
+ line.isUnselected = false;
+ }
+
+ getLineForParallelView(line, id, lineType, isHead) {
+ const { old_line, new_line, rich_text } = line;
+ const hasConflict = lineType === 'conflict';
+
+ return {
+ id,
+ lineType,
+ hasConflict,
+ isHead : hasConflict && isHead,
+ isOrigin : hasConflict && !isHead,
+ hasMatch : lineType === 'match',
+ lineNumber : isHead ? new_line : old_line,
+ section : isHead ? 'head' : 'origin',
+ richText : rich_text,
+ isSelected : false,
+ isUnselected : false
+ }
+ }
+
+
+ getHeadHeaderLine(id) {
+ return {
+ id : id,
+ richText : HEAD_HEADER_TEXT,
+ buttonTitle : HEAD_BUTTON_TITLE,
+ type : 'new',
+ section : 'head',
+ isHeader : true,
+ isHead : true,
+ isSelected : false,
+ isUnselected: false
+ }
+ }
+
+
+ getOriginHeaderLine(id) {
+ return {
+ id : id,
+ richText : ORIGIN_HEADER_TEXT,
+ buttonTitle : ORIGIN_BUTTON_TITLE,
+ type : 'old',
+ section : 'origin',
+ isHeader : true,
+ isOrigin : true,
+ isSelected : false,
+ isUnselected: false
+ }
+ }
+
+
+ handleFailedRequest(vueInstance, data) {
+ vueInstance.hasError = true;
+ vueInstance.conflictsData.errorMessage = 'Something went wrong!';
+ }
+
+
+ getCommitData() {
+ return {
+ commit_message: this.vueInstance.conflictsData.commitMessage,
+ sections: this.vueInstance.resolutionData
+ }
+ }
+
+
+ getFilePath(file) {
+ const { old_path, new_path } = file;
+ return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
+ }
+
+}
diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6
new file mode 100644
index 00000000000..77bffbcb403
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_resolver.js.es6
@@ -0,0 +1,85 @@
+//= require vue
+
+class MergeConflictResolver {
+
+ constructor() {
+ this.dataProvider = new MergeConflictDataProvider()
+ this.initVue()
+ }
+
+
+ initVue() {
+ const that = this;
+ this.vue = new Vue({
+ el : '#conflicts',
+ name : 'MergeConflictResolver',
+ data : this.dataProvider.getInitialData(),
+ created : this.fetchData(),
+ computed : this.setComputedProperties(),
+ methods : {
+ handleSelected(sectionId, selection) {
+ that.dataProvider.handleSelected(sectionId, selection);
+ },
+ handleViewTypeChange(newType) {
+ that.dataProvider.updateViewType(newType);
+ },
+ commit() {
+ that.commit();
+ }
+ }
+ })
+ }
+
+
+ setComputedProperties() {
+ const dp = this.dataProvider;
+
+ return {
+ conflictsCount() { return dp.getConflictsCount() },
+ resolvedCount() { return dp.getResolvedCount() },
+ readyToCommit() { return dp.isReadyToCommit() },
+ commitButtonText() { return dp.getCommitButtonText() }
+ }
+ }
+
+
+ fetchData() {
+ const dp = this.dataProvider;
+
+ $.get($('#conflicts').data('conflictsPath'))
+ .done((data) => {
+ dp.decorateData(this.vue, data);
+ })
+ .error((data) => {
+ dp.handleFailedRequest(this.vue, data);
+ })
+ .always(() => {
+ this.vue.isLoading = false;
+
+ this.vue.$nextTick(() => {
+ $('#conflicts .js-syntax-highlight').syntaxHighlight();
+ });
+
+ if (this.vue.diffViewType === 'parallel') {
+ $('.content-wrapper .container-fluid').removeClass('container-limited');
+ }
+ })
+ }
+
+
+ commit() {
+ this.vue.isSubmitting = true;
+
+ $.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
+ .done((data) => {
+ window.location.href = data.redirect_to;
+ })
+ .error(() => {
+ new Flash('Something went wrong!');
+ })
+ .always(() => {
+ this.vue.isSubmitting = false;
+ });
+ }
+
+}
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 75aa6c7a491..ab9d2367fc7 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -9,6 +9,8 @@
MergeRequestTabs.prototype.buildsLoaded = false;
+ MergeRequestTabs.prototype.pipelinesLoaded = false;
+
MergeRequestTabs.prototype.commitsLoaded = false;
function MergeRequestTabs(opts) {
@@ -50,6 +52,9 @@
} else if (action === 'builds') {
this.loadBuilds($target.attr('href'));
this.expandView();
+ } else if (action === 'pipelines') {
+ this.loadPipelines($target.attr('href'));
+ this.expandView();
} else {
this.expandView();
}
@@ -82,7 +87,7 @@
action = 'notes';
}
this.currentAction = action;
- new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, '');
+ new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
if (action !== 'notes') {
new_state += "/" + action;
}
@@ -183,6 +188,21 @@
});
};
+ MergeRequestTabs.prototype.loadPipelines = function(source) {
+ if (this.pipelinesLoaded) {
+ return;
+ }
+ return this._get({
+ url: source + ".json",
+ success: function(data) {
+ $('#pipelines').html(data.html);
+ gl.utils.localTimeAgo($('.js-timeago', '#pipelines'));
+ this.pipelinesLoaded = true;
+ return this.scrollToElement("#pipelines");
+ }.bind(this)
+ });
+ };
+
MergeRequestTabs.prototype.toggleLoading = function(status) {
return $('.mr-loading-status .loading').toggle(status);
};
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
index 362aaa906d0..bd35b6f679d 100644
--- a/app/assets/javascripts/merge_request_widget.js
+++ b/app/assets/javascripts/merge_request_widget.js
@@ -28,7 +28,7 @@
MergeRequestWidget.prototype.addEventListeners = function() {
var allowedPages;
- allowedPages = ['show', 'commits', 'builds', 'changes'];
+ allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes'];
return $(document).on('page:change.merge_request', (function(_this) {
return function() {
var page;
@@ -53,7 +53,7 @@
return function(data) {
var callback, urlSuffix;
if (data.state === "merged") {
- urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
+ urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) {
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index a0b65d20c03..e897ebdf630 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -94,7 +94,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(selected) {
+ clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -102,7 +102,11 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (selected.name != null) {
selectedMilestone = selected.name;
} else {
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 65d362e072c..bad82868ab0 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -141,7 +141,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(user) {
+ clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -149,7 +149,12 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ selectedId = user.id;
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 5ec5a96a597..d2d60ed7196 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -123,4 +123,9 @@
}
}
}
-} \ No newline at end of file
+}
+
+@mixin dark-diff-match-line {
+ color: rgba(255, 255, 255, 0.3);
+ background: rgba(255, 255, 255, 0.1);
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ca720022539..5da390118c6 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
$ci-output-bg: #1d1f21;
$ci-text-color: #c5c8c6;
+
+$issue-boards-font-size: 15px;
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 77a73dc379b..16ffbe57a99 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #557;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index 80a509a7c1a..7de920e074b 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #49483e;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index c62bd021aef..b11499c71ee 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #174652;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index 524cfaf90c3..657bb5e3cd9 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -1,4 +1,10 @@
/* https://gist.github.com/qguv/7936275 */
+
+@mixin matchLine {
+ color: $black-transparent;
+ background: rgba(255, 255, 255, 0.4);
+}
+
.code.solarized-light {
// Line numbers
.line-numbers, .diff-line-num {
@@ -21,6 +27,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include matchLine;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #ddd8c5;
@@ -36,8 +46,7 @@
}
.line_content.match {
- color: $black-transparent;
- background: rgba(255, 255, 255, 0.4);
+ @include matchLine;
}
}
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index 31a4e3deaac..36a80a916b2 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -1,4 +1,10 @@
/* https://github.com/aahan/pygments-github-style */
+
+@mixin matchLine {
+ color: $black-transparent;
+ background-color: $match-line;
+}
+
.code.white {
// Line numbers
.line-numbers, .diff-line-num {
@@ -22,6 +28,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include matchLine;
+ }
+
.diff-line-num {
&.old {
background-color: $line-number-old;
@@ -57,8 +67,7 @@
}
&.match {
- color: $black-transparent;
- background-color: $match-line;
+ @include matchLine;
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
new file mode 100644
index 00000000000..ad4b2d6496f
--- /dev/null
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -0,0 +1,329 @@
+[v-cloak] {
+ display: none;
+}
+
+.user-can-drag {
+ cursor: -webkit-grab;
+ cursor: grab;
+}
+
+.is-dragging {
+ * {
+ cursor: -webkit-grabbing;
+ cursor: grabbing;
+ }
+}
+
+.dropdown-menu-issues-board-new {
+ width: 320px;
+
+ .dropdown-content {
+ max-height: 150px;
+ }
+}
+
+.issue-board-dropdown-content {
+ margin: 0 8px 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid $dropdown-divider-color;
+
+ > p {
+ margin: 0;
+ font-size: 14px;
+ color: #9c9c9c;
+ }
+}
+
+.issue-boards-page {
+ .content-wrapper {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ }
+
+ .sub-nav,
+ .issues-filters {
+ -webkit-flex: none;
+ flex: none;
+ }
+
+ .page-with-sidebar {
+ display: -webkit-flex;
+ display: flex;
+ min-height: 100vh;
+ max-height: 100vh;
+ padding-bottom: 0;
+ }
+
+ .issue-boards-content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ width: 100%;
+
+ .content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ }
+ }
+}
+
+.boards-app-loading {
+ width: 100%;
+ font-size: 34px;
+}
+
+.boards-list {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ -webkit-flex-basis: 0;
+ flex-basis: 0;
+ min-height: calc(100vh - 152px);
+ max-height: calc(100vh - 152px);
+ padding-top: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+ overflow-x: scroll;
+
+ @media (min-width: $screen-sm-min) {
+ min-height: 475px;
+ max-height: none;
+ }
+}
+
+.board {
+ display: -webkit-flex;
+ display: flex;
+ min-width: calc(100vw - 15px);
+ max-width: calc(100vw - 15px);
+ margin-bottom: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+
+ @media (min-width: $screen-sm-min) {
+ min-width: 400px;
+ max-width: 400px;
+ }
+}
+
+.board-inner {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ font-size: $issue-boards-font-size;
+ background: $background-color;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
+.board-header {
+ border-top-left-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+
+ &.has-border {
+ border-top: 3px solid;
+
+ .board-title {
+ padding-top: ($gl-padding - 3px);
+ }
+ }
+}
+
+.board-header-loading-spinner {
+ margin-right: 10px;
+ color: $gray-darkest;
+}
+
+.board-inner-container {
+ border-bottom: 1px solid $border-color;
+ padding: $gl-padding;
+}
+
+.board-title {
+ position: relative;
+ margin: 0;
+ padding: $gl-padding;
+ font-size: 1em;
+ border-bottom: 1px solid $border-color;
+
+ .board-mobile-handle {
+ position: relative;
+ left: 0;
+ top: 1px;
+ margin-top: 0;
+ margin-right: 5px;
+ }
+}
+
+.board-search-container {
+ position: relative;
+ background-color: #fff;
+
+ .form-control {
+ padding-right: 30px;
+ }
+}
+
+.board-search-icon,
+.board-search-clear-btn {
+ position: absolute;
+ right: $gl-padding + 10px;
+ top: 50%;
+ margin-top: -7px;
+ font-size: 14px;
+}
+
+.board-search-icon {
+ color: $gl-placeholder-color;
+}
+
+.board-search-clear-btn {
+ padding: 0;
+ line-height: 1;
+ background: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-delete {
+ margin-right: 10px;
+ padding: 0;
+ color: $gray-darkest;
+ background-color: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-blank-state {
+ height: 100%;
+ padding: $gl-padding;
+ background-color: #fff;
+}
+
+.board-blank-state-list {
+ list-style: none;
+
+ > li:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .label-color {
+ position: relative;
+ top: 2px;
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-right: 3px;
+ border-radius: $border-radius-default;
+ }
+}
+
+.board-list {
+ -webkit-flex: 1;
+ flex: 1;
+ height: 400px;
+ margin-bottom: 0;
+ padding: 5px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+.board-list-loading {
+ margin-top: 10px;
+ font-size: 26px;
+}
+
+.is-ghost {
+ opacity: 0.3;
+}
+
+.is-dragging {
+ // Important because plugin sets inline CSS
+ opacity: 1!important;
+}
+
+.card {
+ position: relative;
+ width: 100%;
+ padding: 10px $gl-padding;
+ background: #fff;
+ border-radius: $border-radius-default;
+ box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5);
+ list-style: none;
+
+ &.user-can-drag {
+ padding-left: ($gl-padding * 2);
+
+ @media (min-width: $screen-sm-min) {
+ padding-left: $gl-padding;
+ }
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 5px;
+ }
+
+ a {
+ cursor: pointer;
+ }
+
+ .label {
+ border: 0;
+ outline: 0;
+ }
+
+ .confidential-icon {
+ margin-right: 5px;
+ }
+}
+
+.board-mobile-handle {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ margin-top: (-15px / 2);
+
+ @media (min-width: $screen-sm-min) {
+ display: none;
+ }
+}
+
+.card-title {
+ margin: 0;
+ font-size: 1em;
+
+ a {
+ color: inherit;
+ }
+}
+
+.card-footer {
+ margin-top: 5px;
+
+ .label {
+ margin-right: 4px;
+ font-size: (14px / $issue-boards-font-size) * 1em;
+ }
+}
+
+.card-number {
+ margin-right: 8px;
+ font-weight: 500;
+}
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
new file mode 100644
index 00000000000..1f499897c16
--- /dev/null
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -0,0 +1,238 @@
+$colors: (
+ white_header_head_neutral : #e1fad7,
+ white_line_head_neutral : #effdec,
+ white_button_head_neutral : #9adb84,
+
+ white_header_head_chosen : #baf0a8,
+ white_line_head_chosen : #e1fad7,
+ white_button_head_chosen : #52c22d,
+
+ white_header_origin_neutral : #e0f0ff,
+ white_line_origin_neutral : #f2f9ff,
+ white_button_origin_neutral : #87c2fa,
+
+ white_header_origin_chosen : #add8ff,
+ white_line_origin_chosen : #e0f0ff,
+ white_button_origin_chosen : #268ced,
+
+ white_header_not_chosen : #f0f0f0,
+ white_line_not_chosen : #f9f9f9,
+
+
+ dark_header_head_neutral : rgba(#3f3, .2),
+ dark_line_head_neutral : rgba(#3f3, .1),
+ dark_button_head_neutral : #40874f,
+
+ dark_header_head_chosen : rgba(#3f3, .33),
+ dark_line_head_chosen : rgba(#3f3, .2),
+ dark_button_head_chosen : #258537,
+
+ dark_header_origin_neutral : rgba(#2878c9, .4),
+ dark_line_origin_neutral : rgba(#2878c9, .3),
+ dark_button_origin_neutral : #2a5c8c,
+
+ dark_header_origin_chosen : rgba(#2878c9, .6),
+ dark_line_origin_chosen : rgba(#2878c9, .4),
+ dark_button_origin_chosen : #1d6cbf,
+
+ dark_header_not_chosen : rgba(#fff, .25),
+ dark_line_not_chosen : rgba(#fff, .1),
+
+
+ monokai_header_head_neutral : rgba(#a6e22e, .25),
+ monokai_line_head_neutral : rgba(#a6e22e, .1),
+ monokai_button_head_neutral : #376b20,
+
+ monokai_header_head_chosen : rgba(#a6e22e, .4),
+ monokai_line_head_chosen : rgba(#a6e22e, .25),
+ monokai_button_head_chosen : #39800d,
+
+ monokai_header_origin_neutral : rgba(#60d9f1, .35),
+ monokai_line_origin_neutral : rgba(#60d9f1, .15),
+ monokai_button_origin_neutral : #38848c,
+
+ monokai_header_origin_chosen : rgba(#60d9f1, .5),
+ monokai_line_origin_chosen : rgba(#60d9f1, .35),
+ monokai_button_origin_chosen : #3ea4b2,
+
+ monokai_header_not_chosen : rgba(#76715d, .24),
+ monokai_line_not_chosen : rgba(#76715d, .1),
+
+
+ solarized_light_header_head_neutral : rgba(#859900, .37),
+ solarized_light_line_head_neutral : rgba(#859900, .2),
+ solarized_light_button_head_neutral : #afb262,
+
+ solarized_light_header_head_chosen : rgba(#859900, .5),
+ solarized_light_line_head_chosen : rgba(#859900, .37),
+ solarized_light_button_head_chosen : #94993d,
+
+ solarized_light_header_origin_neutral : rgba(#2878c9, .37),
+ solarized_light_line_origin_neutral : rgba(#2878c9, .15),
+ solarized_light_button_origin_neutral : #60a1bf,
+
+ solarized_light_header_origin_chosen : rgba(#2878c9, .6),
+ solarized_light_line_origin_chosen : rgba(#2878c9, .37),
+ solarized_light_button_origin_chosen : #2482b2,
+
+ solarized_light_header_not_chosen : rgba(#839496, .37),
+ solarized_light_line_not_chosen : rgba(#839496, .2),
+
+
+ solarized_dark_header_head_neutral : rgba(#859900, .35),
+ solarized_dark_line_head_neutral : rgba(#859900, .15),
+ solarized_dark_button_head_neutral : #376b20,
+
+ solarized_dark_header_head_chosen : rgba(#859900, .5),
+ solarized_dark_line_head_chosen : rgba(#859900, .35),
+ solarized_dark_button_head_chosen : #39800d,
+
+ solarized_dark_header_origin_neutral : rgba(#2878c9, .35),
+ solarized_dark_line_origin_neutral : rgba(#2878c9, .15),
+ solarized_dark_button_origin_neutral : #086799,
+
+ solarized_dark_header_origin_chosen : rgba(#2878c9, .6),
+ solarized_dark_line_origin_chosen : rgba(#2878c9, .35),
+ solarized_dark_button_origin_chosen : #0082cc,
+
+ solarized_dark_header_not_chosen : rgba(#839496, .25),
+ solarized_dark_line_not_chosen : rgba(#839496, .15)
+);
+
+
+@mixin color-scheme($color) {
+ .header.line_content, .diff-line-num {
+ &.origin {
+ background-color: map-get($colors, #{$color}_header_origin_neutral);
+ border-color: map-get($colors, #{$color}_header_origin_neutral);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_origin_neutral);
+ border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15);
+ }
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_header_origin_chosen);
+ border-color: map-get($colors, #{$color}_header_origin_chosen);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_origin_chosen);
+ border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15);
+ }
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_header_not_chosen);
+ border-color: map-get($colors, #{$color}_header_not_chosen);
+
+ button {
+ background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15);
+ border-color: map-get($colors, #{$color}_button_origin_neutral);
+ }
+ }
+ }
+ &.head {
+ background-color: map-get($colors, #{$color}_header_head_neutral);
+ border-color: map-get($colors, #{$color}_header_head_neutral);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_head_neutral);
+ border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15);
+ }
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_header_head_chosen);
+ border-color: map-get($colors, #{$color}_header_head_chosen);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_head_chosen);
+ border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15);
+ }
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_header_not_chosen);
+ border-color: map-get($colors, #{$color}_header_not_chosen);
+
+ button {
+ background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15);
+ border-color: map-get($colors, #{$color}_button_head_neutral);
+ }
+ }
+ }
+ }
+
+ .line_content {
+ &.origin {
+ background-color: map-get($colors, #{$color}_line_origin_neutral);
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_line_origin_chosen);
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_line_not_chosen);
+ }
+ }
+ &.head {
+ background-color: map-get($colors, #{$color}_line_head_neutral);
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_line_head_chosen);
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_line_not_chosen);
+ }
+ }
+ }
+}
+
+
+#conflicts {
+
+ .white {
+ @include color-scheme('white')
+ }
+
+ .dark {
+ @include color-scheme('dark')
+ }
+
+ .monokai {
+ @include color-scheme('monokai')
+ }
+
+ .solarized-light {
+ @include color-scheme('solarized_light')
+ }
+
+ .solarized-dark {
+ @include color-scheme('solarized_dark')
+ }
+
+ .diff-wrap-lines .line_content {
+ white-space: normal;
+ min-height: 19px;
+ }
+
+ .line_content.header {
+ position: relative;
+
+ button {
+ border-radius: 2px;
+ font-size: 10px;
+ position: absolute;
+ right: 10px;
+ padding: 0;
+ outline: none;
+ color: #fff;
+ width: 75px; // static width to make 2 buttons have same width
+ height: 19px;
+ }
+ }
+
+ .btn-success .fa-spinner {
+ color: #fff;
+ }
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 21919fe4d73..50ac4d8449b 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -229,3 +229,15 @@
box-shadow: none;
}
}
+
+.pipelines.tab-pane {
+
+ .content-list.pipelines {
+ overflow: scroll;
+ }
+
+ .stage {
+ max-width: 60px;
+ width: 60px;
+ }
+}
diff --git a/app/controllers/projects/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb
new file mode 100644
index 00000000000..3cfb08d5822
--- /dev/null
+++ b/app/controllers/projects/board_lists_controller.rb
@@ -0,0 +1,65 @@
+class Projects::BoardListsController < Projects::ApplicationController
+ respond_to :json
+
+ before_action :authorize_admin_list!
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ def create
+ list = Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ service = Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ service = Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position).merge(id: params[:id])
+ end
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+end
diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb
new file mode 100644
index 00000000000..dad38fff6b9
--- /dev/null
+++ b/app/controllers/projects/boards/application_controller.rb
@@ -0,0 +1,15 @@
+module Projects
+ module Boards
+ class ApplicationController < Projects::ApplicationController
+ respond_to :json
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ private
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
new file mode 100644
index 00000000000..2d894b3dd4a
--- /dev/null
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -0,0 +1,56 @@
+module Projects
+ module Boards
+ class IssuesController < Boards::ApplicationController
+ before_action :authorize_read_issue!, only: [:index]
+ before_action :authorize_update_issue!, only: [:update]
+
+ def index
+ issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
+ issues = issues.page(params[:page])
+
+ render json: issues.as_json(
+ only: [:iid, :title, :confidential],
+ include: {
+ assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ labels: { only: [:id, :title, :description, :color, :priority] }
+ })
+ end
+
+ def update
+ service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
+
+ if service.execute(issue)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def issue
+ @issue ||=
+ IssuesFinder.new(current_user, project_id: project.id, state: 'all')
+ .execute
+ .where(iid: params[:id])
+ .first!
+ end
+
+ def authorize_read_issue!
+ return render_403 unless can?(current_user, :read_issue, project)
+ end
+
+ def authorize_update_issue!
+ return render_403 unless can?(current_user, :update_issue, issue)
+ end
+
+ def filter_params
+ params.merge(id: params[:list_id])
+ end
+
+ def move_params
+ params.permit(:id, :from_list_id, :to_list_id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
new file mode 100644
index 00000000000..b995f586737
--- /dev/null
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -0,0 +1,81 @@
+module Projects
+ module Boards
+ class ListsController < Boards::ApplicationController
+ before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
+ before_action :authorize_read_list!, only: [:index]
+
+ def index
+ render json: serialize_as_json(project.board.lists)
+ end
+
+ def create
+ list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: serialize_as_json(list)
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ list = project.board.lists.movable.find(params[:id])
+ service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ list = project.board.lists.destroyable.find(params[:id])
+ service = ::Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = ::Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: serialize_as_json(project.board.lists.movable)
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def authorize_read_list!
+ return render_403 unless can?(current_user, :read_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:id, :list_type, :position],
+ methods: [:title],
+ include: {
+ label: { only: [:id, :title, :description, :color, :priority] }
+ })
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
new file mode 100644
index 00000000000..33206717089
--- /dev/null
+++ b/app/controllers/projects/boards_controller.rb
@@ -0,0 +1,15 @@
+class Projects::BoardsController < Projects::ApplicationController
+ respond_to :html
+
+ before_action :authorize_read_board!, only: [:show]
+
+ def show
+ ::Boards::CreateService.new(project, current_user).execute
+ end
+
+ private
+
+ def authorize_read_board!
+ return access_denied! unless can?(current_user, :read_board, project)
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index c92c4407035..e1462cf0941 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :module_enabled
before_action :merge_request, only: [
- :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
- :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
+ :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
+ :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
]
- before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
- before_action :define_show_vars, only: [:show, :diffs, :commits, :builds]
+ before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
+ before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
before_action :define_commit_vars, only: [:diffs]
before_action :define_diff_comment_vars, only: [:diffs]
- before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds]
+ before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
# Allow read any merge_request
before_action :authorize_read_merge_request!
@@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# Allow modify merge_request
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
+ before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
+
def index
terms = params['issue_search']
@merge_requests = merge_requests_collection
@@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
+ def conflicts
+ respond_to do |format|
+ format.html { define_discussion_vars }
+
+ format.json do
+ if @merge_request.conflicts_can_be_resolved_in_ui?
+ render json: @merge_request.conflicts
+ elsif @merge_request.can_be_merged?
+ render json: {
+ message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ type: 'error'
+ }
+ else
+ render json: {
+ message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ type: 'error'
+ }
+ end
+ end
+ end
+ end
+
+ def resolve_conflicts
+ return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
+
+ if @merge_request.can_be_merged?
+ render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ return
+ end
+
+ begin
+ MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
+
+ flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+
+ render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
+ rescue Gitlab::Conflict::File::MissingResolution => e
+ render status: :bad_request, json: { message: e.message }
+ end
+ end
+
def builds
respond_to do |format|
format.html do
@@ -141,6 +184,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
+ def pipelines
+ @pipelines = @merge_request.all_pipelines
+
+ respond_to do |format|
+ format.html do
+ define_discussion_vars
+
+ render 'show'
+ end
+ format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } }
+ end
+ end
+
def new
build_merge_request
@noteable = @merge_request
@@ -338,6 +394,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
+ def authorize_can_resolve_conflicts!
+ return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
+ end
+
def module_enabled
return render_404 unless @project.merge_requests_enabled
end
@@ -409,7 +469,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
noteable_id: @merge_request.id
}
- @use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
+ @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
Banzai::NoteRenderer.render(
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c3613bc67dd..f3733b01721 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -320,4 +320,8 @@ module ApplicationHelper
capture(&block)
end
end
+
+ def page_class
+ "issue-boards-page" if current_controller?(:boards)
+ end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 7a02d0b10d9..33dcee49aee 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -98,28 +98,31 @@ module CommitsHelper
end
def link_to_browse_code(project, commit)
- if current_controller?(:projects, :commits)
- if @repo.blob_at(commit.id, @path)
- return link_to(
- "Browse File",
- namespace_project_blob_path(project.namespace, project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
- elsif @path.present?
- return link_to(
- "Browse Directory",
- namespace_project_tree_path(project.namespace, project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
- end
+ if @path.blank?
+ return link_to(
+ "Browse Files",
+ namespace_project_tree_path(project.namespace, project, commit),
+ class: "btn btn-default"
+ )
+ end
+
+ return unless current_controller?(:projects, :commits)
+
+ if @repo.blob_at(commit.id, @path)
+ return link_to(
+ "Browse File",
+ namespace_project_blob_path(project.namespace, project,
+ tree_join(commit.id, @path)),
+ class: "btn btn-default"
+ )
+ elsif @path.present?
+ return link_to(
+ "Browse Directory",
+ namespace_project_tree_path(project.namespace, project,
+ tree_join(commit.id, @path)),
+ class: "btn btn-default"
+ )
end
- link_to(
- "Browse Files",
- namespace_project_tree_path(project.namespace, project, commit),
- class: "btn btn-default"
- )
end
def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 3ff8be5e284..6c1cc6ef072 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -24,6 +24,7 @@ module NavHelper
current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') ||
+ current_path?('merge_requests#conflicts') ||
current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed"
diff --git a/app/models/ability.rb b/app/models/ability.rb
index ce5d7ce0dad..07f703f205d 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -90,6 +90,8 @@ class Ability
if project && project.public?
rules = [
:read_project,
+ :read_board,
+ :read_list,
:read_wiki,
:read_label,
:read_milestone,
@@ -228,6 +230,8 @@ class Ability
:read_project,
:read_wiki,
:read_issue,
+ :read_board,
+ :read_list,
:read_label,
:read_milestone,
:read_project_snippet,
@@ -249,6 +253,7 @@ class Ability
:update_issue,
:admin_issue,
:admin_label,
+ :admin_list,
:read_commit_status,
:read_build,
:read_container_image,
diff --git a/app/models/board.rb b/app/models/board.rb
new file mode 100644
index 00000000000..3240c4bede3
--- /dev/null
+++ b/app/models/board.rb
@@ -0,0 +1,7 @@
+class Board < ActiveRecord::Base
+ belongs_to :project
+
+ has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
+
+ validates :project, presence: true
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 1313739f322..ed091b34925 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -109,7 +109,11 @@ class DiffNote < Note
private
def supported?
+<<<<<<< HEAD
for_commit? || self.noteable.support_new_diff_notes?
+=======
+ !self.for_merge_request? || self.noteable.has_complete_diff_refs?
+>>>>>>> master
end
def noteable_diff_refs
diff --git a/app/models/label.rb b/app/models/label.rb
index 35e678001dc..a23140b7d64 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -13,6 +13,8 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
belongs_to :project
+
+ has_many :lists, dependent: :destroy
has_many :label_links, dependent: :destroy
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
diff --git a/app/models/list.rb b/app/models/list.rb
new file mode 100644
index 00000000000..eb87decdbc8
--- /dev/null
+++ b/app/models/list.rb
@@ -0,0 +1,34 @@
+class List < ActiveRecord::Base
+ belongs_to :board
+ belongs_to :label
+
+ enum list_type: { backlog: 0, label: 1, done: 2 }
+
+ validates :board, :list_type, presence: true
+ validates :label, :position, presence: true, if: :label?
+ validates :label_id, uniqueness: { scope: :board_id }, if: :label?
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
+
+ before_destroy :can_be_destroyed
+
+ scope :destroyable, -> { where(list_type: list_types[:label]) }
+ scope :movable, -> { where(list_type: list_types[:label]) }
+
+ def destroyable?
+ label?
+ end
+
+ def movable?
+ label?
+ end
+
+ def title
+ label? ? label.name : list_type.humanize
+ end
+
+ private
+
+ def can_be_destroyed
+ destroyable?
+ end
+end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 31badb54188..39e4d182bb7 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -700,10 +700,21 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
+ def commits_sha
+ commits.map(&:sha)
+ end
+
def pipeline
@pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project
end
+ def all_pipelines
+ @all_pipelines ||=
+ if diff_head_sha && source_project
+ source_project.pipelines.order(id: :desc).where(sha: commits_sha, ref: source_branch)
+ end
+ end
+
def merge_commit
@merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha
end
@@ -716,12 +727,12 @@ class MergeRequest < ActiveRecord::Base
merge_commit
end
- def support_new_diff_notes?
+ def has_complete_diff_refs?
diff_sha_refs && diff_sha_refs.complete?
end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
- return unless support_new_diff_notes?
+ return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
active_diff_notes = self.notes.diff_notes.select do |note|
@@ -749,4 +760,26 @@ class MergeRequest < ActiveRecord::Base
def keep_around_commit
project.repository.keep_around(self.merge_commit_sha)
end
+
+ def conflicts
+ @conflicts ||= Gitlab::Conflict::FileCollection.new(self)
+ end
+
+ def conflicts_can_be_resolved_by?(user)
+ access = ::Gitlab::UserAccess.new(user, project: source_project)
+ access.can_push_to_branch?(source_branch)
+ end
+
+ def conflicts_can_be_resolved_in_ui?
+ return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
+
+ return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
+ return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
+
+ begin
+ @conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines)
+ rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+ @conflicts_can_be_resolved_in_ui = false
+ end
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index eefdae35615..043da030468 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -62,6 +62,8 @@ class Project < ActiveRecord::Base
belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id'
belongs_to :namespace
+ has_one :board, dependent: :destroy
+
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id'
# Project services
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e56bac509a4..2494c266cd2 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -391,6 +391,8 @@ class Repository
expire_exists_cache
expire_root_ref_cache
expire_emptiness_caches
+
+ repository_event(:create_repository)
end
# Runs code just before a repository is deleted.
@@ -407,6 +409,8 @@ class Repository
expire_root_ref_cache
expire_emptiness_caches
expire_exists_cache
+
+ repository_event(:remove_repository)
end
# Runs code just before the HEAD of a repository is changed.
@@ -414,6 +418,8 @@ class Repository
# Cached divergent commit counts are based on repository head
expire_branch_cache
expire_root_ref_cache
+
+ repository_event(:change_default_branch)
end
# Runs code before pushing (= creating or removing) a tag.
@@ -421,12 +427,16 @@ class Repository
expire_cache
expire_tags_cache
expire_tag_count_cache
+
+ repository_event(:push_tag)
end
# Runs code before removing a tag.
def before_remove_tag
expire_tags_cache
expire_tag_count_cache
+
+ repository_event(:remove_tag)
end
def before_import
@@ -443,6 +453,8 @@ class Repository
# Runs code after a new commit has been pushed.
def after_push_commit(branch_name, revision)
expire_cache(branch_name, revision)
+
+ repository_event(:push_commit, branch: branch_name)
end
# Runs code after a new branch has been created.
@@ -450,11 +462,15 @@ class Repository
expire_branches_cache
expire_has_visible_content_cache
expire_branch_count_cache
+
+ repository_event(:push_branch)
end
# Runs code before removing an existing branch.
def before_remove_branch
expire_branches_cache
+
+ repository_event(:remove_branch)
end
# Runs code after an existing branch has been removed.
@@ -869,6 +885,14 @@ class Repository
end
end
+ def resolve_conflicts(user, branch, params)
+ commit_with_hooks(user, branch) do
+ committer = user_to_committer(user)
+
+ Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
+ end
+ end
+
def check_revert_content(commit, base_branch)
source_sha = find_branch(base_branch).target.sha
args = [commit.id, source_sha]
@@ -1059,4 +1083,8 @@ class Repository
def keep_around_ref_name(sha)
"refs/keep-around/#{sha}"
end
+
+ def repository_event(event, tags = {})
+ Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
+ end
end
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
new file mode 100644
index 00000000000..b2069ca825a
--- /dev/null
+++ b/app/services/boards/base_service.rb
@@ -0,0 +1,5 @@
+module Boards
+ class BaseService < ::BaseService
+ delegate :board, to: :project
+ end
+end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
new file mode 100644
index 00000000000..072a0749285
--- /dev/null
+++ b/app/services/boards/create_service.rb
@@ -0,0 +1,16 @@
+module Boards
+ class CreateService < Boards::BaseService
+ def execute
+ create_board! unless project.board.present?
+ project.board
+ end
+
+ private
+
+ def create_board!
+ project.create_board
+ project.board.lists.create(list_type: :backlog)
+ project.board.lists.create(list_type: :done)
+ end
+ end
+end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
new file mode 100644
index 00000000000..435a8c6e681
--- /dev/null
+++ b/app/services/boards/issues/list_service.rb
@@ -0,0 +1,63 @@
+module Boards
+ module Issues
+ class ListService < Boards::BaseService
+ def execute
+ issues = IssuesFinder.new(current_user, filter_params).execute
+ issues = without_board_labels(issues) unless list.movable?
+ issues = with_list_label(issues) if list.movable?
+ issues
+ end
+
+ private
+
+ def list
+ @list ||= board.lists.find(params[:id])
+ end
+
+ def filter_params
+ set_default_scope
+ set_default_sort
+ set_project
+ set_state
+
+ params
+ end
+
+ def set_default_scope
+ params[:scope] = 'all'
+ end
+
+ def set_default_sort
+ params[:sort] = 'priority'
+ end
+
+ def set_project
+ params[:project_id] = project.id
+ end
+
+ def set_state
+ params[:state] = list.done? ? 'closed' : 'opened'
+ end
+
+ def board_label_ids
+ @board_label_ids ||= board.lists.movable.pluck(:label_id)
+ end
+
+ def without_board_labels(issues)
+ return issues unless board_label_ids.any?
+
+ issues.where.not(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where(label_id: board_label_ids).limit(1).arel.exists
+ )
+ end
+
+ def with_list_label(issues)
+ issues.where(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
new file mode 100644
index 00000000000..84dc3f70e76
--- /dev/null
+++ b/app/services/boards/issues/move_service.rb
@@ -0,0 +1,59 @@
+module Boards
+ module Issues
+ class MoveService < Boards::BaseService
+ def execute(issue)
+ return false unless can?(current_user, :update_issue, issue)
+ return false unless valid_move?
+
+ update_service.execute(issue)
+ end
+
+ private
+
+ def valid_move?
+ moving_from_list.present? && moving_to_list.present? &&
+ moving_from_list != moving_to_list
+ end
+
+ def moving_from_list
+ @moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
+ end
+
+ def moving_to_list
+ @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
+ end
+
+ def update_service
+ ::Issues::UpdateService.new(project, current_user, issue_params)
+ end
+
+ def issue_params
+ {
+ add_label_ids: add_label_ids,
+ remove_label_ids: remove_label_ids,
+ state_event: issue_state
+ }
+ end
+
+ def issue_state
+ return 'reopen' if moving_from_list.done?
+ return 'close' if moving_to_list.done?
+ end
+
+ def add_label_ids
+ [moving_to_list.label_id].compact
+ end
+
+ def remove_label_ids
+ label_ids =
+ if moving_to_list.movable?
+ moving_from_list.label_id
+ else
+ board.lists.movable.pluck(:label_id)
+ end
+
+ Array(label_ids).compact
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
new file mode 100644
index 00000000000..5cb408b9d20
--- /dev/null
+++ b/app/services/boards/lists/create_service.rb
@@ -0,0 +1,22 @@
+module Boards
+ module Lists
+ class CreateService < Boards::BaseService
+ def execute
+ List.transaction do
+ create_list_at(next_position)
+ end
+ end
+
+ private
+
+ def next_position
+ max_position = board.lists.movable.maximum(:position)
+ max_position.nil? ? 0 : max_position.succ
+ end
+
+ def create_list_at(position)
+ board.lists.create(params.merge(list_type: :label, position: position))
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
new file mode 100644
index 00000000000..25da3bfb56d
--- /dev/null
+++ b/app/services/boards/lists/destroy_service.rb
@@ -0,0 +1,25 @@
+module Boards
+ module Lists
+ class DestroyService < Boards::BaseService
+ def execute(list)
+ return false unless list.destroyable?
+
+ list.with_lock do
+ decrement_higher_lists(list)
+ remove_list(list)
+ end
+ end
+
+ private
+
+ def decrement_higher_lists(list)
+ board.lists.movable.where('position > ?', list.position)
+ .update_all('position = position - 1')
+ end
+
+ def remove_list(list)
+ list.destroy
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb
new file mode 100644
index 00000000000..1c48b9786e4
--- /dev/null
+++ b/app/services/boards/lists/generate_service.rb
@@ -0,0 +1,36 @@
+module Boards
+ module Lists
+ class GenerateService < Boards::BaseService
+ def execute
+ return false unless board.lists.movable.empty?
+
+ List.transaction do
+ label_params.each { |params| create_list(params) }
+ end
+
+ true
+ end
+
+ private
+
+ def create_list(params)
+ label = find_or_create_label(params)
+ Lists::CreateService.new(project, current_user, label_id: label.id).execute
+ end
+
+ def find_or_create_label(params)
+ project.labels.create_with(color: params[:color])
+ .find_or_create_by(name: params[:name])
+ end
+
+ def label_params
+ [
+ { name: 'Development', color: '#5CB85C' },
+ { name: 'Testing', color: '#F0AD4E' },
+ { name: 'Production', color: '#FF5F00' },
+ { name: 'Ready', color: '#FF0000' }
+ ]
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb
new file mode 100644
index 00000000000..020ff69f4a7
--- /dev/null
+++ b/app/services/boards/lists/move_service.rb
@@ -0,0 +1,51 @@
+module Boards
+ module Lists
+ class MoveService < Boards::BaseService
+ def execute(list)
+ @old_position = list.position
+ @new_position = params[:position]
+
+ return false unless list.movable?
+ return false unless valid_move?
+
+ list.with_lock do
+ reorder_intermediate_lists
+ update_list_position(list)
+ end
+ end
+
+ private
+
+ attr_reader :old_position, :new_position
+
+ def valid_move?
+ new_position.present? && new_position != old_position &&
+ new_position >= 0 && new_position < board.lists.movable.size
+ end
+
+ def reorder_intermediate_lists
+ if old_position < new_position
+ decrement_intermediate_lists
+ else
+ increment_intermediate_lists
+ end
+ end
+
+ def decrement_intermediate_lists
+ board.lists.movable.where('position > ?', old_position)
+ .where('position <= ?', new_position)
+ .update_all('position = position - 1')
+ end
+
+ def increment_intermediate_lists
+ board.lists.movable.where('position >= ?', new_position)
+ .where('position < ?', old_position)
+ .update_all('position = position + 1')
+ end
+
+ def update_list_position(list)
+ list.update_attribute(:position, new_position)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb
new file mode 100644
index 00000000000..adc71b0c2bc
--- /dev/null
+++ b/app/services/merge_requests/resolve_service.rb
@@ -0,0 +1,31 @@
+module MergeRequests
+ class ResolveService < MergeRequests::BaseService
+ attr_accessor :conflicts, :rugged, :merge_index
+
+ def execute(merge_request)
+ @conflicts = merge_request.conflicts
+ @rugged = project.repository.rugged
+ @merge_index = conflicts.merge_index
+
+ conflicts.files.each do |file|
+ write_resolved_file_to_index(file, params[:sections])
+ end
+
+ commit_params = {
+ message: params[:commit_message] || conflicts.default_commit_message,
+ parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid),
+ tree: merge_index.write_tree(rugged)
+ }
+
+ project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params)
+ end
+
+ def write_resolved_file_to_index(file, resolutions)
+ new_file = file.resolve_lines(resolutions).map(&:text).join("\n")
+ our_path = file.our_path
+
+ merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
+ merge_index.conflict_remove(our_path)
+ end
+ end
+end
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index a1a71c2fb33..bf50633af24 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -23,7 +23,6 @@
= render "layouts/broadcast"
= render "layouts/flash"
= yield :flash_message
- %div{ class: (container_class unless @no_container) }
+ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content
- .clearfix
- = yield
+ = yield
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 33cedaaf2ee..15a94ac23c5 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,5 +1,5 @@
!!! 5
-%html{ lang: "en"}
+%html{ lang: "en", class: "#{page_class}" }
= render "layouts/head"
%body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}}
= Gon::Base.render_data
diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml
new file mode 100644
index 00000000000..97eb952eff1
--- /dev/null
+++ b/app/views/projects/boards/components/_blank_state.html.haml
@@ -0,0 +1,15 @@
+%board-blank-state{ "inline-template" => true,
+ "v-if" => "list.id == 'blank'" }
+ .board-blank-state
+ %p
+ Add the following default lists to your Issue Board with one click:
+ %ul.board-blank-state-list
+ %li{ "v-for" => "label in predefinedLabels" }
+ %span.label-color{ ":style" => "{ backgroundColor: label.color } " }
+ {{ label.title }}
+ %p
+ Starting out with the default set of lists will get you right on the way to making the most of your board.
+ %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
+ Add default lists
+ %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
+ Nevermind, I'll use my own
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
new file mode 100644
index 00000000000..f8ebf397ee2
--- /dev/null
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -0,0 +1,44 @@
+%board{ "inline-template" => true,
+ "v-cloak" => true,
+ "v-for" => "list in state.lists | orderBy 'position'",
+ "v-ref:board" => true,
+ ":list" => "list",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ "track-by" => "_uid" }
+ .board{ ":class" => "{ 'is-draggable': !list.preset }",
+ ":data-id" => "list.id" }
+ .board-inner
+ %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" }
+ = icon("align-justify", class: "board-mobile-handle js-board-drag-handle", "v-if" => "(!disabled && !list.preset)")
+ {{ list.title }}
+ %span.pull-right{ "v-if" => "list.type !== 'blank'" }
+ {{ list.issues.length }}
+ - if can?(current_user, :admin_list, @project)
+ %board-delete{ "inline-template" => true,
+ ":list" => "list",
+ "v-if" => "!list.preset && list.id" }
+ %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ = icon("trash")
+ = icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore")
+ .board-inner-container.board-search-container{ "v-if" => "list.canSearch()" }
+ %input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" }
+ = icon("search", class: "board-search-icon", "v-show" => "!query")
+ %button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" }
+ = icon("times", class: "board-search-clear")
+ %board-list{ "inline-template" => true,
+ "v-if" => "list.type !== 'blank'",
+ ":list" => "list",
+ ":issues" => "list.issues",
+ ":loading" => "list.loading",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase" }
+ .board-list-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ %ul.board-list{ "v-el:list" => true,
+ "v-show" => "!loading",
+ ":data-board" => "list.id" }
+ = render "projects/boards/components/card"
+ - if can?(current_user, :admin_list, @project)
+ = render "projects/boards/components/blank_state"
diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml
new file mode 100644
index 00000000000..b20c23f6b8e
--- /dev/null
+++ b/app/views/projects/boards/components/_card.html.haml
@@ -0,0 +1,34 @@
+%board-card{ "inline-template" => true,
+ "v-for" => "issue in issues | orderBy 'priority'",
+ "v-ref:issue" => true,
+ ":index" => "$index",
+ ":list" => "list",
+ ":issue" => "issue",
+ ":issue-link-base" => "issueLinkBase",
+ ":disabled" => "disabled",
+ "track-by" => "id" }
+ %li.card{ ":class" => "{ 'user-can-drag': !disabled }",
+ ":index" => "index" }
+ = icon("align-justify", class: "board-mobile-handle js-card-drag-handle", "v-if" => "!disabled")
+ %h4.card-title
+ = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
+ %a{ ":href" => "issueLinkBase + '/' + issue.id",
+ ":title" => "issue.title" }
+ {{ issue.title }}
+ .card-footer
+ %span.card-number
+ = precede '#' do
+ {{ issue.id }}
+ %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
+ type: "button",
+ "v-if" => "(!list.label || label.id !== list.label.id)",
+ "@click" => "filterByLabel(label, $event)",
+ ":style" => "{ backgroundColor: label.color, color: label.textColor }",
+ ":title" => "label.description",
+ data: { container: 'body' } }
+ {{ label.title }}
+ %a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username",
+ ":title" => "'Assigned to ' + issue.assignee.name",
+ "v-if" => "issue.assignee",
+ data: { container: 'body' } }
+ %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 }
diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml
new file mode 100644
index 00000000000..edbbd3f3d2a
--- /dev/null
+++ b/app/views/projects/boards/show.html.haml
@@ -0,0 +1,19 @@
+- @no_container = true
+- @content_class = "issue-boards-content"
+- page_title "Boards"
+
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('boards/boards_bundle.js')
+ = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test?
+
+= render "projects/issues/head"
+
+= render 'shared/issuable/filter', type: :boards
+
+.boards-list#board-app{ "v-cloak" => true,
+ "data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}",
+ "data-disabled" => "#{!can?(current_user, :admin_list, @project)}",
+ "data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" }
+ .boards-app-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ = render "projects/boards/components/board"
diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml
index 78709a92aed..be387201f8d 100644
--- a/app/views/projects/ci/pipelines/_pipeline.html.haml
+++ b/app/views/projects/ci/pipelines/_pipeline.html.haml
@@ -2,19 +2,21 @@
%tr.commit
%td.commit-link
= link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
- = ci_status_with_icon(status)
-
-
+ - if defined?(status_icon_only) && status_icon_only
+ = ci_icon_for_status(status)
+ - else
+ = ci_status_with_icon(status)
%td
.branch-commit
= link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
%span ##{pipeline.id}
- if pipeline.ref
- .icon-container
- = pipeline.tag? ? icon('tag') : icon('code-fork')
- = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name"
- .icon-container
- = custom_icon("icon_commit")
+ - unless defined?(hide_branch) && hide_branch
+ .icon-container
+ = pipeline.tag? ? icon('tag') : icon('code-fork')
+ = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name"
+ .icon-container
+ = custom_icon("icon_commit")
= link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace"
- if pipeline.latest?
%span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest
@@ -53,7 +55,7 @@
- if pipeline.finished_at
%p.finished-at
= icon("calendar")
- #{time_ago_with_tooltip(pipeline.finished_at, short_format: true, skip_js: true)}
+ #{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)}
%td.pipeline-actions
.controls.hidden-xs.pull-right
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
new file mode 100644
index 00000000000..29f4ef8f49e
--- /dev/null
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -0,0 +1,17 @@
+%ul.content-list.pipelines
+ - if pipelines.blank?
+ %li
+ .nothing-here-block No pipelines to show
+ - else
+ .table-holder
+ %table.table.builds
+ %tbody
+ %th Status
+ %th Commit
+ - pipelines.stages.each do |stage|
+ %th.stage
+ %span.has-tooltip{ title: "#{stage.titleize}" }
+ = stage.titleize
+ %th
+ %th
+ = render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, hide_branch: true
diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml
index 60b45115b73..b6cb559afcb 100644
--- a/app/views/projects/issues/_head.html.haml
+++ b/app/views/projects/issues/_head.html.haml
@@ -6,6 +6,11 @@
%span
Issues
+ = nav_link(controller: :boards) do
+ = link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do
+ %span
+ Board
+
- if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
= nav_link(controller: :merge_requests) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index b6db1ff714d..f8025fc1dbe 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -47,20 +47,24 @@
- if @commits_count.nonzero?
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.notes-tab
- = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do
+ = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do
Discussion
%span.badge= @merge_request.mr_and_commit_notes.user.count
%li.commits-tab
- = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
+ = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
Commits
%span.badge= @commits_count
- if @pipeline
+ %li.pipelines-tab
+ = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
+ Pipelines
+ %span.badge= @merge_request.all_pipelines.size
%li.builds-tab
- = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do
+ = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do
Builds
%span.badge= @statuses.size
%li.diffs-tab
- = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
+ = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
Changes
%span.badge= @merge_request.diff_size
%li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
@@ -88,6 +92,8 @@
- # This tab is always loaded via AJAX
#builds.builds.tab-pane
- # This tab is always loaded via AJAX
+ #pipelines.pipelines.tab-pane
+ - # This tab is always loaded via AJAX
#diffs.diffs.tab-pane
- # This tab is always loaded via AJAX
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
new file mode 100644
index 00000000000..a524936f73c
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -0,0 +1,29 @@
+- class_bindings = "{ |
+ 'head': line.isHead, |
+ 'origin': line.isOrigin, |
+ 'match': line.hasMatch, |
+ 'selected': line.isSelected, |
+ 'unselected': line.isUnselected }"
+
+- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+= render "projects/merge_requests/show/mr_title"
+
+.merge-request-details.issuable-details
+ = render "projects/merge_requests/show/mr_box"
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+
+#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
+ .loading{"v-if" => "isLoading"}
+ %i.fa.fa-spinner.fa-spin
+
+ .nothing-here-block{"v-if" => "hasError"}
+ {{conflictsData.errorMessage}}
+
+ = render partial: "projects/merge_requests/conflicts/commit_stats"
+
+ .files-wrapper{"v-if" => "!isLoading && !hasError"}
+ = render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings }
+ = render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings }
+ = render partial: "projects/merge_requests/conflicts/submit_form"
diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
new file mode 100644
index 00000000000..457c467fba9
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
@@ -0,0 +1,20 @@
+.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"}
+ .inline-parallel-buttons
+ .btn-group
+ %a.btn{ |
+ ":class" => "{'active': !isParallel}", |
+ "@click" => "handleViewTypeChange('inline')"}
+ Inline
+ %a.btn{ |
+ ":class" => "{'active': isParallel}", |
+ "@click" => "handleViewTypeChange('parallel')"}
+ Side-by-side
+
+ .js-toggle-container
+ .commit-stat-summary
+ Showing
+ %strong.cred {{conflictsCount}} {{conflictsData.conflictsText}}
+ between
+ %strong {{conflictsData.source_branch}}
+ and
+ %strong {{conflictsData.target_branch}}
diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
new file mode 100644
index 00000000000..19c7da4b5e3
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
@@ -0,0 +1,28 @@
+.files{"v-show" => "!isParallel"}
+ .diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"}
+ .file-title
+ %i.fa.fa-fw{":class" => "file.iconClass"}
+ %strong {{file.filePath}}
+ .file-actions
+ %a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
+ View file @{{conflictsData.shortCommitSha}}
+
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight
+ %table
+ %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
+ %template{"v-if" => "!line.isHeader"}
+ %td.diff-line-num.new_line{":class" => class_bindings}
+ %a {{line.new_line}}
+ %td.diff-line-num.old_line{":class" => class_bindings}
+ %a {{line.old_line}}
+ %td.line_content{":class" => class_bindings}
+ {{{line.richText}}}
+
+ %template{"v-if" => "line.isHeader"}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.line_content.header{":class" => class_bindings}
+ %strong {{{line.richText}}}
+ %button.btn{"@click" => "handleSelected(line.id, line.section)"}
+ {{line.buttonTitle}}
diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
new file mode 100644
index 00000000000..2e6f67c2eaf
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
@@ -0,0 +1,27 @@
+.files{"v-show" => "isParallel"}
+ .diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"}
+ .file-title
+ %i.fa.fa-fw{":class" => "file.iconClass"}
+ %strong {{file.filePath}}
+ .file-actions
+ %a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
+ View file @{{conflictsData.shortCommitSha}}
+
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight
+ %table
+ %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
+ %template{"v-for" => "line in section"}
+
+ %template{"v-if" => "line.isHeader"}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.line_content.header{":class" => class_bindings}
+ %strong {{line.richText}}
+ %button.btn{"@click" => "handleSelected(line.id, line.section)"}
+ {{line.buttonTitle}}
+
+ %template{"v-if" => "!line.isHeader"}
+ %td.diff-line-num.old_line{":class" => class_bindings}
+ {{line.lineNumber}}
+ %td.line_content.parallel{":class" => class_bindings}
+ {{{line.richText}}}
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
new file mode 100644
index 00000000000..78bd4133ea2
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -0,0 +1,15 @@
+.content-block.oneline-block.files-changed
+ %strong.resolved-count {{resolvedCount}}
+ of
+ %strong.total-count {{conflictsCount}}
+ conflicts have been resolved
+
+ .commit-message-container.form-group
+ .max-width-marker
+ %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
+ {{{conflictsData.commitMessage}}}
+
+ %button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"}
+ %span {{commitButtonText}}
+
+ = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml
index 81de60f116c..808ef7fed27 100644
--- a/app/views/projects/merge_requests/show/_builds.html.haml
+++ b/app/views/projects/merge_requests/show/_builds.html.haml
@@ -1,2 +1 @@
= render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true
-
diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml
new file mode 100644
index 00000000000..afe3f3430c6
--- /dev/null
+++ b/app/views/projects/merge_requests/show/_pipelines.html.haml
@@ -0,0 +1 @@
+= render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true
diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml
index 19b5d0ff066..7794d6d7df2 100644
--- a/app/views/projects/merge_requests/widget/_merged.html.haml
+++ b/app/views/projects/merge_requests/widget/_merged.html.haml
@@ -6,7 +6,7 @@
- if @merge_request.merge_event
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true')
+ - if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
index dc18f715f25..6f5ee5f16c5 100644
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ b/app/views/projects/merge_requests/widget/_open.html.haml
@@ -1,6 +1,12 @@
.mr-state-widget
= render 'projects/merge_requests/widget/heading'
.mr-widget-body
+ -# After conflicts are resolved, the user is redirected back to the MR page.
+ -# There is a short window before background workers run and GitLab processes
+ -# the new push and commits, during which it will think the conflicts still exist.
+ -# We send this param to get the widget to treat the MR as having no more conflicts.
+ - resolved_conflicts = params[:resolved_conflicts]
+
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- elsif @merge_request.commits.blank?
@@ -9,7 +15,7 @@
= render 'projects/merge_requests/widget/open/missing_branch'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- - elsif @merge_request.cannot_be_merged?
+ - elsif @merge_request.cannot_be_merged? && !resolved_conflicts
= render 'projects/merge_requests/widget/open/conflicts'
- elsif @merge_request.work_in_progress?
= render 'projects/merge_requests/widget/open/wip'
@@ -19,7 +25,7 @@
= render 'projects/merge_requests/widget/open/not_allowed'
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
= render 'projects/merge_requests/widget/open/build_failed'
- - elsif @merge_request.can_be_merged?
+ - elsif @merge_request.can_be_merged? || resolved_conflicts
= render 'projects/merge_requests/widget/open/accept'
- if mr_closes_issues.present?
diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml
index d9efe81701f..ea618263a4a 100644
--- a/app/views/projects/merge_requests/widget/_show.html.haml
+++ b/app/views/projects/merge_requests/widget/_show.html.haml
@@ -23,7 +23,8 @@
preparing: "{{status}} build",
normal: "Build {{status}}"
},
- builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
+ builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
+ pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
};
if (typeof merge_request_widget !== 'undefined') {
diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
index f000cc38a65..af3096f04d9 100644
--- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
@@ -3,7 +3,18 @@
This merge request contains merge conflicts
%p
- Please resolve these conflicts or
+ Please
+ - if @merge_request.conflicts_can_be_resolved_by?(current_user)
+ - if @merge_request.conflicts_can_be_resolved_in_ui?
+ = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ - else
+ %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"}
+ resolve these conflicts locally
+ - else
+ resolve these conflicts
+
+ or
+
- if @merge_request.can_be_merged_via_command_line_by?(current_user)
#{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
- else
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index c957cd84479..ffe8d4fbdbf 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -27,7 +27,17 @@
= render "shared/issuable/label_dropdown"
.pull-right
- = render 'shared/sort_dropdown'
+ - if controller.controller_name != 'boards'
+ = render 'shared/sort_dropdown'
+ - if can?(current_user, :admin_list, @project)
+ .dropdown
+ %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } }
+ Create new list
+ .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
+ = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" }
+ - if can?(current_user, :admin_label, @project)
+ = render partial: "shared/issuable/label_page_create"
+ = dropdown_loading
- if controller.controller_name == 'issues'
.issues_bulk_update.hide
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index 4e280c371ac..a76b7baf918 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -2,8 +2,13 @@
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels')
+- show_boards_content = local_assigns.fetch(:show_boards_content, false)
.dropdown-page-one
= dropdown_title(title)
+ - if show_boards_content
+ .issue-board-dropdown-content
+ %p
+ Each label that exists in your issue tracker can have its own dedicated list. Select a label below to add a list to your Board and it will automatically be populated with issues that have that label. To create a list for a label that doesn't exist yet, simply create the label below.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
@@ -12,7 +17,7 @@
- if can?(current_user, :admin_label, @project)
%li
%a.dropdown-toggle-page{href: "#"}
- Create new
+ Create new label
%li
= link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index d69d6037053..61ed1c38ac4 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -5,6 +5,10 @@ class RepositoryForkWorker
sidekiq_options queue: :gitlab_shell
def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
+ Gitlab::Metrics.add_event(:fork_repository,
+ source_path: source_path,
+ target_path: target_path)
+
project = Project.find_by_id(project_id)
unless project.present?
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 7d819fe78f8..e6701078f71 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -10,6 +10,10 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
+ Gitlab::Metrics.add_event(:import_repository,
+ import_url: @project.import_url,
+ path: @project.path_with_namespace)
+
result = Projects::ImportService.new(project, current_user).execute
if result[:status] == :error
diff --git a/config/application.rb b/config/application.rb
index 60a4e1f134e..6b80f8ddafa 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -86,6 +86,8 @@ module Gitlab
config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js"
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
+ config.assets.precompile << "boards/boards_bundle.js"
+ config.assets.precompile << "boards/test_utils/simulate_drag.js"
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
diff --git a/config/routes.rb b/config/routes.rb
index d5393455b89..624e07b0947 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -727,7 +727,9 @@ Rails.application.routes.draw do
member do
get :commits
get :diffs
+ get :conflicts
get :builds
+ get :pipelines
get :merge_check
post :merge
post :cancel_merge_when_build_succeeds
@@ -736,6 +738,7 @@ Rails.application.routes.draw do
post :toggle_award_emoji
post :remove_wip
get :diff_for_path
+ post :resolve_conflicts
end
collection do
@@ -865,6 +868,20 @@ Rails.application.routes.draw do
end
end
+ resource :board, only: [:show] do
+ scope module: :boards do
+ resources :issues, only: [:update]
+
+ resources :lists, only: [:index, :create, :update, :destroy] do
+ collection do
+ post :generate
+ end
+
+ resources :issues, only: [:index]
+ end
+ end
+ end
+
resources :todos, only: [:create]
resources :uploads, only: [:create] do
diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb
index 6441a036e75..0d493fa1c3c 100644
--- a/db/fixtures/development/14_builds.rb
+++ b/db/fixtures/development/14_builds.rb
@@ -26,24 +26,44 @@ class Gitlab::Seeder::Builds
begin
BUILDS.each { |opts| build_create!(pipeline, opts) }
commit_status_create!(pipeline, name: 'jenkins', status: :success)
-
print '.'
rescue ActiveRecord::RecordInvalid
print 'F'
+ ensure
+ pipeline.build_updated
end
end
end
def pipelines
- commits = @project.repository.commits('master', limit: 5)
- commits_sha = commits.map { |commit| commit.raw.id }
- commits_sha.map do |sha|
- @project.ensure_pipeline(sha, 'master')
- end
+ master_pipelines + merge_request_pipelines
+ end
+
+ def master_pipelines
+ create_pipelines_for(@project, 'master')
rescue
[]
end
+ def merge_request_pipelines
+ @project.merge_requests.last(5).map do |merge_request|
+ create_pipelines(merge_request.source_project, merge_request.source_branch, merge_request.commits.last(5))
+ end.flatten
+ rescue
+ []
+ end
+
+ def create_pipelines_for(project, ref)
+ commits = project.repository.commits(ref, limit: 5)
+ create_pipelines(project, ref, commits)
+ end
+
+ def create_pipelines(project, ref, commits)
+ commits.map do |commit|
+ project.pipelines.create(sha: commit.id, ref: ref)
+ end
+ end
+
def build_create!(pipeline, opts = {})
attributes = build_attributes_for(pipeline, opts)
diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb
new file mode 100644
index 00000000000..56afbd4e030
--- /dev/null
+++ b/db/migrate/20160727191041_create_boards.rb
@@ -0,0 +1,13 @@
+class CreateBoards < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :boards do |t|
+ t.references :project, index: true, foreign_key: true, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb
new file mode 100644
index 00000000000..61d501215f2
--- /dev/null
+++ b/db/migrate/20160727193336_create_lists.rb
@@ -0,0 +1,16 @@
+class CreateLists < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :lists do |t|
+ t.references :board, index: true, foreign_key: true, null: false
+ t.references :label, index: true, foreign_key: true
+ t.integer :list_type, null: false, default: 1
+ t.integer :position
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb
new file mode 100644
index 00000000000..baf2e70b127
--- /dev/null
+++ b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb
@@ -0,0 +1,15 @@
+class AddUniqueIndexToListsLabelId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :lists, [:board_id, :label_id], unique: true
+ end
+
+ def down
+ remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0bc1c967af3..68651a45fa6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -117,6 +117,14 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree
+ create_table "boards", force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
+
create_table "broadcast_messages", force: :cascade do |t|
t.text "message", null: false
t.datetime "starts_at"
@@ -533,6 +541,19 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree
+ create_table "lists", force: :cascade do |t|
+ t.integer "board_id", null: false
+ t.integer "label_id"
+ t.integer "list_type", default: 1, null: false
+ t.integer "position"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree
+ add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree
+ add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree
+
create_table "members", force: :cascade do |t|
t.integer "access_level", null: false
t.integer "source_id", null: false
@@ -1120,6 +1141,9 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
+ add_foreign_key "boards", "projects"
+ add_foreign_key "lists", "boards"
+ add_foreign_key "lists", "labels"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "protected_branch_merge_access_levels", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "protected_branches"
diff --git a/doc/monitoring/performance/influxdb_schema.md b/doc/monitoring/performance/influxdb_schema.md
index 41861860b6d..eff0e29f58d 100644
--- a/doc/monitoring/performance/influxdb_schema.md
+++ b/doc/monitoring/performance/influxdb_schema.md
@@ -9,6 +9,7 @@ The following measurements are currently stored in InfluxDB:
- `PROCESS_object_counts`
- `PROCESS_transactions`
- `PROCESS_views`
+- `events`
Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the
process type. In all series, any form of duration is stored in milliseconds.
@@ -78,6 +79,14 @@ following value fields are available:
The `action` tag contains the action name of the transaction that rendered the
view.
+## events
+
+This measurement is used to store generic events such as the number of Git
+pushes, Emails sent, etc. Each point in this measurement has a single value
+field called `count`. The value of this field is simply set to `1`. Each point
+also has at least one tag: `event`. This tag's value is set to the event name.
+Depending on the event type additional tags may be available as well.
+
---
Read more on:
diff --git a/doc/user/project/merge_requests/img/conflict_section.png b/doc/user/project/merge_requests/img/conflict_section.png
new file mode 100644
index 00000000000..842e50b14b2
--- /dev/null
+++ b/doc/user/project/merge_requests/img/conflict_section.png
Binary files differ
diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png
new file mode 100644
index 00000000000..ffb96b17b07
--- /dev/null
+++ b/doc/user/project/merge_requests/img/merge_request_widget.png
Binary files differ
diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md
new file mode 100644
index 00000000000..44b76ffc8e6
--- /dev/null
+++ b/doc/user/project/merge_requests/resolve_conflicts.md
@@ -0,0 +1,41 @@
+# Merge conflict resolution
+
+> [Introduced][ce-5479] in GitLab 8.11.
+
+When a merge request has conflicts, GitLab may provide the option to resolve
+those conflicts in the GitLab UI. (See
+[conflicts available for resolution](#conflicts-available-for-resolution) for
+more information on when this is available.) If this is an option, you will see
+a **resolve these conflicts** link in the merge request widget:
+
+![Merge request widget](img/merge_request_widget.png)
+
+Clicking this will show a list of files with conflicts, with conflict sections
+highlighted:
+
+![Conflict section](img/conflict_section.png)
+
+Once all conflicts have been marked as using 'ours' or 'theirs', the conflict
+can be resolved. This will perform a merge of the target branch of the merge
+request into the source branch, resolving the conflicts using the options
+chosen. If the source branch is `feature` and the target branch is `master`,
+this is similar to performing `git checkout feature; git merge master` locally.
+
+## Conflicts available for resolution
+
+GitLab allows resolving conflicts in a file where all of the below are true:
+
+- The file is text, not binary
+- The file does not already contain conflict markers
+- The file, with conflict markers added, is not over 200 KB in size
+- The file exists under the same path in both branches
+
+If any file with conflicts in that merge request does not meet all of these
+criteria, the conflicts for that merge request cannot be resolved in the UI.
+
+Additionally, GitLab does not detect conflicts in renames away from a path. For
+example, this will not create a conflict: on branch `a`, doing `git mv file1
+file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be
+present in the branch after the merge request is merged.
+
+[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479
diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb
index 260ac81f5fa..9f3b582a263 100644
--- a/lib/ci/api/builds.rb
+++ b/lib/ci/api/builds.rb
@@ -20,8 +20,13 @@ module Ci
build = Ci::RegisterBuildService.new.execute(current_runner)
if build
+ Gitlab::Metrics.add_event(:build_found,
+ project: build.project.path_with_namespace)
+
present build, with: Entities::BuildDetails
else
+ Gitlab::Metrics.add_event(:build_not_found)
+
not_found!
end
end
@@ -42,6 +47,9 @@ module Ci
build.update_attributes(trace: params[:trace]) if params[:trace]
+ Gitlab::Metrics.add_event(:update_build,
+ project: build.project.path_with_namespace)
+
case params[:state].to_s
when 'success'
build.success
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
new file mode 100644
index 00000000000..0a1fd27ced5
--- /dev/null
+++ b/lib/gitlab/conflict/file.rb
@@ -0,0 +1,186 @@
+module Gitlab
+ module Conflict
+ class File
+ include Gitlab::Routing.url_helpers
+ include IconsHelper
+
+ class MissingResolution < StandardError
+ end
+
+ CONTEXT_LINES = 3
+
+ attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository
+
+ def initialize(merge_file_result, conflict, merge_request:)
+ @merge_file_result = merge_file_result
+ @their_path = conflict[:theirs][:path]
+ @our_path = conflict[:ours][:path]
+ @our_mode = conflict[:ours][:mode]
+ @merge_request = merge_request
+ @repository = merge_request.project.repository
+ @match_line_headers = {}
+ end
+
+ # Array of Gitlab::Diff::Line objects
+ def lines
+ @lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data],
+ our_path: our_path,
+ their_path: their_path,
+ parent_file: self)
+ end
+
+ def resolve_lines(resolution)
+ section_id = nil
+
+ lines.map do |line|
+ unless line.type
+ section_id = nil
+ next line
+ end
+
+ section_id ||= line_code(line)
+
+ case resolution[section_id]
+ when 'head'
+ next unless line.type == 'new'
+ when 'origin'
+ next unless line.type == 'old'
+ else
+ raise MissingResolution, "Missing resolution for section ID: #{section_id}"
+ end
+
+ line
+ end.compact
+ end
+
+ def highlight_lines!
+ their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n")
+ our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n")
+
+ their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines
+ our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines
+
+ lines.each do |line|
+ if line.type == 'old'
+ line.rich_text = their_highlight[line.old_line - 1].try(:html_safe)
+ else
+ line.rich_text = our_highlight[line.new_line - 1].try(:html_safe)
+ end
+ end
+ end
+
+ def sections
+ return @sections if @sections
+
+ chunked_lines = lines.chunk { |line| line.type.nil? }.to_a
+ match_line = nil
+
+ sections_count = chunked_lines.size
+
+ @sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i|
+ section = nil
+
+ # We need to reduce context sections to CONTEXT_LINES. Conflict sections are
+ # always shown in full.
+ if no_conflict
+ conflict_before = i > 0
+ conflict_after = (sections_count - i) > 1
+
+ if conflict_before && conflict_after
+ # Create a gap in a long context section.
+ if lines.length > CONTEXT_LINES * 2
+ head_lines = lines.first(CONTEXT_LINES)
+ tail_lines = lines.last(CONTEXT_LINES)
+
+ # Ensure any existing match line has text for all lines up to the last
+ # line of its context.
+ update_match_line_text(match_line, head_lines.last)
+
+ # Insert a new match line after the created gap.
+ match_line = create_match_line(tail_lines.first)
+
+ section = [
+ { conflict: false, lines: head_lines },
+ { conflict: false, lines: tail_lines.unshift(match_line) }
+ ]
+ end
+ elsif conflict_after
+ tail_lines = lines.last(CONTEXT_LINES)
+
+ # Create a gap and insert a match line at the start.
+ if lines.length > tail_lines.length
+ match_line = create_match_line(tail_lines.first)
+
+ tail_lines.unshift(match_line)
+ end
+
+ lines = tail_lines
+ elsif conflict_before
+ # We're at the end of the file (no conflicts after), so just remove extra
+ # trailing lines.
+ lines = lines.first(CONTEXT_LINES)
+ end
+ end
+
+ # We want to update the match line's text every time unless we've already
+ # created a gap and its corresponding match line.
+ update_match_line_text(match_line, lines.last) unless section
+
+ section ||= { conflict: !no_conflict, lines: lines }
+ section[:id] = line_code(lines.first) unless no_conflict
+ section
+ end
+ end
+
+ def line_code(line)
+ Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos)
+ end
+
+ def create_match_line(line)
+ Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos)
+ end
+
+ # Any line beginning with a letter, an underscore, or a dollar can be used in a
+ # match line header. Only context sections can contain match lines, as match lines
+ # have to exist in both versions of the file.
+ def find_match_line_header(index)
+ return @match_line_headers[index] if @match_line_headers.key?(index)
+
+ @match_line_headers[index] = begin
+ if index >= 0
+ line = lines[index]
+
+ if line.type.nil? && line.text.match(/\A[A-Za-z$_]/)
+ " #{line.text}"
+ else
+ find_match_line_header(index - 1)
+ end
+ end
+ end
+ end
+
+ # Set the match line's text for the current line. A match line takes its start
+ # position and context header (where present) from itself, and its end position from
+ # the line passed in.
+ def update_match_line_text(match_line, line)
+ return unless match_line
+
+ header = find_match_line_header(match_line.index - 1)
+
+ match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}"
+ end
+
+ def as_json(opts = nil)
+ {
+ old_path: their_path,
+ new_path: our_path,
+ blob_icon: file_type_icon_class('file', our_mode, our_path),
+ blob_path: namespace_project_blob_path(merge_request.project.namespace,
+ merge_request.project,
+ ::File.join(merge_request.diff_refs.head_sha, our_path)),
+ sections: sections
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
new file mode 100644
index 00000000000..bbd0427a2c8
--- /dev/null
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -0,0 +1,57 @@
+module Gitlab
+ module Conflict
+ class FileCollection
+ class ConflictSideMissing < StandardError
+ end
+
+ attr_reader :merge_request, :our_commit, :their_commit
+
+ def initialize(merge_request)
+ @merge_request = merge_request
+ @our_commit = merge_request.source_branch_head.raw.raw_commit
+ @their_commit = merge_request.target_branch_head.raw.raw_commit
+ end
+
+ def repository
+ merge_request.project.repository
+ end
+
+ def merge_index
+ @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit)
+ end
+
+ def files
+ @files ||= merge_index.conflicts.map do |conflict|
+ raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
+
+ Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]),
+ conflict,
+ merge_request: merge_request)
+ end
+ end
+
+ def as_json(opts = nil)
+ {
+ target_branch: merge_request.target_branch,
+ source_branch: merge_request.source_branch,
+ commit_sha: merge_request.diff_head_sha,
+ commit_message: default_commit_message,
+ files: files
+ }
+ end
+
+ def default_commit_message
+ conflict_filenames = merge_index.conflicts.map do |conflict|
+ "# #{conflict[:ours][:path]}"
+ end
+
+ <<EOM.chomp
+Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branch}'
+
+# Conflicts:
+#{conflict_filenames.join("\n")}
+EOM
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb
new file mode 100644
index 00000000000..6eccded7872
--- /dev/null
+++ b/lib/gitlab/conflict/parser.rb
@@ -0,0 +1,62 @@
+module Gitlab
+ module Conflict
+ class Parser
+ class ParserError < StandardError
+ end
+
+ class UnexpectedDelimiter < ParserError
+ end
+
+ class MissingEndDelimiter < ParserError
+ end
+
+ class UnmergeableFile < ParserError
+ end
+
+ def parse(text, our_path:, their_path:, parent_file: nil)
+ raise UnmergeableFile if text.blank? # Typically a binary file
+ raise UnmergeableFile if text.length > 102400
+
+ line_obj_index = 0
+ line_old = 1
+ line_new = 1
+ type = nil
+ lines = []
+ conflict_start = "<<<<<<< #{our_path}"
+ conflict_middle = '======='
+ conflict_end = ">>>>>>> #{their_path}"
+
+ text.each_line.map do |line|
+ full_line = line.delete("\n")
+
+ if full_line == conflict_start
+ raise UnexpectedDelimiter unless type.nil?
+
+ type = 'new'
+ elsif full_line == conflict_middle
+ raise UnexpectedDelimiter unless type == 'new'
+
+ type = 'old'
+ elsif full_line == conflict_end
+ raise UnexpectedDelimiter unless type == 'old'
+
+ type = nil
+ elsif line[0] == '\\'
+ type = 'nonewline'
+ lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
+ else
+ lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
+ line_old += 1 if type != 'new'
+ line_new += 1 if type != 'old'
+
+ line_obj_index += 1
+ end
+ end
+
+ raise MissingEndDelimiter unless type.nil?
+
+ lines
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index cf097e0d0de..80a146b4a5a 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -2,11 +2,13 @@ module Gitlab
module Diff
class Line
attr_reader :type, :index, :old_pos, :new_pos
+ attr_writer :rich_text
attr_accessor :text
- def initialize(text, type, index, old_pos, new_pos)
+ def initialize(text, type, index, old_pos, new_pos, parent_file: nil)
@text, @type, @index = text, type, index
@old_pos, @new_pos = old_pos, new_pos
+ @parent_file = parent_file
end
def self.init_from_hash(hash)
@@ -43,9 +45,25 @@ module Gitlab
type == 'old'
end
+ def rich_text
+ @parent_file.highlight_lines! if @parent_file && !@rich_text
+
+ @rich_text
+ end
+
def meta?
type == 'match' || type == 'nonewline'
end
+
+ def as_json(opts = nil)
+ {
+ type: type,
+ old_line: old_line,
+ new_line: new_line,
+ text: text,
+ rich_text: rich_text || text
+ }
+ end
end
end
end
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
index 41fcd971c22..3d1ba33ec68 100644
--- a/lib/gitlab/metrics.rb
+++ b/lib/gitlab/metrics.rb
@@ -124,6 +124,15 @@ module Gitlab
trans.action = action if trans
end
+ # Tracks an event.
+ #
+ # See `Gitlab::Metrics::Transaction#add_event` for more details.
+ def self.add_event(*args)
+ trans = current_transaction
+
+ trans.add_event(*args) if trans
+ end
+
# Returns the prefix to use for the name of a series.
def self.series_prefix
@series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
diff --git a/lib/gitlab/metrics/metric.rb b/lib/gitlab/metrics/metric.rb
index f23d67e1e38..bd0afe53c51 100644
--- a/lib/gitlab/metrics/metric.rb
+++ b/lib/gitlab/metrics/metric.rb
@@ -4,15 +4,20 @@ module Gitlab
class Metric
JITTER_RANGE = 0.000001..0.001
- attr_reader :series, :values, :tags
+ attr_reader :series, :values, :tags, :type
# series - The name of the series (as a String) to store the metric in.
# values - A Hash containing the values to store.
# tags - A Hash containing extra tags to add to the metrics.
- def initialize(series, values, tags = {})
+ def initialize(series, values, tags = {}, type = :metric)
@values = values
@series = series
@tags = tags
+ @type = type
+ end
+
+ def event?
+ type == :event
end
# Returns a Hash in a format that can be directly written to InfluxDB.
diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb
index e61670f491c..b4493bf44d2 100644
--- a/lib/gitlab/metrics/rack_middleware.rb
+++ b/lib/gitlab/metrics/rack_middleware.rb
@@ -17,6 +17,10 @@ module Gitlab
begin
retval = trans.run { @app.call(env) }
+ rescue Exception => error # rubocop: disable Lint/RescueException
+ trans.add_event(:rails_exception)
+
+ raise error
# Even in the event of an error we want to submit any metrics we
# might've gathered up to this point.
ensure
diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb
index a1240fd33ee..f9dd8e41912 100644
--- a/lib/gitlab/metrics/sidekiq_middleware.rb
+++ b/lib/gitlab/metrics/sidekiq_middleware.rb
@@ -11,6 +11,10 @@ module Gitlab
# Old gitlad-shell messages don't provide enqueued_at/created_at attributes
trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0))
trans.run { yield }
+ rescue Exception => error # rubocop: disable Lint/RescueException
+ trans.add_event(:sidekiq_exception)
+
+ raise error
ensure
trans.finish
end
diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb
index 968f3218950..7bc16181be6 100644
--- a/lib/gitlab/metrics/transaction.rb
+++ b/lib/gitlab/metrics/transaction.rb
@@ -4,7 +4,10 @@ module Gitlab
class Transaction
THREAD_KEY = :_gitlab_metrics_transaction
- attr_reader :tags, :values, :methods
+ # The series to store events (e.g. Git pushes) in.
+ EVENT_SERIES = 'events'
+
+ attr_reader :tags, :values, :method, :metrics
attr_accessor :action
@@ -55,6 +58,20 @@ module Gitlab
@metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags)
end
+ # Tracks a business level event
+ #
+ # Business level events including events such as Git pushes, Emails being
+ # sent, etc.
+ #
+ # event_name - The name of the event (e.g. "git_push").
+ # tags - A set of tags to attach to the event.
+ def add_event(event_name, tags = {})
+ @metrics << Metric.new(EVENT_SERIES,
+ { count: 1 },
+ { event: event_name }.merge(tags),
+ :event)
+ end
+
# Returns a MethodCall object for the given name.
def method_call_for(name)
unless method = @methods[name]
@@ -101,7 +118,7 @@ module Gitlab
submit_hashes = submit.map do |metric|
hash = metric.to_hash
- hash[:tags][:action] ||= @action if @action
+ hash[:tags][:action] ||= @action if @action && !metric.event?
hash
end
diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb
new file mode 100644
index 00000000000..d0ad5e26dbd
--- /dev/null
+++ b/spec/controllers/projects/boards/issues_controller_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe Projects::Boards::IssuesController do
+ let(:project) { create(:project_with_board) }
+ let(:user) { create(:user) }
+
+ let(:planning) { create(:label, project: project, name: 'Planning') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+
+ let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
+ let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ describe 'GET index' do
+ context 'with valid list id' do
+ it 'returns issues that have the list label applied' do
+ johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png')))
+ create(:labeled_issue, project: project, labels: [planning])
+ create(:labeled_issue, project: project, labels: [development])
+ create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
+
+ list_issues user: user, list_id: list2
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(response).to match_response_schema('issues')
+ expect(parsed_response.length).to eq 2
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ list_issues user: user, list_id: 999
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_issue, project).and_return(false)
+ end
+
+ it 'returns a successful 403 response' do
+ list_issues user: user, list_id: list2
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def list_issues(user:, list_id:)
+ sign_in(user)
+
+ get :index, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ list_id: list_id.to_param
+ end
+ end
+
+ describe 'PATCH update' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
+
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'moves issue to the desired list' do
+ move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(issue.reload.labels).to contain_exactly(development)
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns a unprocessable entity 422 response for invalid lists' do
+ move user: user, issue: issue, from_list_id: nil, to_list_id: nil
+
+ expect(response).to have_http_status(422)
+ end
+
+ it 'returns a not found 404 response for invalid issue id' do
+ move user: user, issue: 999, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:guest) { create(:user) }
+
+ before do
+ project.team << [guest, :guest]
+ end
+
+ it 'returns a successful 403 response' do
+ move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def move(user:, issue:, from_list_id:, to_list_id:)
+ sign_in(user)
+
+ patch :update, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: issue.to_param,
+ from_list_id: from_list_id,
+ to_list_id: to_list_id,
+ format: :json
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb
new file mode 100644
index 00000000000..9496636e3cc
--- /dev/null
+++ b/spec/controllers/projects/boards/lists_controller_spec.rb
@@ -0,0 +1,241 @@
+require 'spec_helper'
+
+describe Projects::Boards::ListsController do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+ let(:guest) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ project.team << [guest, :guest]
+ end
+
+ describe 'GET index' do
+ it 'returns a successful 200 response' do
+ read_board_list user: user
+
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq 'application/json'
+ end
+
+ it 'returns a list of board lists' do
+ board = project.create_board
+ create(:backlog_list, board: board)
+ create(:list, board: board)
+ create(:done_list, board: board)
+
+ read_board_list user: user
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(response).to match_response_schema('lists')
+ expect(parsed_response.length).to eq 3
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_list, project).and_return(false)
+ end
+
+ it 'returns a successful 403 response' do
+ read_board_list user: user
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def read_board_list(user:)
+ sign_in(user)
+
+ get :index, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: :json
+ end
+ end
+
+ describe 'POST create' do
+ let(:label) { create(:label, project: project, name: 'Development') }
+
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ create_board_list user: user, label_id: label.id
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the created list' do
+ create_board_list user: user, label_id: label.id
+
+ expect(response).to match_response_schema('list')
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns an error' do
+ create_board_list user: user, label_id: nil
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(parsed_response['label']).to contain_exactly "can't be blank"
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:label) { create(:label, project: project, name: 'Development') }
+
+ it 'returns a successful 403 response' do
+ create_board_list user: guest, label_id: label.id
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def create_board_list(user:, label_id:)
+ sign_in(user)
+
+ post :create, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ list: { label_id: label_id },
+ format: :json
+ end
+ end
+
+ describe 'PATCH update' do
+ let!(:planning) { create(:list, board: board, position: 0) }
+ let!(:development) { create(:list, board: board, position: 1) }
+
+ context 'with valid position' do
+ it 'returns a successful 200 response' do
+ move user: user, list: planning, position: 1
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'moves the list to the desired position' do
+ move user: user, list: planning, position: 1
+
+ expect(planning.reload.position).to eq 1
+ end
+ end
+
+ context 'with invalid position' do
+ it 'returns a unprocessable entity 422 response' do
+ move user: user, list: planning, position: 6
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ move user: user, list: 999, position: 1
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ move user: guest, list: planning, position: 6
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def move(user:, list:, position:)
+ sign_in(user)
+
+ patch :update, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: list.to_param,
+ list: { position: position },
+ format: :json
+ end
+ end
+
+ describe 'DELETE destroy' do
+ let!(:planning) { create(:list, board: board, position: 0) }
+
+ context 'with valid list id' do
+ it 'returns a successful 200 response' do
+ remove_board_list user: user, list: planning
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'removes list from board' do
+ expect { remove_board_list user: user, list: planning }.to change(board.lists, :size).by(-1)
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'returns a not found 404 response' do
+ remove_board_list user: user, list: 999
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ remove_board_list user: guest, list: planning
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def remove_board_list(user:, list:)
+ sign_in(user)
+
+ delete :destroy, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: list.to_param,
+ format: :json
+ end
+ end
+
+ describe 'POST generate' do
+ context 'when board lists is empty' do
+ it 'returns a successful 200 response' do
+ generate_default_board_lists user: user
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the defaults lists' do
+ generate_default_board_lists user: user
+
+ expect(response).to match_response_schema('lists')
+ end
+ end
+
+ context 'when board lists is not empty' do
+ it 'returns a unprocessable entity 422 response' do
+ create(:list, board: board)
+
+ generate_default_board_lists user: user
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a successful 403 response' do
+ generate_default_board_lists user: guest
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def generate_default_board_lists(user:)
+ sign_in(user)
+
+ post :generate, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: :json
+ end
+ end
+end
diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb
new file mode 100644
index 00000000000..75a6d39e82c
--- /dev/null
+++ b/spec/controllers/projects/boards_controller_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Projects::BoardsController do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.team << [user, :master]
+ sign_in(user)
+ end
+
+ describe 'GET show' do
+ it 'creates a new board when project does not have one' do
+ expect { read_board }.to change(Board, :count).by(1)
+ end
+
+ it 'renders HTML template' do
+ read_board
+
+ expect(response).to render_template :show
+ expect(response.content_type).to eq 'text/html'
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
+ allow(Ability.abilities).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ end
+
+ it 'returns a successful 404 response' do
+ read_board
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
+ def read_board(format: :html)
+ get :show, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ format: format
+ end
+ end
+end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 69758494543..c64c2b075c5 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+ let(:merge_request_with_conflicts) do
+ create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
+ mr.mark_as_unmergeable
+ end
+ end
before do
sign_in(user)
@@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do
end
end
end
+
+ describe 'GET conflicts' do
+ let(:json_response) { JSON.parse(response.body) }
+
+ context 'when the conflicts cannot be resolved in the UI' do
+ before do
+ allow_any_instance_of(Gitlab::Conflict::Parser).
+ to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ get :conflicts,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project.to_param,
+ id: merge_request_with_conflicts.iid,
+ format: 'json'
+ end
+
+ it 'returns a 200 status code' do
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns JSON with a message' do
+ expect(json_response.keys).to contain_exactly('message', 'type')
+ end
+ end
+
+ context 'with valid conflicts' do
+ before do
+ get :conflicts,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project.to_param,
+ id: merge_request_with_conflicts.iid,
+ format: 'json'
+ end
+
+ it 'includes meta info about the MR' do
+ expect(json_response['commit_message']).to include('Merge branch')
+ expect(json_response['commit_sha']).to match(/\h{40}/)
+ expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
+ expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
+ end
+
+ it 'includes each file that has conflicts' do
+ filenames = json_response['files'].map { |file| file['new_path'] }
+
+ expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
+ end
+
+ it 'splits files into sections with lines' do
+ json_response['files'].each do |file|
+ file['sections'].each do |section|
+ expect(section).to include('conflict', 'lines')
+
+ section['lines'].each do |line|
+ if section['conflict']
+ expect(line['type']).to be_in(['old', 'new'])
+ expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
+ else
+ if line['type'].nil?
+ expect(line['old_line']).not_to eq(nil)
+ expect(line['new_line']).not_to eq(nil)
+ else
+ expect(line['type']).to eq('match')
+ expect(line['old_line']).to eq(nil)
+ expect(line['new_line']).to eq(nil)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ it 'has unique section IDs across files' do
+ section_ids = json_response['files'].flat_map do |file|
+ file['sections'].map { |section| section['id'] }.compact
+ end
+
+ expect(section_ids.uniq).to eq(section_ids)
+ end
+ end
+ end
+
+ context 'POST resolve_conflicts' do
+ let(:json_response) { JSON.parse(response.body) }
+ let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
+
+ def resolve_conflicts(sections)
+ post :resolve_conflicts,
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project.to_param,
+ id: merge_request_with_conflicts.iid,
+ format: 'json',
+ sections: sections,
+ commit_message: 'Commit message'
+ end
+
+ context 'with valid params' do
+ before do
+ resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+ '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin')
+ end
+
+ it 'creates a new commit on the branch' do
+ expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
+ expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
+ end
+
+ it 'returns an OK response' do
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context 'when sections are missing' do
+ before do
+ resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head')
+ end
+
+ it 'returns a 400 error' do
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'has a message with the name of the first missing section' do
+ expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9')
+ end
+
+ it 'does not create a new commit' do
+ expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
+ end
+ end
+ end
end
diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb
new file mode 100644
index 00000000000..35c4a0b6f08
--- /dev/null
+++ b/spec/factories/boards.rb
@@ -0,0 +1,5 @@
+FactoryGirl.define do
+ factory :board do
+ project factory: :empty_project
+ end
+end
diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb
new file mode 100644
index 00000000000..9e3f06c682c
--- /dev/null
+++ b/spec/factories/lists.rb
@@ -0,0 +1,20 @@
+FactoryGirl.define do
+ factory :list do
+ board
+ label
+ list_type :label
+ sequence(:position)
+ end
+
+ factory :backlog_list, parent: :list do
+ list_type :backlog
+ label nil
+ position nil
+ end
+
+ factory :done_list, parent: :list do
+ list_type :done
+ label nil
+ position nil
+ end
+end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index b682ced75ac..f82d68a1816 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -83,4 +83,10 @@ FactoryGirl.define do
)
end
end
+
+ factory :project_with_board, parent: :empty_project do
+ after(:create) do |project|
+ project.create_board
+ end
+ end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
new file mode 100644
index 00000000000..e4c5a10ce7e
--- /dev/null
+++ b/spec/features/boards/boards_spec.rb
@@ -0,0 +1,598 @@
+require 'rails_helper'
+
+describe 'Issue Boards', feature: true, js: true do
+ include WaitForAjax
+
+ let(:project) { create(:empty_project, :public) }
+ let(:user) { create(:user) }
+ let!(:user2) { create(:user) }
+
+ before do
+ project.create_board
+ project.board.lists.create(list_type: :backlog)
+ project.board.lists.create(list_type: :done)
+
+ project.team << [user, :master]
+ project.team << [user2, :master]
+
+ login_as(user)
+ end
+
+ context 'no lists' do
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ expect(page).to have_selector('.board', count: 3)
+ end
+
+ it 'shows blank state' do
+ expect(page).to have_content('Welcome to your Issue Board!')
+ end
+
+ it 'hides the blank state when clicking nevermind button' do
+ page.within(find('.board-blank-state')) do
+ click_button("Nevermind, I'll use my own")
+ end
+ expect(page).to have_selector('.board', count: 2)
+ end
+
+ it 'creates default lists' do
+ lists = ['Backlog', 'Development', 'Testing', 'Production', 'Ready', 'Done']
+
+ page.within(find('.board-blank-state')) do
+ click_button('Add default lists')
+ end
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 6)
+
+ page.all('.board').each_with_index do |list, i|
+ expect(list.find('.board-title')).to have_content(lists[i])
+ end
+ end
+ end
+
+ context 'with lists' do
+ let(:milestone) { create(:milestone, project: project) }
+
+ let(:planning) { create(:label, project: project, name: 'Planning') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let!(:backlog) { create(:label, project: project, name: 'Backlog') }
+ let!(:done) { create(:label, project: project, name: 'Done') }
+
+ let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
+ let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
+
+ let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) }
+ let!(:issue1) { create(:issue, project: project, assignee: user) }
+ let!(:issue2) { create(:issue, project: project, author: user2) }
+ let!(:issue3) { create(:issue, project: project) }
+ let!(:issue4) { create(:issue, project: project) }
+ let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) }
+ let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) }
+ let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) }
+ let!(:issue8) { create(:closed_issue, project: project) }
+ let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug]) }
+
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 4)
+ expect(find('.board:nth-child(1)')).to have_selector('.card')
+ expect(find('.board:nth-child(2)')).to have_selector('.card')
+ expect(find('.board:nth-child(3)')).to have_selector('.card')
+ expect(find('.board:nth-child(4)')).to have_selector('.card')
+ end
+
+ it 'shows lists' do
+ expect(page).to have_selector('.board', count: 4)
+ end
+
+ it 'shows issues in lists' do
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('2')
+ expect(page).to have_selector('.card', count: 2)
+ end
+
+ page.within(find('.board:nth-child(3)')) do
+ expect(page.find('.board-header')).to have_content('2')
+ expect(page).to have_selector('.card', count: 2)
+ end
+ end
+
+ it 'shows confidential issues with icon' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.confidential-icon', count: 1)
+ end
+ end
+
+ it 'allows user to delete board' do
+ page.within(find('.board:nth-child(2)')) do
+ find('.board-delete').click
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 3)
+ end
+
+ it 'removes checkmark in new list dropdown after deleting' do
+ click_button 'Create new list'
+ wait_for_ajax
+
+ page.within(find('.board:nth-child(2)')) do
+ find('.board-delete').click
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 3)
+ expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active')
+ end
+
+ it 'infinite scrolls list' do
+ 50.times do
+ create(:issue, project: project)
+ end
+
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('20')
+ expect(page).to have_selector('.card', count: 20)
+
+ evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+
+ expect(page.find('.board-header')).to have_content('40')
+ expect(page).to have_selector('.card', count: 40)
+ end
+ end
+
+ context 'backlog' do
+ it 'shows issues in backlog with no labels' do
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('6')
+ expect(page).to have_selector('.card', count: 6)
+ end
+ end
+
+ it 'is searchable' do
+ page.within(find('.board', match: :first)) do
+ find('.form-control').set issue1.title
+
+ expect(page).to have_selector('.card', count: 1)
+ end
+ end
+
+ it 'clears search' do
+ page.within(find('.board', match: :first)) do
+ find('.form-control').set issue1.title
+
+ expect(page).to have_selector('.card', count: 1)
+
+ find('.board-search-clear-btn').click
+
+ expect(page).to have_selector('.card', count: 6)
+ end
+ end
+
+ it 'moves issue from backlog into list' do
+ drag_to(list_to_index: 1)
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('3')
+ expect(page).to have_selector('.card', count: 3)
+ end
+ end
+ end
+
+ context 'done' do
+ it 'shows list of done issues' do
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1)
+ end
+
+ it 'moves issue to done' do
+ drag_to(list_from_index: 0, list_to_index: 3)
+
+ expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2)
+ expect(find('.board:nth-child(4)')).to have_content(issue9.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
+ end
+
+ it 'removes all of the same issue to done' do
+ drag_to(list_from_index: 1, list_to_index: 3)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(4)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
+ end
+ end
+
+ context 'lists' do
+ it 'changes position of list' do
+ drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header')
+
+ expect(find('.board:nth-child(2)')).to have_content(development.title)
+ expect(find('.board:nth-child(2)')).to have_content(planning.title)
+ end
+
+ it 'issue moves between lists' do
+ drag_to(list_from_index: 1, card_index: 1, list_to_index: 2)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(3)')).to have_content(issue6.title)
+ expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title)
+ end
+
+ it 'issue moves between lists' do
+ drag_to(list_from_index: 2, list_to_index: 1)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
+ expect(find('.board:nth-child(2)')).to have_content(issue7.title)
+ expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title)
+ end
+
+ it 'issue moves from done' do
+ drag_to(list_from_index: 3, list_to_index: 1)
+
+ expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
+ expect(find('.board:nth-child(2)')).to have_content(issue8.title)
+ end
+
+ context 'issue card' do
+ it 'shows assignee' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.avatar', count: 1)
+ end
+ end
+ end
+
+ context 'new list' do
+ it 'shows all labels in new list dropdown' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ expect(page).to have_content(planning.title)
+ expect(page).to have_content(development.title)
+ expect(page).to have_content(testing.title)
+ end
+ end
+
+ it 'creates new list for label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link testing.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'creates new list for Backlog label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link backlog.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'creates new list for Done label' do
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link done.title
+ end
+
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 5)
+ end
+
+ it 'moves issues from backlog into new list' do
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('6')
+ expect(page).to have_selector('.card', count: 6)
+ end
+
+ click_button 'Create new list'
+
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link testing.title
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+ end
+ end
+ end
+
+ context 'filtering' do
+ it 'filters by author' do
+ page.within '.issues-filters' do
+ click_button('Author')
+
+ page.within '.dropdown-menu-author' do
+ click_link(user2.name)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-author-search')).to have_content(user2.name)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by assignee' do
+ page.within '.issues-filters' do
+ click_button('Assignee')
+
+ page.within '.dropdown-menu-assignee' do
+ click_link(user.name)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-assignee-search')).to have_content(user.name)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by milestone' do
+ page.within '.issues-filters' do
+ click_button('Milestone')
+
+ page.within '.milestone-filter' do
+ click_link(milestone.title)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(find('.js-milestone-select')).to have_content(milestone.title)
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+ end
+
+ it 'filters by label' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'infinite scrolls list with label filter' do
+ 50.times do
+ create(:labeled_issue, project: project, labels: [testing])
+ end
+
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('20')
+ expect(page).to have_selector('.card', count: 20)
+
+ evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+
+ expect(page.find('.board-header')).to have_content('40')
+ expect(page).to have_selector('.card', count: 40)
+ end
+ end
+
+ it 'filters by multiple labels' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link(testing.title)
+ wait_for_vue_resource(spinner: false)
+ click_link(bug.title)
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by no label' do
+ page.within '.issues-filters' do
+ click_button('Label')
+
+ page.within '.dropdown-menu-labels' do
+ click_link("No Label")
+ wait_for_vue_resource(spinner: false)
+ find('.dropdown-menu-close').click
+ end
+ end
+
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('5')
+ expect(page).to have_selector('.card', count: 5)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+ end
+
+ it 'filters by clicking label button on issue' do
+ page.within(find('.board', match: :first)) do
+ expect(page).to have_selector('.card', count: 6)
+ click_button(bug.title)
+ wait_for_vue_resource(spinner: false)
+ end
+
+ page.within(find('.board', match: :first)) do
+ expect(page.find('.board-header')).to have_content('1')
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ page.within(find('.board:nth-child(2)')) do
+ expect(page.find('.board-header')).to have_content('0')
+ expect(page).to have_selector('.card', count: 0)
+ end
+
+ page.within('.labels-filter') do
+ expect(find('.dropdown-toggle-text')).to have_content(bug.title)
+ end
+ end
+
+ it 'removes label filter by clicking label button on issue' do
+ page.within(find('.board', match: :first)) do
+ page.within(find('.card', match: :first)) do
+ click_button(bug.title)
+ end
+ wait_for_vue_resource(spinner: false)
+
+ expect(page).to have_selector('.card', count: 1)
+ end
+
+ wait_for_vue_resource
+
+ page.within('.labels-filter') do
+ expect(find('.dropdown-toggle-text')).to have_content(bug.title)
+ end
+ end
+ end
+ end
+
+ context 'signed out user' do
+ before do
+ logout
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'does not show create new list' do
+ expect(page).not_to have_selector('.js-new-board-list')
+ end
+ end
+
+ context 'as guest user' do
+ let(:user_guest) { create(:user) }
+
+ before do
+ project.team << [user_guest, :guest]
+ logout
+ login_as(user_guest)
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'does not show create new list' do
+ expect(page).not_to have_selector('.js-new-board-list')
+ end
+ end
+
+ def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list')
+ evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});")
+
+ Timeout.timeout(Capybara.default_max_wait_time) do
+ loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero?
+ end
+
+ wait_for_vue_resource
+ end
+
+ def wait_for_vue_resource(spinner: true)
+ Timeout.timeout(Capybara.default_max_wait_time) do
+ loop until page.evaluate_script('Vue.activeResources').zero?
+ end
+
+ if spinner
+ expect(find('.boards-list')).not_to have_selector('.fa-spinner')
+ end
+ end
+end
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
new file mode 100644
index 00000000000..930c36ade2b
--- /dev/null
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+feature 'Merge request conflict resolution', js: true, feature: true do
+ include WaitForAjax
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ def create_merge_request(source_branch)
+ create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr|
+ mr.mark_as_unmergeable
+ end
+ end
+
+ context 'when a merge request can be resolved in the UI' do
+ let(:merge_request) { create_merge_request('conflict-resolvable') }
+
+ before do
+ project.team << [user, :developer]
+ login_as(user)
+
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it 'shows a link to the conflict resolution page' do
+ expect(page).to have_link('conflicts', href: /\/conflicts\Z/)
+ end
+
+ context 'visiting the conflicts resolution page' do
+ before { click_link('conflicts', href: /\/conflicts\Z/) }
+
+ it 'shows the conflicts' do
+ begin
+ expect(find('#conflicts')).to have_content('popen.rb')
+ rescue Capybara::Poltergeist::JavascriptError
+ retry
+ end
+ end
+ end
+ end
+
+ UNRESOLVABLE_CONFLICTS = {
+ 'conflict-too-large' => 'when the conflicts contain a large file',
+ 'conflict-binary-file' => 'when the conflicts contain a binary file',
+ 'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers',
+ 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another'
+ }
+
+ UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
+ context description do
+ let(:merge_request) { create_merge_request(source_branch) }
+
+ before do
+ project.team << [user, :developer]
+ login_as(user)
+
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it 'does not show a link to the conflict resolution page' do
+ expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/)
+ end
+
+ it 'shows an error if the conflicts page is visited directly' do
+ visit current_url + '/conflicts'
+ wait_for_ajax
+
+ expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb
new file mode 100644
index 00000000000..9c4c0525267
--- /dev/null
+++ b/spec/features/merge_requests/pipelines_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+feature 'Pipelines for Merge Requests', feature: true, js: true do
+ include WaitForAjax
+
+ given(:user) { create(:user) }
+ given(:merge_request) { create(:merge_request) }
+ given(:project) { merge_request.target_project }
+
+ before do
+ project.team << [user, :master]
+ login_as user
+ end
+
+ context 'with pipelines' do
+ let!(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+
+ before do
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ scenario 'user visits merge request pipelines tab' do
+ page.within('.merge-request-tabs') do
+ click_link('Pipelines')
+ end
+ wait_for_ajax
+
+ expect(page).to have_selector('.pipeline-actions')
+ end
+ end
+
+ context 'without pipelines' do
+ before do
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ scenario 'user visits merge request page' do
+ page.within('.merge-request-tabs') do
+ expect(page).to have_no_link('Pipelines')
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json
new file mode 100644
index 00000000000..299e4675d6f
--- /dev/null
+++ b/spec/fixtures/api/schemas/issue.json
@@ -0,0 +1,40 @@
+{
+ "type": "object",
+ "required" : [
+ "iid",
+ "title",
+ "confidential"
+ ],
+ "properties" : {
+ "iid": { "type": "integer" },
+ "title": { "type": "string" },
+ "confidential": { "type": "boolean" },
+ "labels": {
+ "type": ["array"],
+ "required": [
+ "id",
+ "color",
+ "description",
+ "title",
+ "priority"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "description": { "type": ["string", "null"] },
+ "title": { "type": "string" },
+ "priority": { "type": ["integer", "null"] }
+ }
+ },
+ "assignee": {
+ "id": { "type": "integet" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar_url": { "type": "uri" }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/issues.json b/spec/fixtures/api/schemas/issues.json
new file mode 100644
index 00000000000..0d2067f704a
--- /dev/null
+++ b/spec/fixtures/api/schemas/issues.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "issue.json" }
+}
diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json
new file mode 100644
index 00000000000..f070fa3b254
--- /dev/null
+++ b/spec/fixtures/api/schemas/list.json
@@ -0,0 +1,39 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "list_type",
+ "title",
+ "position"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "list_type": {
+ "type": "string",
+ "enum": ["backlog", "label", "done"]
+ },
+ "label": {
+ "type": ["object"],
+ "required": [
+ "id",
+ "color",
+ "description",
+ "title",
+ "priority"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "description": { "type": ["string", "null"] },
+ "title": { "type": "string" },
+ "priority": { "type": ["integer", "null"] }
+ }
+ },
+ "title": { "type": "string" },
+ "position": { "type": ["integer", "null"] }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/lists.json b/spec/fixtures/api/schemas/lists.json
new file mode 100644
index 00000000000..9f618aa9de5
--- /dev/null
+++ b/spec/fixtures/api/schemas/lists.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "list.json" }
+}
diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6
new file mode 100644
index 00000000000..078e4b00023
--- /dev/null
+++ b/spec/javascripts/boards/boards_store_spec.js.es6
@@ -0,0 +1,164 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+(() => {
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ $.cookie('issue_board_welcome_hidden', 'false');
+ });
+
+ describe('Store', () => {
+ it('starts with a blank state', () => {
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ });
+
+ describe('lists', () => {
+ it('creates new list without persisting to DB', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+ });
+
+ it('finds list by ID', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+
+ expect(list.id).toBe(1);
+ });
+
+ it('finds list by type', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('type', 'label');
+
+ expect(list).toBeDefined();
+ });
+
+ it('finds list limited by type', () => {
+ gl.issueBoards.BoardsStore.addList({
+ id: 1,
+ position: 0,
+ title: 'Test',
+ list_type: 'backlog'
+ });
+ const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog');
+
+ expect(list).toBeDefined();
+ });
+
+ it('gets issue when new list added', (done) => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ setTimeout(() => {
+ expect(list.issues.length).toBe(1);
+ expect(list.issues[0].id).toBe(1);
+ done();
+ }, 0);
+ });
+
+ it('persists new list', (done) => {
+ gl.issueBoards.BoardsStore.new({
+ title: 'Test',
+ type: 'label',
+ label: {
+ id: 1,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+ });
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ setTimeout(() => {
+ const list = gl.issueBoards.BoardsStore.findList('id', 1);
+ expect(list).toBeDefined();
+ expect(list.id).toBe(1);
+ expect(list.position).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('check for blank state adding', () => {
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
+ });
+
+ it('check for blank state not adding', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false);
+ });
+
+ it('check for blank state adding when backlog & done list exist', () => {
+ gl.issueBoards.BoardsStore.addList({
+ list_type: 'backlog'
+ });
+ gl.issueBoards.BoardsStore.addList({
+ list_type: 'done'
+ });
+
+ expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
+ });
+
+ it('adds the blank state', () => {
+ gl.issueBoards.BoardsStore.addBlankState();
+
+ const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank');
+ expect(list).toBeDefined();
+ });
+
+ it('removes list from state', () => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ gl.issueBoards.BoardsStore.removeList(1, 'label');
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ });
+
+ it('moves the position of lists', () => {
+ const listOne = gl.issueBoards.BoardsStore.addList(listObj),
+ listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
+
+ gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']);
+
+ expect(listOne.position).toBe(1);
+ });
+
+ it('moves an issue from one list to another', (done) => {
+ const listOne = gl.issueBoards.BoardsStore.addList(listObj),
+ listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
+
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
+
+ setTimeout(() => {
+ expect(listOne.issues.length).toBe(1);
+ expect(listTwo.issues.length).toBe(1);
+
+ gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1));
+
+ expect(listOne.issues.length).toBe(0);
+ expect(listTwo.issues.length).toBe(1);
+
+ done();
+ }, 0);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js.es6
new file mode 100644
index 00000000000..3569d1b98bd
--- /dev/null
+++ b/spec/javascripts/boards/issue_spec.js.es6
@@ -0,0 +1,83 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+describe('Issue model', () => {
+ let issue;
+
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ issue = new ListIssue({
+ title: 'Testing',
+ iid: 1,
+ confidential: false,
+ labels: [{
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing'
+ }]
+ });
+ });
+
+ it('has label', () => {
+ expect(issue.labels.length).toBe(1);
+ });
+
+ it('add new label', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'bug',
+ color: 'blue',
+ description: 'bugs!'
+ });
+ expect(issue.labels.length).toBe(2);
+ });
+
+ it('does not add existing label', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'test',
+ color: 'blue',
+ description: 'bugs!'
+ });
+
+ expect(issue.labels.length).toBe(1);
+ });
+
+ it('finds label', () => {
+ const label = issue.findLabel(issue.labels[0]);
+ expect(label).toBeDefined();
+ });
+
+ it('removes label', () => {
+ const label = issue.findLabel(issue.labels[0]);
+ issue.removeLabel(label);
+ expect(issue.labels.length).toBe(0);
+ });
+
+ it('removes multiple labels', () => {
+ issue.addLabel({
+ id: 2,
+ title: 'bug',
+ color: 'blue',
+ description: 'bugs!'
+ });
+ expect(issue.labels.length).toBe(2);
+
+ issue.removeLabels([issue.labels[0], issue.labels[1]]);
+ expect(issue.labels.length).toBe(0);
+ });
+});
diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js.es6
new file mode 100644
index 00000000000..c206b794442
--- /dev/null
+++ b/spec/javascripts/boards/list_spec.js.es6
@@ -0,0 +1,89 @@
+//= require jquery
+//= require jquery_ujs
+//= require jquery.cookie
+//= require vue
+//= require vue-resource
+//= require lib/utils/url_utility
+//= require boards/models/issue
+//= require boards/models/label
+//= require boards/models/list
+//= require boards/models/user
+//= require boards/services/board_service
+//= require boards/stores/boards_store
+//= require ./mock_data
+
+describe('List model', () => {
+ let list;
+
+ beforeEach(() => {
+ gl.boardService = new BoardService('/test/issue-boards/board');
+ gl.issueBoards.BoardsStore.create();
+
+ list = new List(listObj);
+ });
+
+ it('gets issues when created', (done) => {
+ setTimeout(() => {
+ expect(list.issues.length).toBe(1);
+ done();
+ }, 0);
+ });
+
+ it('saves list and returns ID', (done) => {
+ list = new List({
+ title: 'test',
+ label: {
+ id: 1,
+ title: 'test',
+ color: 'red'
+ }
+ });
+ list.save();
+
+ setTimeout(() => {
+ expect(list.id).toBe(1);
+ expect(list.type).toBe('label');
+ expect(list.position).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('destroys the list', (done) => {
+ gl.issueBoards.BoardsStore.addList(listObj);
+ list = gl.issueBoards.BoardsStore.findList('id', 1);
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+ list.destroy();
+
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
+ done();
+ }, 0);
+ });
+
+ it('can\'t search when not backlog', () => {
+ expect(list.canSearch()).toBe(false);
+ });
+
+ it('can search when backlog', () => {
+ list.type = 'backlog';
+ expect(list.canSearch()).toBe(true);
+ });
+
+ it('gets issue from list', (done) => {
+ setTimeout(() => {
+ const issue = list.findIssue(1);
+ expect(issue).toBeDefined();
+ done();
+ }, 0);
+ });
+
+ it('removes issue', (done) => {
+ setTimeout(() => {
+ const issue = list.findIssue(1);
+ expect(list.issues.length).toBe(1);
+ list.removeIssue(issue);
+ expect(list.issues.length).toBe(0);
+ done();
+ }, 0);
+ });
+});
diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js.es6
new file mode 100644
index 00000000000..0c37ec8354f
--- /dev/null
+++ b/spec/javascripts/boards/mock_data.js.es6
@@ -0,0 +1,53 @@
+const listObj = {
+ id: 1,
+ position: 0,
+ title: 'Test',
+ list_type: 'label',
+ label: {
+ id: 1,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+};
+
+const listObjDuplicate = {
+ id: 2,
+ position: 1,
+ title: 'Test',
+ list_type: 'label',
+ label: {
+ id: 2,
+ title: 'Testing',
+ color: 'red',
+ description: 'testing;'
+ }
+};
+
+const BoardsMockData = {
+ 'GET': {
+ '/test/issue-boards/board/lists{/id}/issues': [{
+ title: 'Testing',
+ iid: 1,
+ confidential: false,
+ labels: []
+ }]
+ },
+ 'POST': {
+ '/test/issue-boards/board/lists{/id}': listObj
+ },
+ 'PUT': {
+ '/test/issue-boards/board/lists{/id}': {}
+ },
+ 'DELETE': {
+ '/test/issue-boards/board/lists{/id}': {}
+ }
+};
+
+Vue.http.interceptors.push((request, next) => {
+ const body = BoardsMockData[request.method][request.url];
+
+ next(request.respondWith(JSON.stringify(body), {
+ status: 200
+ }));
+});
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
new file mode 100644
index 00000000000..39d892c18c0
--- /dev/null
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe Gitlab::Conflict::FileCollection, lib: true do
+ let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
+ let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) }
+
+ describe '#files' do
+ it 'returns an array of Conflict::Files' do
+ expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File))
+ end
+ end
+
+ describe '#default_commit_message' do
+ it 'matches the format of the git CLI commit message' do
+ expect(file_collection.default_commit_message).to eq(<<EOM.chomp)
+Merge branch 'conflict-start' into 'conflict-resolvable'
+
+# Conflicts:
+# files/ruby/popen.rb
+# files/ruby/regex.rb
+EOM
+ end
+ end
+end
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
new file mode 100644
index 00000000000..60020487061
--- /dev/null
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -0,0 +1,261 @@
+require 'spec_helper'
+
+describe Gitlab::Conflict::File, lib: true do
+ let(:project) { create(:project) }
+ let(:repository) { project.repository }
+ let(:rugged) { repository.rugged }
+ let(:their_commit) { rugged.branches['conflict-start'].target }
+ let(:our_commit) { rugged.branches['conflict-resolvable'].target }
+ let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) }
+ let(:index) { rugged.merge_commits(our_commit, their_commit) }
+ let(:conflict) { index.conflicts.last }
+ let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
+ let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) }
+
+ describe '#resolve_lines' do
+ let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
+
+ context 'when resolving everything to the same side' do
+ let(:resolution_hash) { section_keys.map { |key| [key, 'head'] }.to_h }
+ let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
+ let(:expected_lines) { conflict_file.lines.reject { |line| line.type == 'old' } }
+
+ it 'has the correct number of lines' do
+ expect(resolved_lines.length).to eq(expected_lines.length)
+ end
+
+ it 'has content matching the chosen lines' do
+ expect(resolved_lines.map(&:text)).to eq(expected_lines.map(&:text))
+ end
+ end
+
+ context 'with mixed resolutions' do
+ let(:resolution_hash) do
+ section_keys.map.with_index { |key, i| [key, i.even? ? 'head' : 'origin'] }.to_h
+ end
+
+ let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
+
+ it 'has the correct number of lines' do
+ file_lines = conflict_file.lines.reject { |line| line.type == 'new' }
+
+ expect(resolved_lines.length).to eq(file_lines.length)
+ end
+
+ it 'returns a file containing only the chosen parts of the resolved sections' do
+ expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)).
+ to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both'])
+ end
+ end
+
+ it 'raises MissingResolution when passed a hash without resolutions for all sections' do
+ empty_hash = section_keys.map { |key| [key, nil] }.to_h
+ invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
+
+ expect { conflict_file.resolve_lines({}) }.
+ to raise_error(Gitlab::Conflict::File::MissingResolution)
+
+ expect { conflict_file.resolve_lines(empty_hash) }.
+ to raise_error(Gitlab::Conflict::File::MissingResolution)
+
+ expect { conflict_file.resolve_lines(invalid_hash) }.
+ to raise_error(Gitlab::Conflict::File::MissingResolution)
+ end
+ end
+
+ describe '#highlight_lines!' do
+ def html_to_text(html)
+ CGI.unescapeHTML(ActionView::Base.full_sanitizer.sanitize(html)).delete("\n")
+ end
+
+ it 'modifies the existing lines' do
+ expect { conflict_file.highlight_lines! }.to change { conflict_file.lines.map(&:instance_variables) }
+ end
+
+ it 'is called implicitly when rich_text is accessed on a line' do
+ expect(conflict_file).to receive(:highlight_lines!).once.and_call_original
+
+ conflict_file.lines.each(&:rich_text)
+ end
+
+ it 'sets the rich_text of the lines matching the text content' do
+ conflict_file.lines.each do |line|
+ expect(line.text).to eq(html_to_text(line.rich_text))
+ end
+ end
+ end
+
+ describe '#sections' do
+ it 'only inserts match lines when there is a gap between sections' do
+ conflict_file.sections.each_with_index do |section, i|
+ previous_line_number = 0
+ current_line_number = section[:lines].map(&:old_line).compact.min
+
+ if i > 0
+ previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last
+ end
+
+ if current_line_number == previous_line_number + 1
+ expect(section[:lines].first.type).not_to eq('match')
+ else
+ expect(section[:lines].first.type).to eq('match')
+ expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/)
+ end
+ end
+ end
+
+ it 'sets conflict to false for sections with only unchanged lines' do
+ conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
+ without_match = section[:lines].reject { |line| line.type == 'match' }
+
+ expect(without_match).to all(have_attributes(type: nil))
+ end
+ end
+
+ it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do
+ conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
+ without_match = section[:lines].reject { |line| line.type == 'match' }
+
+ expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2
+ end
+ end
+
+ it 'sets conflict to true for sections with only changed lines' do
+ conflict_file.sections.select { |section| section[:conflict] }.each do |section|
+ section[:lines].each do |line|
+ expect(line.type).to be_in(['new', 'old'])
+ end
+ end
+ end
+
+ it 'adds unique IDs to conflict sections, and not to other sections' do
+ section_ids = []
+
+ conflict_file.sections.each do |section|
+ if section[:conflict]
+ expect(section).to have_key(:id)
+ section_ids << section[:id]
+ else
+ expect(section).not_to have_key(:id)
+ end
+ end
+
+ expect(section_ids.uniq).to eq(section_ids)
+ end
+
+ context 'with an example file' do
+ let(:file) do
+ <<FILE
+ # Ensure there is no match line header here
+ def username_regexp
+ default_regexp
+ end
+
+<<<<<<< files/ruby/regex.rb
+def project_name_regexp
+ /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
+end
+
+def name_regexp
+ /\A[a-zA-Z0-9_\-\. ]*\z/
+=======
+def project_name_regex
+ %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
+end
+
+def name_regex
+ %r{\A[a-zA-Z0-9_\-\. ]*\z}
+>>>>>>> files/ruby/regex.rb
+end
+
+# Some extra lines
+# To force a match line
+# To be created
+
+def path_regexp
+ default_regexp
+end
+
+<<<<<<< files/ruby/regex.rb
+def archive_formats_regexp
+ /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
+=======
+def archive_formats_regex
+ %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
+>>>>>>> files/ruby/regex.rb
+end
+
+def git_reference_regexp
+ # Valid git ref regexp, see:
+ # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
+ %r{
+ (?!
+ (?# doesn't begins with)
+ \/| (?# rule #6)
+ (?# doesn't contain)
+ .*(?:
+ [\/.]\.| (?# rule #1,3)
+ \/\/| (?# rule #6)
+ @\{| (?# rule #8)
+ \\ (?# rule #9)
+ )
+ )
+ [^\000-\040\177~^:?*\[]+ (?# rule #4-5)
+ (?# doesn't end with)
+ (?<!\.lock) (?# rule #1)
+ (?<![\/.]) (?# rule #6-7)
+ }x
+end
+
+protected
+
+<<<<<<< files/ruby/regex.rb
+def default_regexp
+ /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
+=======
+def default_regex
+ %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
+>>>>>>> files/ruby/regex.rb
+end
+FILE
+ end
+
+ let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) }
+ let(:sections) { conflict_file.sections }
+
+ it 'sets the correct match line headers' do
+ expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@')
+ expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp')
+ expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end')
+ end
+
+ it 'does not add match lines where they are not needed' do
+ expect(sections[1][:lines].first.type).not_to eq('match')
+ expect(sections[2][:lines].first.type).not_to eq('match')
+ expect(sections[4][:lines].first.type).not_to eq('match')
+ expect(sections[5][:lines].first.type).not_to eq('match')
+ expect(sections[7][:lines].first.type).not_to eq('match')
+ end
+
+ it 'creates context sections of the correct length' do
+ expect(sections[0][:lines].reject(&:type).length).to eq(3)
+ expect(sections[2][:lines].reject(&:type).length).to eq(3)
+ expect(sections[3][:lines].reject(&:type).length).to eq(3)
+ expect(sections[5][:lines].reject(&:type).length).to eq(3)
+ expect(sections[6][:lines].reject(&:type).length).to eq(3)
+ expect(sections[8][:lines].reject(&:type).length).to eq(1)
+ end
+ end
+ end
+
+ describe '#as_json' do
+ it 'includes the blob path for the file' do
+ expect(conflict_file.as_json[:blob_path]).
+ to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb")
+ end
+
+ it 'includes the blob icon for the file' do
+ expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb
new file mode 100644
index 00000000000..65a828accde
--- /dev/null
+++ b/spec/lib/gitlab/conflict/parser_spec.rb
@@ -0,0 +1,188 @@
+require 'spec_helper'
+
+describe Gitlab::Conflict::Parser, lib: true do
+ let(:parser) { Gitlab::Conflict::Parser.new }
+
+ describe '#parse' do
+ def parse_text(text)
+ parser.parse(text, our_path: 'README.md', their_path: 'README.md')
+ end
+
+ context 'when the file has valid conflicts' do
+ let(:text) do
+ <<CONFLICT
+module Gitlab
+ module Regexp
+ extend self
+
+ def username_regexp
+ default_regexp
+ end
+
+<<<<<<< files/ruby/regex.rb
+ def project_name_regexp
+ /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
+ end
+
+ def name_regexp
+ /\A[a-zA-Z0-9_\-\. ]*\z/
+=======
+ def project_name_regex
+ %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
+ end
+
+ def name_regex
+ %r{\A[a-zA-Z0-9_\-\. ]*\z}
+>>>>>>> files/ruby/regex.rb
+ end
+
+ def path_regexp
+ default_regexp
+ end
+
+<<<<<<< files/ruby/regex.rb
+ def archive_formats_regexp
+ /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
+=======
+ def archive_formats_regex
+ %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
+>>>>>>> files/ruby/regex.rb
+ end
+
+ def git_reference_regexp
+ # Valid git ref regexp, see:
+ # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
+ %r{
+ (?!
+ (?# doesn't begins with)
+ \/| (?# rule #6)
+ (?# doesn't contain)
+ .*(?:
+ [\/.]\.| (?# rule #1,3)
+ \/\/| (?# rule #6)
+ @\{| (?# rule #8)
+ \\ (?# rule #9)
+ )
+ )
+ [^\000-\040\177~^:?*\[]+ (?# rule #4-5)
+ (?# doesn't end with)
+ (?<!\.lock) (?# rule #1)
+ (?<![\/.]) (?# rule #6-7)
+ }x
+ end
+
+ protected
+
+<<<<<<< files/ruby/regex.rb
+ def default_regexp
+ /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
+=======
+ def default_regex
+ %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
+>>>>>>> files/ruby/regex.rb
+ end
+ end
+end
+CONFLICT
+ end
+
+ let(:lines) do
+ parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
+ end
+
+ it 'sets our lines as new lines' do
+ expect(lines[8..13]).to all(have_attributes(type: 'new'))
+ expect(lines[26..27]).to all(have_attributes(type: 'new'))
+ expect(lines[56..57]).to all(have_attributes(type: 'new'))
+ end
+
+ it 'sets their lines as old lines' do
+ expect(lines[14..19]).to all(have_attributes(type: 'old'))
+ expect(lines[28..29]).to all(have_attributes(type: 'old'))
+ expect(lines[58..59]).to all(have_attributes(type: 'old'))
+ end
+
+ it 'sets non-conflicted lines as both' do
+ expect(lines[0..7]).to all(have_attributes(type: nil))
+ expect(lines[20..25]).to all(have_attributes(type: nil))
+ expect(lines[30..55]).to all(have_attributes(type: nil))
+ expect(lines[60..62]).to all(have_attributes(type: nil))
+ end
+
+ it 'sets consecutive line numbers for index, old_pos, and new_pos' do
+ old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos)
+ new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos)
+
+ expect(lines.map(&:index)).to eq(0.upto(62).to_a)
+ expect(old_line_numbers).to eq(1.upto(53).to_a)
+ expect(new_line_numbers).to eq(1.upto(53).to_a)
+ end
+ end
+
+ context 'when the file contents include conflict delimiters' do
+ it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do
+ expect { parse_text('=======') }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text('>>>>>>> README.md') }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text('>>>>>>> some-other-path.md') }.
+ not_to raise_error
+ end
+
+ it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do
+ start_text = "<<<<<<< README.md\n"
+ end_text = "\n=======\n>>>>>>> README.md"
+
+ expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text(start_text + start_text + end_text) }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
+ not_to raise_error
+ end
+
+ it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do
+ start_text = "<<<<<<< README.md\n=======\n"
+ end_text = "\n>>>>>>> README.md"
+
+ expect { parse_text(start_text + '=======' + end_text) }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text(start_text + start_text + end_text) }.
+ to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+ expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
+ not_to raise_error
+ end
+
+ it 'raises MissingEndDelimiter when there is no end delimiter at the end' do
+ start_text = "<<<<<<< README.md\n=======\n"
+
+ expect { parse_text(start_text) }.
+ to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+
+ expect { parse_text(start_text + '>>>>>>> some-other-path.md') }.
+ to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ end
+ end
+
+ context 'other file types' do
+ it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
+ expect { parse_text('') }.
+ to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+
+ expect { parse_text(nil) }.
+ to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ end
+
+ it 'raises UnmergeableFile when the file is over 100 KB' do
+ expect { parse_text('a' * 102401) }.
+ to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb
index f718d536130..f26fca52c50 100644
--- a/spec/lib/gitlab/metrics/metric_spec.rb
+++ b/spec/lib/gitlab/metrics/metric_spec.rb
@@ -23,6 +23,24 @@ describe Gitlab::Metrics::Metric do
it { is_expected.to eq({ host: 'localtoast' }) }
end
+ describe '#type' do
+ subject { metric.type }
+
+ it { is_expected.to eq(:metric) }
+ end
+
+ describe '#event?' do
+ it 'returns false for a regular metric' do
+ expect(metric.event?).to eq(false)
+ end
+
+ it 'returns true for an event metric' do
+ expect(metric).to receive(:type).and_return(:event)
+
+ expect(metric.event?).to eq(true)
+ end
+ end
+
describe '#to_hash' do
it 'returns a Hash' do
expect(metric.to_hash).to be_an_instance_of(Hash)
diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
index f264ed64029..a30cb2a5e38 100644
--- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
@@ -45,6 +45,15 @@ describe Gitlab::Metrics::RackMiddleware do
middleware.call(env)
end
+
+ it 'tracks any raised exceptions' do
+ expect(app).to receive(:call).with(env).and_raise(RuntimeError)
+
+ expect_any_instance_of(Gitlab::Metrics::Transaction).
+ to receive(:add_event).with(:rails_exception)
+
+ expect { middleware.call(env) }.to raise_error(RuntimeError)
+ end
end
describe '#transaction_from_env' do
diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
index 4d2aa03e722..acaba785606 100644
--- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
@@ -12,7 +12,9 @@ describe Gitlab::Metrics::SidekiqMiddleware do
with('TestWorker#perform').
and_call_original
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float))
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
+ with(:sidekiq_queue_duration, instance_of(Float))
+
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
middleware.call(worker, message, :test) { nil }
@@ -25,10 +27,28 @@ describe Gitlab::Metrics::SidekiqMiddleware do
with('TestWorker#perform').
and_call_original
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float))
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
+ with(:sidekiq_queue_duration, instance_of(Float))
+
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
middleware.call(worker, {}, :test) { nil }
end
+
+ it 'tracks any raised exceptions' do
+ worker = double(:worker, class: double(:class, name: 'TestWorker'))
+
+ expect_any_instance_of(Gitlab::Metrics::Transaction).
+ to receive(:run).and_raise(RuntimeError)
+
+ expect_any_instance_of(Gitlab::Metrics::Transaction).
+ to receive(:add_event).with(:sidekiq_exception)
+
+ expect_any_instance_of(Gitlab::Metrics::Transaction).
+ to receive(:finish)
+
+ expect { middleware.call(worker, message, :test) }.
+ to raise_error(RuntimeError)
+ end
end
end
diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb
index f1a191d9410..3887c04c832 100644
--- a/spec/lib/gitlab/metrics/transaction_spec.rb
+++ b/spec/lib/gitlab/metrics/transaction_spec.rb
@@ -142,5 +142,62 @@ describe Gitlab::Metrics::Transaction do
transaction.submit
end
+
+ it 'does not add an action tag for events' do
+ transaction.action = 'Foo#bar'
+ transaction.add_event(:meow)
+
+ hash = {
+ series: 'events',
+ tags: { event: :meow },
+ values: { count: 1 },
+ timestamp: an_instance_of(Fixnum)
+ }
+
+ expect(Gitlab::Metrics).to receive(:submit_metrics).
+ with([hash])
+
+ transaction.submit
+ end
+ end
+
+ describe '#add_event' do
+ it 'adds a metric' do
+ transaction.add_event(:meow)
+
+ expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric)
+ end
+
+ it "does not prefix the metric's series name" do
+ transaction.add_event(:meow)
+
+ metric = transaction.metrics[0]
+
+ expect(metric.series).to eq(described_class::EVENT_SERIES)
+ end
+
+ it 'tracks a counter for every event' do
+ transaction.add_event(:meow)
+
+ metric = transaction.metrics[0]
+
+ expect(metric.values).to eq(count: 1)
+ end
+
+ it 'tracks the event name' do
+ transaction.add_event(:meow)
+
+ metric = transaction.metrics[0]
+
+ expect(metric.tags).to eq(event: :meow)
+ end
+
+ it 'allows tracking of custom tags' do
+ transaction.add_event(:meow, animal: 'cat')
+
+ metric = transaction.metrics[0]
+
+ expect(metric.tags).to eq(event: :meow, animal: 'cat')
+ end
end
end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
index 84f9475a0f8..ab6e311b1e8 100644
--- a/spec/lib/gitlab/metrics_spec.rb
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -153,4 +153,28 @@ describe Gitlab::Metrics do
expect(described_class.series_prefix).to be_an_instance_of(String)
end
end
+
+ describe '.add_event' do
+ context 'without a transaction' do
+ it 'does nothing' do
+ expect_any_instance_of(Gitlab::Metrics::Transaction).
+ not_to receive(:add_event)
+
+ Gitlab::Metrics.add_event(:meow)
+ end
+ end
+
+ context 'with a transaction' do
+ it 'adds an event' do
+ transaction = Gitlab::Metrics::Transaction.new
+
+ expect(transaction).to receive(:add_event).with(:meow)
+
+ expect(Gitlab::Metrics).to receive(:current_transaction).
+ and_return(transaction)
+
+ Gitlab::Metrics.add_event(:meow)
+ end
+ end
+ end
end
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
new file mode 100644
index 00000000000..12d29540137
--- /dev/null
+++ b/spec/models/board_spec.rb
@@ -0,0 +1,12 @@
+require 'rails_helper'
+
+describe Board do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+end
diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb
index 2a09063f857..5a5d1a5d60c 100644
--- a/spec/models/label_spec.rb
+++ b/spec/models/label_spec.rb
@@ -5,8 +5,10 @@ describe Label, models: true do
describe 'associations' do
it { is_expected.to belong_to(:project) }
+
it { is_expected.to have_many(:label_links).dependent(:destroy) }
it { is_expected.to have_many(:issues).through(:label_links).source(:target) }
+ it { is_expected.to have_many(:lists).dependent(:destroy) }
end
describe 'modules' do
diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb
new file mode 100644
index 00000000000..9e1a52011c3
--- /dev/null
+++ b/spec/models/list_spec.rb
@@ -0,0 +1,117 @@
+require 'rails_helper'
+
+describe List do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:board) }
+ it { is_expected.to belong_to(:label) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:board) }
+ it { is_expected.to validate_presence_of(:label) }
+ it { is_expected.to validate_presence_of(:list_type) }
+ it { is_expected.to validate_presence_of(:position) }
+ it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) }
+
+ it 'validates uniqueness of label scoped to board_id' do
+ create(:list)
+
+ expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id)
+ end
+
+ context 'when list_type is set to backlog' do
+ subject { described_class.new(list_type: :backlog) }
+
+ it { is_expected.not_to validate_presence_of(:label) }
+ it { is_expected.not_to validate_presence_of(:position) }
+ end
+
+ context 'when list_type is set to done' do
+ subject { described_class.new(list_type: :done) }
+
+ it { is_expected.not_to validate_presence_of(:label) }
+ it { is_expected.not_to validate_presence_of(:position) }
+ end
+ end
+
+ describe '#destroy' do
+ it 'can be destroyed when when list_type is set to label' do
+ subject = create(:list)
+
+ expect(subject.destroy).to be_truthy
+ end
+
+ it 'can not be destroyed when list_type is set to backlog' do
+ subject = create(:backlog_list)
+
+ expect(subject.destroy).to be_falsey
+ end
+
+ it 'can not be destroyed when when list_type is set to done' do
+ subject = create(:done_list)
+
+ expect(subject.destroy).to be_falsey
+ end
+ end
+
+ describe '#destroyable?' do
+ it 'retruns true when list_type is set to label' do
+ subject.list_type = :label
+
+ expect(subject).to be_destroyable
+ end
+
+ it 'retruns false when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject).not_to be_destroyable
+ end
+
+ it 'retruns false when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject).not_to be_destroyable
+ end
+ end
+
+ describe '#movable?' do
+ it 'retruns true when list_type is set to label' do
+ subject.list_type = :label
+
+ expect(subject).to be_movable
+ end
+
+ it 'retruns false when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject).not_to be_movable
+ end
+
+ it 'retruns false when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject).not_to be_movable
+ end
+ end
+
+ describe '#title' do
+ it 'returns label name when list_type is set to label' do
+ subject.list_type = :label
+ subject.label = Label.new(name: 'Development')
+
+ expect(subject.title).to eq 'Development'
+ end
+
+ it 'returns Backlog when list_type is set to backlog' do
+ subject.list_type = :backlog
+
+ expect(subject.title).to eq 'Backlog'
+ end
+
+ it 'returns Done when list_type is set to done' do
+ subject.list_type = :done
+
+ expect(subject.title).to eq 'Done'
+ end
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 00c528c8cde..64c56d922ff 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -456,6 +456,20 @@ describe MergeRequest, models: true do
subject { create :merge_request, :simple }
end
+ describe '#commits_sha' do
+ let(:commit0) { double('commit0', sha: 'sha1') }
+ let(:commit1) { double('commit1', sha: 'sha2') }
+ let(:commit2) { double('commit2', sha: 'sha3') }
+
+ before do
+ allow(subject.merge_request_diff).to receive(:commits).and_return([commit0, commit1, commit2])
+ end
+
+ it 'returns sha of commits' do
+ expect(subject.commits_sha).to contain_exactly('sha1', 'sha2', 'sha3')
+ end
+ end
+
describe '#pipeline' do
describe 'when the source project exists' do
it 'returns the latest pipeline' do
@@ -480,6 +494,19 @@ describe MergeRequest, models: true do
end
end
+ describe '#all_pipelines' do
+ let!(:pipelines) do
+ subject.merge_request_diff.commits.map do |commit|
+ create(:ci_empty_pipeline, project: subject.source_project, sha: commit.id, ref: subject.source_branch)
+ end
+ end
+
+ it 'returns a pipelines from source projects with proper ordering' do
+ expect(subject.all_pipelines).not_to be_empty
+ expect(subject.all_pipelines).to eq(pipelines.reverse)
+ end
+ end
+
describe '#participants' do
let(:project) { create(:project, :public) }
@@ -848,4 +875,56 @@ describe MergeRequest, models: true do
end
end
end
+
+ describe '#conflicts_can_be_resolved_in_ui?' do
+ def create_merge_request(source_branch)
+ create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
+ mr.mark_as_unmergeable
+ end
+ end
+
+ it 'returns a falsey value when the MR can be merged without conflicts' do
+ merge_request = create_merge_request('master')
+ merge_request.mark_as_mergeable
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR does not support new diff notes' do
+ merge_request = create_merge_request('conflict-resolvable')
+ merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a large file' do
+ merge_request = create_merge_request('conflict-too-large')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a binary file' do
+ merge_request = create_merge_request('conflict-binary-file')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do
+ merge_request = create_merge_request('conflict-contains-conflict-markers')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
+ merge_request = create_merge_request('conflict-missing-side')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a truthy value when the conflicts are resolvable in the UI' do
+ merge_request = create_merge_request('conflict-resolvable')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
+ end
+ end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 0a32a486703..d1f3a815290 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -23,6 +23,7 @@ describe Project, models: true do
it { is_expected.to have_one(:slack_service).dependent(:destroy) }
it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
it { is_expected.to have_one(:asana_service).dependent(:destroy) }
+ it { is_expected.to have_one(:board).dependent(:destroy) }
it { is_expected.to have_many(:commit_statuses) }
it { is_expected.to have_many(:pipelines) }
it { is_expected.to have_many(:builds) }
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
new file mode 100644
index 00000000000..a1a4dd4c57c
--- /dev/null
+++ b/spec/services/boards/create_service_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe Boards::CreateService, services: true do
+ describe '#execute' do
+ subject(:service) { described_class.new(project, double) }
+
+ context 'when project does not have a board' do
+ let(:project) { create(:empty_project, board: nil) }
+
+ it 'creates a new board' do
+ expect { service.execute }.to change(Board, :count).by(1)
+ end
+
+ it 'creates default lists' do
+ service.execute
+
+ expect(project.board.lists.size).to eq 2
+ expect(project.board.lists.first).to be_backlog
+ expect(project.board.lists.last).to be_done
+ end
+ end
+
+ context 'when project has a board' do
+ let!(:project) { create(:project_with_board) }
+
+ it 'does not create a new board' do
+ expect { service.execute }.not_to change(Board, :count)
+ end
+
+ it 'does not create board lists' do
+ expect { service.execute }.not_to change(project.board.lists, :count)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
new file mode 100644
index 00000000000..f7f45983d26
--- /dev/null
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+describe Boards::Issues::ListService, services: true do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+ let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
+ let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
+ let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:list1) { create(:list, board: board, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let!(:done) { create(:done_list, board: board) }
+
+ let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) }
+ let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) }
+ let!(:reopened_issue1) { create(:issue, :reopened, project: project) }
+
+ let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) }
+ let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) }
+ let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) }
+ let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) }
+
+ let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
+ let!(:closed_issue3) { create(:issue, :closed, project: project) }
+ let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'delegates search to IssuesFinder' do
+ params = { id: list1.id }
+
+ expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+
+ described_class.new(project, user, params).execute
+ end
+
+ context 'sets default order to priority' do
+ it 'returns opened issues when listing issues from Backlog' do
+ params = { id: backlog.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
+ end
+
+ it 'returns closed issues when listing issues from Done' do
+ params = { id: done.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1]
+ end
+
+ it 'returns opened issues that have label list applied when listing issues from a label list' do
+ params = { id: list1.id }
+
+ issues = described_class.new(project, user, params).execute
+
+ expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb
new file mode 100644
index 00000000000..0122159cab8
--- /dev/null
+++ b/spec/services/boards/issues/move_service_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe Boards::Issues::MoveService, services: true do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:list1) { create(:list, board: board, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let!(:done) { create(:done_list, board: board) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'when moving from backlog' do
+ it 'adds the label of the list it goes to' do
+ issue = create(:labeled_issue, project: project, labels: [bug])
+ params = { from_list_id: backlog.id, to_list_id: list1.id }
+
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, development)
+ end
+ end
+
+ context 'when moving to backlog' do
+ it 'removes all list-labels' do
+ issue = create(:labeled_issue, project: project, labels: [bug, development, testing])
+ params = { from_list_id: list1.id, to_list_id: backlog.id }
+
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug)
+ end
+ end
+
+ context 'when moving from backlog to done' do
+ it 'closes the issue' do
+ issue = create(:labeled_issue, project: project, labels: [bug])
+ params = { from_list_id: backlog.id, to_list_id: done.id }
+
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_closed
+ end
+ end
+
+ context 'when moving an issue between lists' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { from_list_id: list1.id, to_list_id: list2.id } }
+
+ it 'delegates the label changes to Issues::UpdateService' do
+ expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'removes the label from the list it came from and adds the label of the list it goes to' do
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, testing)
+ end
+ end
+
+ context 'when moving to done' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing]) }
+ let(:params) { { from_list_id: list2.id, to_list_id: done.id } }
+
+ it 'delegates the close proceedings to Issues::CloseService' do
+ expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'removes all list-labels and close the issue' do
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_closed
+ end
+ end
+
+ context 'when moving from done' do
+ let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let(:params) { { from_list_id: done.id, to_list_id: list2.id } }
+
+ it 'delegates the re-open proceedings to Issues::ReopenService' do
+ expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
+
+ described_class.new(project, user, params).execute(issue)
+ end
+
+ it 'adds the label of the list it goes to and reopen the issue' do
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug, testing)
+ expect(issue).to be_reopened
+ end
+ end
+
+ context 'when moving from done to backlog' do
+ it 'reopens the issue' do
+ issue = create(:labeled_issue, :closed, project: project, labels: [bug])
+ params = { from_list_id: done.id, to_list_id: backlog.id }
+
+ described_class.new(project, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_reopened
+ end
+ end
+
+ context 'when moving to same list' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { from_list_id: list1.id, to_list_id: list1.id } }
+
+ it 'returns false' do
+ expect(described_class.new(project, user, params).execute(issue)).to eq false
+ end
+
+ it 'keeps issues labels' do
+ described_class.new(project, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, development)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb
new file mode 100644
index 00000000000..5e7e145065e
--- /dev/null
+++ b/spec/services/boards/lists/create_service_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe Boards::Lists::CreateService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+ let(:label) { create(:label, name: 'in-progress') }
+
+ subject(:service) { described_class.new(project, user, label_id: label.id) }
+
+ context 'when board lists is empty' do
+ it 'creates a new list at beginning of the list' do
+ list = service.execute
+
+ expect(list.position).to eq 0
+ end
+ end
+
+ context 'when board lists has only a backlog list' do
+ it 'creates a new list at beginning of the list' do
+ create(:backlog_list, board: board)
+
+ list = service.execute
+
+ expect(list.position).to eq 0
+ end
+ end
+
+ context 'when board lists has only labels lists' do
+ it 'creates a new list at end of the lists' do
+ create(:list, board: board, position: 0)
+ create(:list, board: board, position: 1)
+
+ list = service.execute
+
+ expect(list.position).to eq 2
+ end
+ end
+
+ context 'when board lists has backlog, label and done lists' do
+ it 'creates a new list at end of the label lists' do
+ create(:backlog_list, board: board)
+ create(:done_list, board: board)
+ list1 = create(:list, board: board, position: 0)
+
+ list2 = service.execute
+
+ expect(list1.reload.position).to eq 0
+ expect(list2.reload.position).to eq 1
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb
new file mode 100644
index 00000000000..6eff445feee
--- /dev/null
+++ b/spec/services/boards/lists/destroy_service_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+describe Boards::Lists::DestroyService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ context 'when list type is label' do
+ it 'removes list from board' do
+ list = create(:list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.to change(board.lists, :count).by(-1)
+ end
+
+ it 'decrements position of higher lists' do
+ backlog = create(:backlog_list, board: board)
+ development = create(:list, board: board, position: 0)
+ review = create(:list, board: board, position: 1)
+ staging = create(:list, board: board, position: 2)
+ done = create(:done_list, board: board)
+
+ described_class.new(project, user).execute(development)
+
+ expect(backlog.reload.position).to be_nil
+ expect(review.reload.position).to eq 0
+ expect(staging.reload.position).to eq 1
+ expect(done.reload.position).to be_nil
+ end
+ end
+
+ it 'does not remove list from board when list type is backlog' do
+ list = create(:backlog_list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.not_to change(board.lists, :count)
+ end
+
+ it 'does not remove list from board when list type is done' do
+ list = create(:done_list, board: board)
+ service = described_class.new(project, user)
+
+ expect { service.execute(list) }.not_to change(board.lists, :count)
+ end
+ end
+end
diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb
new file mode 100644
index 00000000000..9fd39122737
--- /dev/null
+++ b/spec/services/boards/lists/generate_service_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Boards::Lists::GenerateService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ subject(:service) { described_class.new(project, user) }
+
+ context 'when board lists is empty' do
+ it 'creates the default lists' do
+ expect { service.execute }.to change(board.lists, :count).by(4)
+ end
+ end
+
+ context 'when board lists is not empty' do
+ it 'does not creates the default lists' do
+ create(:list, board: board)
+
+ expect { service.execute }.not_to change(board.lists, :count)
+ end
+ end
+
+ context 'when project labels does not contains any list label' do
+ it 'creates labels' do
+ expect { service.execute }.to change(project.labels, :count).by(4)
+ end
+ end
+
+ context 'when project labels contains some of list label' do
+ it 'creates the missing labels' do
+ create(:label, project: project, name: 'Development')
+ create(:label, project: project, name: 'Ready')
+
+ expect { service.execute }.to change(project.labels, :count).by(2)
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb
new file mode 100644
index 00000000000..3e9b7d07fc6
--- /dev/null
+++ b/spec/services/boards/lists/move_service_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe Boards::Lists::MoveService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let!(:planning) { create(:list, board: board, position: 0) }
+ let!(:development) { create(:list, board: board, position: 1) }
+ let!(:review) { create(:list, board: board, position: 2) }
+ let!(:staging) { create(:list, board: board, position: 3) }
+ let!(:done) { create(:done_list, board: board) }
+
+ context 'when list type is set to label' do
+ it 'keeps position of lists when new position is nil' do
+ service = described_class.new(project, user, position: nil)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to old position' do
+ service = described_class.new(project, user, position: planning.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is negative' do
+ service = described_class.new(project, user, position: -1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to number of labels lists' do
+ service = described_class.new(project, user, position: board.lists.label.size)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is greater than number of labels lists' do
+ service = described_class.new(project, user, position: board.lists.label.size + 1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'increments position of intermediate lists when new positon is equal to first position' do
+ service = described_class.new(project, user, position: 0)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [1, 2, 3, 0]
+ end
+
+ it 'decrements position of intermediate lists when new positon is equal to last position' do
+ service = described_class.new(project, user, position: board.lists.label.last.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [3, 0, 1, 2]
+ end
+
+ it 'decrements position of intermediate lists when new position is greater than old position' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [2, 0, 1, 3]
+ end
+
+ it 'increments position of intermediate lists when new position is lower than old position' do
+ service = described_class.new(project, user, position: 1)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [0, 2, 3, 1]
+ end
+ end
+
+ it 'keeps position of lists when list type is backlog' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(backlog)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when list type is done' do
+ service = described_class.new(project, user, position: 2)
+
+ service.execute(done)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+ end
+
+ def current_list_positions
+ [planning, development, review, staging].map { |list| list.reload.position }
+ end
+end
diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb
new file mode 100644
index 00000000000..e42d727672b
--- /dev/null
+++ b/spec/support/api/schema_matcher.rb
@@ -0,0 +1,8 @@
+RSpec::Matchers.define :match_response_schema do |schema, **options|
+ match do |response|
+ schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
+ schema_path = "#{schema_directory}/#{schema}.json"
+
+ JSON::Validator.validate!(schema_path, response.body, options)
+ end
+end
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 1c0c66969e3..edbbfc3c9e5 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -5,25 +5,31 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = {
- 'empty-branch' => '7efb185',
- 'ends-with.json' => '98b0d8b3',
- 'flatten-dir' => 'e56497b',
- 'feature' => '0b4bc9a',
- 'feature_conflict' => 'bb5206f',
- 'fix' => '48f0be4',
- 'improve/awesome' => '5937ac0',
- 'markdown' => '0ed8c6c',
- 'lfs' => 'be93687',
- 'master' => '5937ac0',
- "'test'" => 'e56497b',
- 'orphaned-branch' => '45127a9',
- 'binary-encoding' => '7b1cf43',
- 'gitattributes' => '5a62481',
- 'expand-collapse-diffs' => '4842455',
- 'expand-collapse-files' => '025db92',
- 'expand-collapse-lines' => '238e82d',
- 'video' => '8879059',
- 'crlf-diff' => '5938907'
+ 'empty-branch' => '7efb185',
+ 'ends-with.json' => '98b0d8b3',
+ 'flatten-dir' => 'e56497b',
+ 'feature' => '0b4bc9a',
+ 'feature_conflict' => 'bb5206f',
+ 'fix' => '48f0be4',
+ 'improve/awesome' => '5937ac0',
+ 'markdown' => '0ed8c6c',
+ 'lfs' => 'be93687',
+ 'master' => '5937ac0',
+ "'test'" => 'e56497b',
+ 'orphaned-branch' => '45127a9',
+ 'binary-encoding' => '7b1cf43',
+ 'gitattributes' => '5a62481',
+ 'expand-collapse-diffs' => '4842455',
+ 'expand-collapse-files' => '025db92',
+ 'expand-collapse-lines' => '238e82d',
+ 'video' => '8879059',
+ 'crlf-diff' => '5938907',
+ 'conflict-start' => '14fa46b',
+ 'conflict-resolvable' => '1450cd6',
+ 'conflict-binary-file' => '259a6fb',
+ 'conflict-contains-conflict-markers' => '5e0964c',
+ 'conflict-missing-side' => 'eb227b3',
+ 'conflict-too-large' => '39fa04f',
}
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js
new file mode 100644
index 00000000000..eca7c5012b2
--- /dev/null
+++ b/vendor/assets/javascripts/Sortable.js
@@ -0,0 +1,1285 @@
+/**!
+ * Sortable
+ * @author RubaXa <trash@rubaxa.org>
+ * @license MIT
+ */
+
+
+(function (factory) {
+ "use strict";
+
+ if (typeof define === "function" && define.amd) {
+ define(factory);
+ }
+ else if (typeof module != "undefined" && typeof module.exports != "undefined") {
+ module.exports = factory();
+ }
+ else if (typeof Package !== "undefined") {
+ Sortable = factory(); // export for Meteor.js
+ }
+ else {
+ /* jshint sub:true */
+ window["Sortable"] = factory();
+ }
+})(function () {
+ "use strict";
+
+ var dragEl,
+ parentEl,
+ ghostEl,
+ cloneEl,
+ rootEl,
+ nextEl,
+
+ scrollEl,
+ scrollParentEl,
+
+ lastEl,
+ lastCSS,
+ lastParentCSS,
+
+ oldIndex,
+ newIndex,
+
+ activeGroup,
+ autoScroll = {},
+
+ tapEvt,
+ touchEvt,
+
+ moved,
+
+ /** @const */
+ RSPACE = /\s+/g,
+
+ expando = 'Sortable' + (new Date).getTime(),
+
+ win = window,
+ document = win.document,
+ parseInt = win.parseInt,
+
+ supportDraggable = !!('draggable' in document.createElement('div')),
+ supportCssPointerEvents = (function (el) {
+ el = document.createElement('x');
+ el.style.cssText = 'pointer-events:auto';
+ return el.style.pointerEvents === 'auto';
+ })(),
+
+ _silent = false,
+
+ abs = Math.abs,
+ min = Math.min,
+ slice = [].slice,
+
+ touchDragOverListeners = [],
+
+ _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) {
+ // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521
+ if (rootEl && options.scroll) {
+ var el,
+ rect,
+ sens = options.scrollSensitivity,
+ speed = options.scrollSpeed,
+
+ x = evt.clientX,
+ y = evt.clientY,
+
+ winWidth = window.innerWidth,
+ winHeight = window.innerHeight,
+
+ vx,
+ vy
+ ;
+
+ // Delect scrollEl
+ if (scrollParentEl !== rootEl) {
+ scrollEl = options.scroll;
+ scrollParentEl = rootEl;
+
+ if (scrollEl === true) {
+ scrollEl = rootEl;
+
+ do {
+ if ((scrollEl.offsetWidth < scrollEl.scrollWidth) ||
+ (scrollEl.offsetHeight < scrollEl.scrollHeight)
+ ) {
+ break;
+ }
+ /* jshint boss:true */
+ } while (scrollEl = scrollEl.parentNode);
+ }
+ }
+
+ if (scrollEl) {
+ el = scrollEl;
+ rect = scrollEl.getBoundingClientRect();
+ vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens);
+ vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens);
+ }
+
+
+ if (!(vx || vy)) {
+ vx = (winWidth - x <= sens) - (x <= sens);
+ vy = (winHeight - y <= sens) - (y <= sens);
+
+ /* jshint expr:true */
+ (vx || vy) && (el = win);
+ }
+
+
+ if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) {
+ autoScroll.el = el;
+ autoScroll.vx = vx;
+ autoScroll.vy = vy;
+
+ clearInterval(autoScroll.pid);
+
+ if (el) {
+ autoScroll.pid = setInterval(function () {
+ if (el === win) {
+ win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed);
+ } else {
+ vy && (el.scrollTop += vy * speed);
+ vx && (el.scrollLeft += vx * speed);
+ }
+ }, 24);
+ }
+ }
+ }
+ }, 30),
+
+ _prepareGroup = function (options) {
+ var group = options.group;
+
+ if (!group || typeof group != 'object') {
+ group = options.group = {name: group};
+ }
+
+ ['pull', 'put'].forEach(function (key) {
+ if (!(key in group)) {
+ group[key] = true;
+ }
+ });
+
+ options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' ';
+ }
+ ;
+
+
+
+ /**
+ * @class Sortable
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ function Sortable(el, options) {
+ if (!(el && el.nodeType && el.nodeType === 1)) {
+ throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el);
+ }
+
+ this.el = el; // root element
+ this.options = options = _extend({}, options);
+
+
+ // Export instance
+ el[expando] = this;
+
+
+ // Default options
+ var defaults = {
+ group: Math.random(),
+ sort: true,
+ disabled: false,
+ store: null,
+ handle: null,
+ scroll: true,
+ scrollSensitivity: 30,
+ scrollSpeed: 10,
+ draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*',
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ ignore: 'a, img',
+ filter: null,
+ animation: 0,
+ setData: function (dataTransfer, dragEl) {
+ dataTransfer.setData('Text', dragEl.textContent);
+ },
+ dropBubble: false,
+ dragoverBubble: false,
+ dataIdAttr: 'data-id',
+ delay: 0,
+ forceFallback: false,
+ fallbackClass: 'sortable-fallback',
+ fallbackOnBody: false,
+ fallbackTolerance: 0
+ };
+
+
+ // Set default options
+ for (var name in defaults) {
+ !(name in options) && (options[name] = defaults[name]);
+ }
+
+ _prepareGroup(options);
+
+ // Bind all private methods
+ for (var fn in this) {
+ if (fn.charAt(0) === '_') {
+ this[fn] = this[fn].bind(this);
+ }
+ }
+
+ // Setup drag mode
+ this.nativeDraggable = options.forceFallback ? false : supportDraggable;
+
+ // Bind events
+ _on(el, 'mousedown', this._onTapStart);
+ _on(el, 'touchstart', this._onTapStart);
+
+ if (this.nativeDraggable) {
+ _on(el, 'dragover', this);
+ _on(el, 'dragenter', this);
+ }
+
+ touchDragOverListeners.push(this._onDragOver);
+
+ // Restore sorting
+ options.store && this.sort(options.store.get(this));
+ }
+
+
+ Sortable.prototype = /** @lends Sortable.prototype */ {
+ constructor: Sortable,
+
+ _onTapStart: function (/** Event|TouchEvent */evt) {
+ var _this = this,
+ el = this.el,
+ options = this.options,
+ type = evt.type,
+ touch = evt.touches && evt.touches[0],
+ target = (touch || evt).target,
+ originalTarget = target,
+ filter = options.filter,
+ startIndex;
+
+ // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group.
+ if (dragEl) {
+ return;
+ }
+
+ if (type === 'mousedown' && evt.button !== 0 || options.disabled) {
+ return; // only left button or enabled
+ }
+
+ target = _closest(target, options.draggable, el);
+
+ if (!target) {
+ return;
+ }
+
+ if (options.handle && !_closest(originalTarget, options.handle, el)) {
+ return;
+ }
+
+ // Get the index of the dragged element within its parent
+ startIndex = _index(target, options.draggable);
+
+ // Check filter
+ if (typeof filter === 'function') {
+ if (filter.call(this, evt, target, this)) {
+ _dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex);
+ evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+ else if (filter) {
+ filter = filter.split(',').some(function (criteria) {
+ criteria = _closest(originalTarget, criteria.trim(), el);
+
+ if (criteria) {
+ _dispatchEvent(_this, criteria, 'filter', target, el, startIndex);
+ return true;
+ }
+ });
+
+ if (filter) {
+ evt.preventDefault();
+ return; // cancel dnd
+ }
+ }
+
+ // Prepare `dragstart`
+ this._prepareDragStart(evt, touch, target, startIndex);
+ },
+
+ _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) {
+ var _this = this,
+ el = _this.el,
+ options = _this.options,
+ ownerDocument = el.ownerDocument,
+ dragStartFn;
+
+ if (target && !dragEl && (target.parentNode === el)) {
+ tapEvt = evt;
+
+ rootEl = el;
+ dragEl = target;
+ parentEl = dragEl.parentNode;
+ nextEl = dragEl.nextSibling;
+ activeGroup = options.group;
+ oldIndex = startIndex;
+
+ this._lastX = (touch || evt).clientX;
+ this._lastY = (touch || evt).clientY;
+
+ dragStartFn = function () {
+ // Delayed drag has been triggered
+ // we can re-enable the events: touchmove/mousemove
+ _this._disableDelayedDrag();
+
+ // Make the element draggable
+ dragEl.draggable = true;
+
+ // Chosen item
+ _toggleClass(dragEl, _this.options.chosenClass, true);
+
+ // Bind the events: dragstart/dragend
+ _this._triggerDragStart(touch);
+
+ // Drag start event
+ _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex);
+ };
+
+ // Disable "draggable"
+ options.ignore.split(',').forEach(function (criteria) {
+ _find(dragEl, criteria.trim(), _disableDraggable);
+ });
+
+ _on(ownerDocument, 'mouseup', _this._onDrop);
+ _on(ownerDocument, 'touchend', _this._onDrop);
+ _on(ownerDocument, 'touchcancel', _this._onDrop);
+
+ if (options.delay) {
+ // If the user moves the pointer or let go the click or touch
+ // before the delay has been reached:
+ // disable the delayed drag
+ _on(ownerDocument, 'mouseup', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchend', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag);
+ _on(ownerDocument, 'mousemove', _this._disableDelayedDrag);
+ _on(ownerDocument, 'touchmove', _this._disableDelayedDrag);
+
+ _this._dragStartTimer = setTimeout(dragStartFn, options.delay);
+ } else {
+ dragStartFn();
+ }
+ }
+ },
+
+ _disableDelayedDrag: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ clearTimeout(this._dragStartTimer);
+ _off(ownerDocument, 'mouseup', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchend', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchcancel', this._disableDelayedDrag);
+ _off(ownerDocument, 'mousemove', this._disableDelayedDrag);
+ _off(ownerDocument, 'touchmove', this._disableDelayedDrag);
+ },
+
+ _triggerDragStart: function (/** Touch */touch) {
+ if (touch) {
+ // Touch device support
+ tapEvt = {
+ target: dragEl,
+ clientX: touch.clientX,
+ clientY: touch.clientY
+ };
+
+ this._onDragStart(tapEvt, 'touch');
+ }
+ else if (!this.nativeDraggable) {
+ this._onDragStart(tapEvt, true);
+ }
+ else {
+ _on(dragEl, 'dragend', this);
+ _on(rootEl, 'dragstart', this._onDragStart);
+ }
+
+ try {
+ if (document.selection) {
+ document.selection.empty();
+ } else {
+ window.getSelection().removeAllRanges();
+ }
+ } catch (err) {
+ }
+ },
+
+ _dragStarted: function () {
+ if (rootEl && dragEl) {
+ // Apply effect
+ _toggleClass(dragEl, this.options.ghostClass, true);
+
+ Sortable.active = this;
+
+ // Drag start event
+ _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex);
+ }
+ },
+
+ _emulateDragOver: function () {
+ if (touchEvt) {
+ if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) {
+ return;
+ }
+
+ this._lastX = touchEvt.clientX;
+ this._lastY = touchEvt.clientY;
+
+ if (!supportCssPointerEvents) {
+ _css(ghostEl, 'display', 'none');
+ }
+
+ var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY),
+ parent = target,
+ groupName = ' ' + this.options.group.name + '',
+ i = touchDragOverListeners.length;
+
+ if (parent) {
+ do {
+ if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) {
+ while (i--) {
+ touchDragOverListeners[i]({
+ clientX: touchEvt.clientX,
+ clientY: touchEvt.clientY,
+ target: target,
+ rootEl: parent
+ });
+ }
+
+ break;
+ }
+
+ target = parent; // store last element
+ }
+ /* jshint boss:true */
+ while (parent = parent.parentNode);
+ }
+
+ if (!supportCssPointerEvents) {
+ _css(ghostEl, 'display', '');
+ }
+ }
+ },
+
+
+ _onTouchMove: function (/**TouchEvent*/evt) {
+ if (tapEvt) {
+ var options = this.options,
+ fallbackTolerance = options.fallbackTolerance,
+ touch = evt.touches ? evt.touches[0] : evt,
+ dx = touch.clientX - tapEvt.clientX,
+ dy = touch.clientY - tapEvt.clientY,
+ translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';
+
+ // only set the status to dragging, when we are actually dragging
+ if (!Sortable.active) {
+ if (fallbackTolerance &&
+ min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance
+ ) {
+ return;
+ }
+
+ this._dragStarted();
+ }
+
+ // as well as creating the ghost element on the document body
+ this._appendGhost();
+
+ moved = true;
+ touchEvt = touch;
+
+ _css(ghostEl, 'webkitTransform', translate3d);
+ _css(ghostEl, 'mozTransform', translate3d);
+ _css(ghostEl, 'msTransform', translate3d);
+ _css(ghostEl, 'transform', translate3d);
+
+ evt.preventDefault();
+ }
+ },
+
+ _appendGhost: function () {
+ if (!ghostEl) {
+ var rect = dragEl.getBoundingClientRect(),
+ css = _css(dragEl),
+ options = this.options,
+ ghostRect;
+
+ ghostEl = dragEl.cloneNode(true);
+
+ _toggleClass(ghostEl, options.ghostClass, false);
+ _toggleClass(ghostEl, options.fallbackClass, true);
+
+ _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10));
+ _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10));
+ _css(ghostEl, 'width', rect.width);
+ _css(ghostEl, 'height', rect.height);
+ _css(ghostEl, 'opacity', '0.8');
+ _css(ghostEl, 'position', 'fixed');
+ _css(ghostEl, 'zIndex', '100000');
+ _css(ghostEl, 'pointerEvents', 'none');
+
+ options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl);
+
+ // Fixing dimensions.
+ ghostRect = ghostEl.getBoundingClientRect();
+ _css(ghostEl, 'width', rect.width * 2 - ghostRect.width);
+ _css(ghostEl, 'height', rect.height * 2 - ghostRect.height);
+ }
+ },
+
+ _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) {
+ var dataTransfer = evt.dataTransfer,
+ options = this.options;
+
+ this._offUpEvents();
+
+ if (activeGroup.pull == 'clone') {
+ cloneEl = dragEl.cloneNode(true);
+ _css(cloneEl, 'display', 'none');
+ rootEl.insertBefore(cloneEl, dragEl);
+ _dispatchEvent(this, rootEl, 'clone', dragEl);
+ }
+
+ if (useFallback) {
+ if (useFallback === 'touch') {
+ // Bind touch events
+ _on(document, 'touchmove', this._onTouchMove);
+ _on(document, 'touchend', this._onDrop);
+ _on(document, 'touchcancel', this._onDrop);
+ } else {
+ // Old brwoser
+ _on(document, 'mousemove', this._onTouchMove);
+ _on(document, 'mouseup', this._onDrop);
+ }
+
+ this._loopId = setInterval(this._emulateDragOver, 50);
+ }
+ else {
+ if (dataTransfer) {
+ dataTransfer.effectAllowed = 'move';
+ options.setData && options.setData.call(this, dataTransfer, dragEl);
+ }
+
+ _on(document, 'drop', this);
+ setTimeout(this._dragStarted, 0);
+ }
+ },
+
+ _onDragOver: function (/**Event*/evt) {
+ var el = this.el,
+ target,
+ dragRect,
+ revert,
+ options = this.options,
+ group = options.group,
+ groupPut = group.put,
+ isOwner = (activeGroup === group),
+ canSort = options.sort;
+
+ if (evt.preventDefault !== void 0) {
+ evt.preventDefault();
+ !options.dragoverBubble && evt.stopPropagation();
+ }
+
+ moved = true;
+
+ if (activeGroup && !options.disabled &&
+ (isOwner
+ ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list
+ : activeGroup.pull && groupPut && (
+ (activeGroup.name === group.name) || // by Name
+ (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array
+ )
+ ) &&
+ (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback
+ ) {
+ // Smart auto-scrolling
+ _autoScroll(evt, options, this.el);
+
+ if (_silent) {
+ return;
+ }
+
+ target = _closest(evt.target, options.draggable, el);
+ dragRect = dragEl.getBoundingClientRect();
+
+ if (revert) {
+ _cloneHide(true);
+ parentEl = rootEl; // actualization
+
+ if (cloneEl || nextEl) {
+ rootEl.insertBefore(dragEl, cloneEl || nextEl);
+ }
+ else if (!canSort) {
+ rootEl.appendChild(dragEl);
+ }
+
+ return;
+ }
+
+
+ if ((el.children.length === 0) || (el.children[0] === ghostEl) ||
+ (el === evt.target) && (target = _ghostIsLast(el, evt))
+ ) {
+
+ if (target) {
+ if (target.animated) {
+ return;
+ }
+
+ targetRect = target.getBoundingClientRect();
+ }
+
+ _cloneHide(isOwner);
+
+ if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) {
+ if (!dragEl.contains(el)) {
+ el.appendChild(dragEl);
+ parentEl = el; // actualization
+ }
+
+ this._animate(dragRect, dragEl);
+ target && this._animate(targetRect, target);
+ }
+ }
+ else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) {
+ if (lastEl !== target) {
+ lastEl = target;
+ lastCSS = _css(target);
+ lastParentCSS = _css(target.parentNode);
+ }
+
+
+ var targetRect = target.getBoundingClientRect(),
+ width = targetRect.right - targetRect.left,
+ height = targetRect.bottom - targetRect.top,
+ floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display)
+ || (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0),
+ isWide = (target.offsetWidth > dragEl.offsetWidth),
+ isLong = (target.offsetHeight > dragEl.offsetHeight),
+ halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5,
+ nextSibling = target.nextElementSibling,
+ moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect),
+ after
+ ;
+
+ if (moveVector !== false) {
+ _silent = true;
+ setTimeout(_unsilent, 30);
+
+ _cloneHide(isOwner);
+
+ if (moveVector === 1 || moveVector === -1) {
+ after = (moveVector === 1);
+ }
+ else if (floating) {
+ var elTop = dragEl.offsetTop,
+ tgTop = target.offsetTop;
+
+ if (elTop === tgTop) {
+ after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide;
+ }
+ else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) {
+ after = (evt.clientY - targetRect.top) / height > 0.5;
+ } else {
+ after = tgTop > elTop;
+ }
+ } else {
+ after = (nextSibling !== dragEl) && !isLong || halfway && isLong;
+ }
+
+ if (!dragEl.contains(el)) {
+ if (after && !nextSibling) {
+ el.appendChild(dragEl);
+ } else {
+ target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
+ }
+ }
+
+ parentEl = dragEl.parentNode; // actualization
+
+ this._animate(dragRect, dragEl);
+ this._animate(targetRect, target);
+ }
+ }
+ }
+ },
+
+ _animate: function (prevRect, target) {
+ var ms = this.options.animation;
+
+ if (ms) {
+ var currentRect = target.getBoundingClientRect();
+
+ _css(target, 'transition', 'none');
+ _css(target, 'transform', 'translate3d('
+ + (prevRect.left - currentRect.left) + 'px,'
+ + (prevRect.top - currentRect.top) + 'px,0)'
+ );
+
+ target.offsetWidth; // repaint
+
+ _css(target, 'transition', 'all ' + ms + 'ms');
+ _css(target, 'transform', 'translate3d(0,0,0)');
+
+ clearTimeout(target.animated);
+ target.animated = setTimeout(function () {
+ _css(target, 'transition', '');
+ _css(target, 'transform', '');
+ target.animated = false;
+ }, ms);
+ }
+ },
+
+ _offUpEvents: function () {
+ var ownerDocument = this.el.ownerDocument;
+
+ _off(document, 'touchmove', this._onTouchMove);
+ _off(ownerDocument, 'mouseup', this._onDrop);
+ _off(ownerDocument, 'touchend', this._onDrop);
+ _off(ownerDocument, 'touchcancel', this._onDrop);
+ },
+
+ _onDrop: function (/**Event*/evt) {
+ var el = this.el,
+ options = this.options;
+
+ clearInterval(this._loopId);
+ clearInterval(autoScroll.pid);
+ clearTimeout(this._dragStartTimer);
+
+ // Unbind events
+ _off(document, 'mousemove', this._onTouchMove);
+
+ if (this.nativeDraggable) {
+ _off(document, 'drop', this);
+ _off(el, 'dragstart', this._onDragStart);
+ }
+
+ this._offUpEvents();
+
+ if (evt) {
+ if (moved) {
+ evt.preventDefault();
+ !options.dropBubble && evt.stopPropagation();
+ }
+
+ ghostEl && ghostEl.parentNode.removeChild(ghostEl);
+
+ if (dragEl) {
+ if (this.nativeDraggable) {
+ _off(dragEl, 'dragend', this);
+ }
+
+ _disableDraggable(dragEl);
+
+ // Remove class's
+ _toggleClass(dragEl, this.options.ghostClass, false);
+ _toggleClass(dragEl, this.options.chosenClass, false);
+
+ if (rootEl !== parentEl) {
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // drag from one list and drop into another
+ _dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+
+ // Add event
+ _dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex);
+
+ // Remove event
+ _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex);
+ }
+ }
+ else {
+ // Remove clone
+ cloneEl && cloneEl.parentNode.removeChild(cloneEl);
+
+ if (dragEl.nextSibling !== nextEl) {
+ // Get the index of the dragged element within its parent
+ newIndex = _index(dragEl, options.draggable);
+
+ if (newIndex >= 0) {
+ // drag & drop within the same list
+ _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex);
+ _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+ }
+ }
+ }
+
+ if (Sortable.active) {
+ if (newIndex === null || newIndex === -1) {
+ newIndex = oldIndex;
+ }
+
+ _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex);
+
+ // Save sorting
+ this.save();
+ }
+ }
+
+ }
+
+ this._nulling();
+ },
+
+ _nulling: function () {
+ rootEl =
+ dragEl =
+ parentEl =
+ ghostEl =
+ nextEl =
+ cloneEl =
+
+ scrollEl =
+ scrollParentEl =
+
+ tapEvt =
+ touchEvt =
+
+ moved =
+ newIndex =
+
+ lastEl =
+ lastCSS =
+
+ activeGroup =
+ Sortable.active = null;
+ },
+
+ handleEvent: function (/**Event*/evt) {
+ var type = evt.type;
+
+ if (type === 'dragover' || type === 'dragenter') {
+ if (dragEl) {
+ this._onDragOver(evt);
+ _globalDragOver(evt);
+ }
+ }
+ else if (type === 'drop' || type === 'dragend') {
+ this._onDrop(evt);
+ }
+ },
+
+
+ /**
+ * Serializes the item into an array of string.
+ * @returns {String[]}
+ */
+ toArray: function () {
+ var order = [],
+ el,
+ children = this.el.children,
+ i = 0,
+ n = children.length,
+ options = this.options;
+
+ for (; i < n; i++) {
+ el = children[i];
+ if (_closest(el, options.draggable, this.el)) {
+ order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
+ }
+ }
+
+ return order;
+ },
+
+
+ /**
+ * Sorts the elements according to the array.
+ * @param {String[]} order order of the items
+ */
+ sort: function (order) {
+ var items = {}, rootEl = this.el;
+
+ this.toArray().forEach(function (id, i) {
+ var el = rootEl.children[i];
+
+ if (_closest(el, this.options.draggable, rootEl)) {
+ items[id] = el;
+ }
+ }, this);
+
+ order.forEach(function (id) {
+ if (items[id]) {
+ rootEl.removeChild(items[id]);
+ rootEl.appendChild(items[id]);
+ }
+ });
+ },
+
+
+ /**
+ * Save the current sorting
+ */
+ save: function () {
+ var store = this.options.store;
+ store && store.set(this);
+ },
+
+
+ /**
+ * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
+ * @param {HTMLElement} el
+ * @param {String} [selector] default: `options.draggable`
+ * @returns {HTMLElement|null}
+ */
+ closest: function (el, selector) {
+ return _closest(el, selector || this.options.draggable, this.el);
+ },
+
+
+ /**
+ * Set/get option
+ * @param {string} name
+ * @param {*} [value]
+ * @returns {*}
+ */
+ option: function (name, value) {
+ var options = this.options;
+
+ if (value === void 0) {
+ return options[name];
+ } else {
+ options[name] = value;
+
+ if (name === 'group') {
+ _prepareGroup(options);
+ }
+ }
+ },
+
+
+ /**
+ * Destroy
+ */
+ destroy: function () {
+ var el = this.el;
+
+ el[expando] = null;
+
+ _off(el, 'mousedown', this._onTapStart);
+ _off(el, 'touchstart', this._onTapStart);
+
+ if (this.nativeDraggable) {
+ _off(el, 'dragover', this);
+ _off(el, 'dragenter', this);
+ }
+
+ // Remove draggable attributes
+ Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {
+ el.removeAttribute('draggable');
+ });
+
+ touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1);
+
+ this._onDrop();
+
+ this.el = el = null;
+ }
+ };
+
+
+ function _cloneHide(state) {
+ if (cloneEl && (cloneEl.state !== state)) {
+ _css(cloneEl, 'display', state ? 'none' : '');
+ !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl);
+ cloneEl.state = state;
+ }
+ }
+
+
+ function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) {
+ if (el) {
+ ctx = ctx || document;
+
+ do {
+ if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) {
+ return el;
+ }
+ }
+ while (el !== ctx && (el = el.parentNode));
+ }
+
+ return null;
+ }
+
+
+ function _globalDragOver(/**Event*/evt) {
+ if (evt.dataTransfer) {
+ evt.dataTransfer.dropEffect = 'move';
+ }
+ evt.preventDefault();
+ }
+
+
+ function _on(el, event, fn) {
+ el.addEventListener(event, fn, false);
+ }
+
+
+ function _off(el, event, fn) {
+ el.removeEventListener(event, fn, false);
+ }
+
+
+ function _toggleClass(el, name, state) {
+ if (el) {
+ if (el.classList) {
+ el.classList[state ? 'add' : 'remove'](name);
+ }
+ else {
+ var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' ');
+ el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' ');
+ }
+ }
+ }
+
+
+ function _css(el, prop, val) {
+ var style = el && el.style;
+
+ if (style) {
+ if (val === void 0) {
+ if (document.defaultView && document.defaultView.getComputedStyle) {
+ val = document.defaultView.getComputedStyle(el, '');
+ }
+ else if (el.currentStyle) {
+ val = el.currentStyle;
+ }
+
+ return prop === void 0 ? val : val[prop];
+ }
+ else {
+ if (!(prop in style)) {
+ prop = '-webkit-' + prop;
+ }
+
+ style[prop] = val + (typeof val === 'string' ? '' : 'px');
+ }
+ }
+ }
+
+
+ function _find(ctx, tagName, iterator) {
+ if (ctx) {
+ var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
+
+ if (iterator) {
+ for (; i < n; i++) {
+ iterator(list[i], i);
+ }
+ }
+
+ return list;
+ }
+
+ return [];
+ }
+
+
+
+ function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) {
+ var evt = document.createEvent('Event'),
+ options = (sortable || rootEl[expando]).options,
+ onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);
+
+ evt.initEvent(name, true, true);
+
+ evt.to = rootEl;
+ evt.from = fromEl || rootEl;
+ evt.item = targetEl || rootEl;
+ evt.clone = cloneEl;
+
+ evt.oldIndex = startIndex;
+ evt.newIndex = newIndex;
+
+ rootEl.dispatchEvent(evt);
+
+ if (options[onName]) {
+ options[onName].call(sortable, evt);
+ }
+ }
+
+
+ function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) {
+ var evt,
+ sortable = fromEl[expando],
+ onMoveFn = sortable.options.onMove,
+ retVal;
+
+ evt = document.createEvent('Event');
+ evt.initEvent('move', true, true);
+
+ evt.to = toEl;
+ evt.from = fromEl;
+ evt.dragged = dragEl;
+ evt.draggedRect = dragRect;
+ evt.related = targetEl || toEl;
+ evt.relatedRect = targetRect || toEl.getBoundingClientRect();
+
+ fromEl.dispatchEvent(evt);
+
+ if (onMoveFn) {
+ retVal = onMoveFn.call(sortable, evt);
+ }
+
+ return retVal;
+ }
+
+
+ function _disableDraggable(el) {
+ el.draggable = false;
+ }
+
+
+ function _unsilent() {
+ _silent = false;
+ }
+
+
+ /** @returns {HTMLElement|false} */
+ function _ghostIsLast(el, evt) {
+ var lastEl = el.lastElementChild,
+ rect = lastEl.getBoundingClientRect();
+
+ return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta
+ }
+
+
+ /**
+ * Generate id
+ * @param {HTMLElement} el
+ * @returns {String}
+ * @private
+ */
+ function _generateId(el) {
+ var str = el.tagName + el.className + el.src + el.href + el.textContent,
+ i = str.length,
+ sum = 0;
+
+ while (i--) {
+ sum += str.charCodeAt(i);
+ }
+
+ return sum.toString(36);
+ }
+
+ /**
+ * Returns the index of an element within its parent for a selected set of
+ * elements
+ * @param {HTMLElement} el
+ * @param {selector} selector
+ * @return {number}
+ */
+ function _index(el, selector) {
+ var index = 0;
+
+ if (!el || !el.parentNode) {
+ return -1;
+ }
+
+ while (el && (el = el.previousElementSibling)) {
+ if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) {
+ index++;
+ }
+ }
+
+ return index;
+ }
+
+ function _matches(/**HTMLElement*/el, /**String*/selector) {
+ if (el) {
+ selector = selector.split('.');
+
+ var tag = selector.shift().toUpperCase(),
+ re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g');
+
+ return (
+ (tag === '' || el.nodeName.toUpperCase() == tag) &&
+ (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length)
+ );
+ }
+
+ return false;
+ }
+
+ function _throttle(callback, ms) {
+ var args, _this;
+
+ return function () {
+ if (args === void 0) {
+ args = arguments;
+ _this = this;
+
+ setTimeout(function () {
+ if (args.length === 1) {
+ callback.call(_this, args[0]);
+ } else {
+ callback.apply(_this, args);
+ }
+
+ args = void 0;
+ }, ms);
+ }
+ };
+ }
+
+ function _extend(dst, src) {
+ if (dst && src) {
+ for (var key in src) {
+ if (src.hasOwnProperty(key)) {
+ dst[key] = src[key];
+ }
+ }
+ }
+
+ return dst;
+ }
+
+
+ // Export utils
+ Sortable.utils = {
+ on: _on,
+ off: _off,
+ css: _css,
+ find: _find,
+ is: function (el, selector) {
+ return !!_closest(el, selector, el);
+ },
+ extend: _extend,
+ throttle: _throttle,
+ closest: _closest,
+ toggleClass: _toggleClass,
+ index: _index
+ };
+
+
+ /**
+ * Create sortable instance
+ * @param {HTMLElement} el
+ * @param {Object} [options]
+ */
+ Sortable.create = function (el, options) {
+ return new Sortable(el, options);
+ };
+
+
+ // Export
+ Sortable.version = '1.4.2';
+ return Sortable;
+});
diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js
index 1b1f4f0bd63..39d7d2306f8 100644
--- a/vendor/assets/javascripts/clipboard.js
+++ b/vendor/assets/javascripts/clipboard.js
@@ -154,12 +154,12 @@ function E () {
E.prototype = {
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
-
+
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
-
+
return this;
},
@@ -169,7 +169,7 @@ E.prototype = {
self.off(name, fn);
callback.apply(ctx, arguments);
};
-
+
return this.on(name, fn, ctx);
},
@@ -178,11 +178,11 @@ E.prototype = {
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
-
+
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
-
+
return this;
},
@@ -190,21 +190,21 @@ E.prototype = {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = [];
-
+
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback) liveEvents.push(evts[i]);
}
}
-
+
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
- (liveEvents.length)
+ (liveEvents.length)
? e[name] = liveEvents
: delete e[name];
-
+
return this;
}
};
@@ -618,4 +618,4 @@ exports['default'] = Clipboard;
module.exports = exports['default'];
},{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7)
-}); \ No newline at end of file
+});