diff options
Diffstat (limited to 'app')
365 files changed, 7059 insertions, 2051 deletions
diff --git a/app/assets/images/bg-header.png b/app/assets/images/bg-header.png Binary files differdeleted file mode 100644 index 639271c6faf..00000000000 --- a/app/assets/images/bg-header.png +++ /dev/null diff --git a/app/assets/images/bg_fallback.png b/app/assets/images/bg_fallback.png Binary files differdeleted file mode 100644 index 5c55bc79dec..00000000000 --- a/app/assets/images/bg_fallback.png +++ /dev/null diff --git a/app/assets/images/chosen-sprite.png b/app/assets/images/chosen-sprite.png Binary files differdeleted file mode 100644 index 3d936b07d44..00000000000 --- a/app/assets/images/chosen-sprite.png +++ /dev/null diff --git a/app/assets/images/diff_note_add.png b/app/assets/images/diff_note_add.png Binary files differdeleted file mode 100644 index 0084422e330..00000000000 --- a/app/assets/images/diff_note_add.png +++ /dev/null diff --git a/app/assets/images/icon-search.png b/app/assets/images/icon-search.png Binary files differdeleted file mode 100644 index 3c1c146541d..00000000000 --- a/app/assets/images/icon-search.png +++ /dev/null diff --git a/app/assets/images/icon_sprite.png b/app/assets/images/icon_sprite.png Binary files differdeleted file mode 100644 index 2e7a5023398..00000000000 --- a/app/assets/images/icon_sprite.png +++ /dev/null diff --git a/app/assets/images/images.png b/app/assets/images/images.png Binary files differdeleted file mode 100644 index bd60de994c4..00000000000 --- a/app/assets/images/images.png +++ /dev/null diff --git a/app/assets/images/move.png b/app/assets/images/move.png Binary files differdeleted file mode 100644 index 6a0567f8f25..00000000000 --- a/app/assets/images/move.png +++ /dev/null diff --git a/app/assets/images/progress_bar.gif b/app/assets/images/progress_bar.gif Binary files differdeleted file mode 100644 index c3d43fa40b2..00000000000 --- a/app/assets/images/progress_bar.gif +++ /dev/null diff --git a/app/assets/images/slider_handles.png b/app/assets/images/slider_handles.png Binary files differdeleted file mode 100644 index 52ad11ab7a1..00000000000 --- a/app/assets/images/slider_handles.png +++ /dev/null diff --git a/app/assets/images/switch_icon.png b/app/assets/images/switch_icon.png Binary files differdeleted file mode 100644 index c6b6c8d9521..00000000000 --- a/app/assets/images/switch_icon.png +++ /dev/null diff --git a/app/assets/images/trans_bg.gif b/app/assets/images/trans_bg.gif Binary files differdeleted file mode 100644 index 1a1c9c15ec7..00000000000 --- a/app/assets/images/trans_bg.gif +++ /dev/null diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 49c2ac0dac3..84b292e59c6 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -9,10 +9,11 @@ licensePath: "/api/:version/licenses/:key", gitignorePath: "/api/:version/gitignores/:key", gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", + issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", + group: function(group_id, callback) { - var url; - url = Api.buildUrl(Api.groupPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -24,8 +25,7 @@ }); }, groups: function(query, skip_ldap, callback) { - var url; - url = Api.buildUrl(Api.groupsPath); + var url = Api.buildUrl(Api.groupsPath); return $.ajax({ url: url, data: { @@ -39,8 +39,7 @@ }); }, namespaces: function(query, callback) { - var url; - url = Api.buildUrl(Api.namespacesPath); + var url = Api.buildUrl(Api.namespacesPath); return $.ajax({ url: url, data: { @@ -54,8 +53,7 @@ }); }, projects: function(query, order, callback) { - var url; - url = Api.buildUrl(Api.projectsPath); + var url = Api.buildUrl(Api.projectsPath); return $.ajax({ url: url, data: { @@ -70,9 +68,8 @@ }); }, newLabel: function(project_id, data, callback) { - var url; - url = Api.buildUrl(Api.labelsPath); - url = url.replace(':id', project_id); + var url = Api.buildUrl(Api.labelsPath) + .replace(':id', project_id); data.private_token = gon.api_token; return $.ajax({ url: url, @@ -86,9 +83,8 @@ }); }, groupProjects: function(group_id, query, callback) { - var url; - url = Api.buildUrl(Api.groupProjectsPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -102,8 +98,8 @@ }); }, licenseText: function(key, data, callback) { - var url; - url = Api.buildUrl(Api.licensePath).replace(':key', key); + var url = Api.buildUrl(Api.licensePath) + .replace(':key', key); return $.ajax({ url: url, data: data @@ -112,19 +108,32 @@ }); }, gitignoreText: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + var url = Api.buildUrl(Api.gitignorePath) + .replace(':key', key); return $.get(url, function(gitignore) { return callback(gitignore); }); }, gitlabCiYml: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + var url = Api.buildUrl(Api.gitlabCiYmlPath) + .replace(':key', key); return $.get(url, function(file) { return callback(file); }); }, + issueTemplate: function(namespacePath, projectPath, key, type, callback) { + var url = Api.buildUrl(Api.issuableTemplatePath) + .replace(':key', key) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + $.ajax({ + url: url, + dataType: 'json' + }).done(function(file) { + callback(null, file); + }).error(callback); + }, buildUrl: function(url) { if (gon.relative_url_root != null) { url = gon.relative_url_root + url; diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 127e568adc9..85ec52cee37 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -41,6 +41,7 @@ /*= require date.format */ /*= require_directory ./behaviors */ /*= require_directory ./blob */ +/*= require_directory ./templates */ /*= require_directory ./commit */ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ @@ -223,8 +224,11 @@ return $('.navbar-toggle').toggleClass('active'); }); $body.on("click", ".js-toggle-diff-comments", function(e) { - $(this).toggleClass('active'); - $(this).closest(".diff-file").find(".notes_holder").toggle(); + var $this = $(this); + var showComments = $this.hasClass('active'); + + $this.toggleClass('active'); + $this.closest(".diff-file").find(".notes_holder").toggle(showComments); return e.preventDefault(); }); $document.off("click", '.js-confirm-danger'); @@ -287,7 +291,7 @@ $('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded').removeClass('page-sidebar-pinned'); $('.navbar-fixed-top').removeClass('header-pinned-nav'); } - return $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) { + $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) { var $page, $pinBtn, $tooltip, $topNav, doPinNav, tooltipText; e.preventDefault(); $pinBtn = $(e.currentTarget); @@ -315,6 +319,8 @@ $tooltip.find('.tooltip-inner').text(tooltipText); return $pinBtn.attr('title', tooltipText).tooltip('fixTitle'); }); - }); + // Custom time ago + gl.utils.shortTimeAgo($('.js-short-timeago')); + }); }).call(this); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index ea683b31f75..2c5b83e4f1e 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -161,23 +161,11 @@ $emojiButton = votesBlock.find("[data-emoji=" + mutualVote + "]").parent(); isAlreadyVoted = $emojiButton.hasClass('active'); if (isAlreadyVoted) { - this.showEmojiLoader($emojiButton); - return this.addAward(votesBlock, awardUrl, mutualVote, false, function() { - return $emojiButton.removeClass('is-loading'); - }); + this.addAward(votesBlock, awardUrl, mutualVote, false); } } }; - AwardsHandler.prototype.showEmojiLoader = function($emojiButton) { - var $loader; - $loader = $emojiButton.find('.fa-spinner'); - if (!$loader.length) { - $emojiButton.append('<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>'); - } - return $emojiButton.addClass('is-loading'); - }; - AwardsHandler.prototype.isActive = function($emojiButton) { return $emojiButton.hasClass('active'); }; diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 2cf0a6631b8..b0a37ef0e0a 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -9,6 +9,7 @@ } this.onClick = bind(this.onClick, this); this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); + this.dropdownIcon = $('.fa-chevron-down', this.dropdown); this.buildDropdown(); this.bindEvents(); this.onFilenameUpdate(); @@ -60,11 +61,26 @@ return this.requestFile(item); }; - TemplateSelector.prototype.requestFile = function(item) {}; + TemplateSelector.prototype.requestFile = function(item) { + // This `requestFile` method is an abstract method that should + // be added by all subclasses. + }; - TemplateSelector.prototype.requestFileSuccess = function(file) { + TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) { this.editor.setValue(file.content, 1); - return this.editor.focus(); + if (!skipFocus) this.editor.focus(); + }; + + TemplateSelector.prototype.startLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + }; + + TemplateSelector.prototype.stopLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); }; return TemplateSelector; diff --git a/app/assets/javascripts/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/build.js b/app/assets/javascripts/build.js index e135cb92a30..0d7d29bb0d0 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -6,19 +6,26 @@ Build.state = null; - function Build(page_url, build_url, build_status, state1) { - this.page_url = page_url; - this.build_url = build_url; - this.build_status = build_status; - this.state = state1; + function Build(options) { + this.page_url = options.page_url; + this.build_url = options.build_url; + this.build_status = options.build_status; + this.state = options.state1; + this.build_stage = options.build_stage; this.hideSidebar = bind(this.hideSidebar, this); this.toggleSidebar = bind(this.toggleSidebar, this); + this.updateDropdown = bind(this.updateDropdown, this); clearInterval(Build.interval); this.bp = Breakpoints.get(); - this.hideSidebar(); $('.js-build-sidebar').niceScroll(); + + this.populateJobs(this.build_stage); + this.updateStageDropdownText(this.build_stage); + this.hideSidebar(); + $(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); $(window).off('resize.build').on('resize.build', this.hideSidebar); + $(document).off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); this.updateArtifactRemoveDate(); if ($('#build-trace').length) { this.getInitialBuildTrace(); @@ -128,10 +135,26 @@ $date = $('.js-artifacts-remove'); if ($date.length) { date = $date.text(); - return $date.text($.timefor(new Date(date), ' ')); + return $date.text($.timefor(new Date(date.replace(/-/g, '/')), ' ')); } }; + Build.prototype.populateJobs = function(stage) { + $('.build-job').hide(); + $('.build-job[data-stage="' + stage + '"]').show(); + }; + + Build.prototype.updateStageDropdownText = function(stage) { + $('.stage-selection').text(stage); + }; + + Build.prototype.updateDropdown = function(e) { + e.preventDefault(); + var stage = e.currentTarget.text; + this.updateStageDropdownText(stage); + this.populateJobs(stage); + }; + return Build; })(); 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/diff.js b/app/assets/javascripts/diff.js index 298f3852085..3dd7ceba92f 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -10,7 +10,7 @@ $(document).off('click', '.js-unfold'); $(document).on('click', '.js-unfold', (function(_this) { return function(event) { - var line_number, link, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom; + var line_number, link, file, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom; target = $(event.target); unfoldBottom = target.hasClass('js-unfold-bottom'); unfold = true; @@ -31,14 +31,16 @@ unfold = false; } } - link = target.parents('.diff-file').attr('data-blob-diff-path'); + file = target.parents('.diff-file'); + link = file.data('blob-diff-path'); params = { since: since, to: to, bottom: unfoldBottom, offset: offset, unfold: unfold, - indent: 1 + indent: 1, + view: file.data('view') }; return $.get(link, params, function(response) { return target.parent().replaceWith(response); @@ -48,26 +50,13 @@ } Diff.prototype.lineNumbers = function(line) { - var i, l, len, line_number, line_numbers, lines, results; if (!line.children().length) { return [0, 0]; } - lines = line.children().slice(0, 2); - line_numbers = (function() { - var i, len, results; - results = []; - for (i = 0, len = lines.length; i < len; i++) { - l = lines[i]; - results.push($(l).attr('data-linenumber')); - } - return results; - })(); - results = []; - for (i = 0, len = line_numbers.length; i < len; i++) { - line_number = line_numbers[i]; - results.push(parseInt(line_number)); - } - return results; + + return line.find('.diff-line-num').map(function() { + return parseInt($(this).data('linenumber')); + }); }; return Diff; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 846162a5a74..0bdab796daf 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -55,6 +55,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.issue-form')); new IssuableForm($('.issue-form')); + new IssuableTemplateSelectors(); break; case 'projects:merge_requests:new': case 'projects:merge_requests:edit': @@ -62,6 +63,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + new IssuableTemplateSelectors(); break; case 'projects:tags:new': new ZenMode(); @@ -86,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(); @@ -174,8 +178,8 @@ new Search(); break; case 'projects:protected_branches:index': - new ProtectedBranchesAccessSelect($(".new_protected_branch"), false, true); - new ProtectedBranchesAccessSelect($(".protected-branches-list"), true, false); + new gl.ProtectedBranchCreate(); + new gl.ProtectedBranchEditList(); break; } switch (path.first()) { @@ -187,6 +191,12 @@ break; case 'projects': new NamespaceSelects(); + break; + case 'labels': + switch (path[2]) { + case 'edit': + new Labels(); + } } break; case 'dashboard': @@ -212,6 +222,7 @@ new ProjectNew(); break; case 'show': + new Star(); new ProjectNew(); new ProjectShow(); new NotificationsDropdown(); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 288cce04f87..4a6fea929c7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,5 +1,5 @@ -/*= require markdown_preview */ +/*= require preview_markdown */ (function() { this.DropzoneInput = (function() { diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 09b5eb398d4..b2e49b71fec 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -33,7 +33,7 @@ this.render = bind(this.render, this); this.VIEW_TYPE = $('input#view[type=hidden]').val(); debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION); - $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); + $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); } FilesCommentButton.prototype.render = function(e) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 41f4c1914f2..2e5b15f4b77 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -47,8 +47,8 @@ } } }, - setup: function(wrap) { - this.input = $('.js-gfm-input'); + setup: function(input) { + this.input = input || $('.js-gfm-input'); this.destroyAtWho(); this.setupAtWho(); if (this.dataSource) { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index c5d92831fbe..d3394fae3f9 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -28,38 +28,43 @@ }; })(this)); timeout = ""; - this.input.on("keyup", (function(_this) { - return function(e) { + this.input + .on('keydown', function (e) { + var keyCode = e.which; + + if (keyCode === 13) { + e.preventDefault() + } + }) + .on('keyup', function(e) { var keyCode; keyCode = e.which; if (ARROW_KEY_CODES.indexOf(keyCode) >= 0) { return; } - if (_this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (_this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { $inputContainer.removeClass(HAS_VALUE_CLASS); } if (keyCode === 13) { return false; } - if (_this.options.remote) { + if (this.options.remote) { clearTimeout(timeout); return timeout = setTimeout(function() { - var blur_field; - blur_field = _this.shouldBlur(keyCode); - if (blur_field && _this.filterInputBlur) { - _this.input.blur(); + var blurField = this.shouldBlur(keyCode); + if (blurField && this.filterInputBlur) { + this.input.blur(); } - return _this.options.query(_this.input.val(), function(data) { - return _this.options.callback(data); - }); - }, 250); + return this.options.query(this.input.val(), function(data) { + return this.options.callback(data); + }.bind(this)); + }.bind(this), 250); } else { - return _this.filter(_this.input.val()); + return this.filter(this.input.val()); } - }; - })(this)); + }.bind(this)); } GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { @@ -382,6 +387,7 @@ GitLabDropdown.prototype.opened = function() { var contentHtml; + currentIndex = -1; this.addArrowKeyEvent(); if (this.options.setIndeterminateIds) { this.options.setIndeterminateIds.call(this); @@ -601,7 +607,7 @@ return this.dropdown.before($input); }; - GitLabDropdown.prototype.selectRowAtIndex = function(e, index) { + GitLabDropdown.prototype.selectRowAtIndex = function(index) { var $el, selector; selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a"; if (this.dropdown.find(".dropdown-toggle-page").length) { @@ -609,8 +615,6 @@ } $el = $(selector, this.dropdown); if ($el.length) { - e.preventDefault(); - e.stopImmediatePropagation(); return $el.first().trigger('click'); } }; @@ -619,7 +623,7 @@ var $input, ARROW_KEY_CODES, selector; ARROW_KEY_CODES = [38, 40]; $input = this.dropdown.find(".dropdown-input-field"); - selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator)'; + selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator):visible'; if (this.dropdown.find(".dropdown-toggle-page").length) { selector = ".dropdown-page-one " + selector; } @@ -647,7 +651,7 @@ return false; } if (currentKeyCode === 13 && currentIndex !== -1) { - return _this.selectRowAtIndex(e, currentIndex); + return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1); } }; })(this)); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 6ac7564a848..528a673eb15 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -21,7 +21,7 @@ this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); - GitLab.GfmAutoComplete.setup(); + GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); this.addEventListeners(); diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js index f27f1bad1f7..d0305c6c6a1 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable.js @@ -5,13 +5,10 @@ this.Issuable = { init: function() { - if (!issuable_created) { - issuable_created = true; - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - return Issuable.initLabelFilterRemove(); - } + Issuable.initTemplates(); + Issuable.initSearch(); + Issuable.initChecks(); + return Issuable.initLabelFilterRemove(); }, initTemplates: function() { return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 675dd5b7cea..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,60 +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) { - var errors; - $newLabelCreateButton.enable(); - if (label.message != null) { - errors = _.map(label.message, function(value, key) { - return key + " " + value[0]; - }); - return $newLabelError.html(errors.join("<br/>")).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() { @@ -270,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')) + "']"); @@ -289,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')) { @@ -298,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/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index e817261f210..10afa7e4329 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -8,13 +8,16 @@ base.utils = {}; } w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + w.gl.utils.formatDate = function(datetime) { return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); }; + w.gl.utils.getDayName = function(date) { return this.days[date.getDay()]; }; - return w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { + + w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { if (setTimeago == null) { setTimeago = true; } @@ -31,6 +34,39 @@ }); } }; + + w.gl.utils.shortTimeAgo = function($el) { + var shortLocale, tmpLocale; + shortLocale = { + prefixAgo: null, + prefixFromNow: null, + suffixAgo: 'ago', + suffixFromNow: 'from now', + seconds: '1 min', + minute: '1 min', + minutes: '%d mins', + hour: '1 hr', + hours: '%d hrs', + day: '1 day', + days: '%d days', + month: '1 month', + months: '%d months', + year: '1 year', + years: '%d years', + wordSeparator: ' ', + numbers: [] + }; + tmpLocale = $.timeago.settings.strings; + $el.each(function(el) { + var $el1; + $el1 = $(this); + return $el1.attr('title', gl.utils.formatDate($el.attr('datetime'))); + }); + $.timeago.settings.strings = shortLocale; + $el.timeago(); + $.timeago.settings.strings = tmpLocale; + }; + })(window); }).call(this); diff --git a/app/assets/javascripts/lib/utils/md5.js b/app/assets/javascripts/lib/utils/md5.js deleted file mode 100644 index b63716eaad2..00000000000 --- a/app/assets/javascripts/lib/utils/md5.js +++ /dev/null @@ -1,211 +0,0 @@ -function md5 (str) { - // http://kevin.vanzonneveld.net - // + original by: Webtoolkit.info (http://www.webtoolkit.info/) - // + namespaced by: Michael White (http://getsprink.com) - // + tweaked by: Jack - // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // + input by: Brett Zamir (http://brett-zamir.me) - // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // - depends on: utf8_encode - // * example 1: md5('Kevin van Zonneveld'); - // * returns 1: '6e658d4bfcb59cc13f96c14450ac40b9' - var xl; - - var rotateLeft = function (lValue, iShiftBits) { - return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); - }; - - var addUnsigned = function (lX, lY) { - var lX4, lY4, lX8, lY8, lResult; - lX8 = (lX & 0x80000000); - lY8 = (lY & 0x80000000); - lX4 = (lX & 0x40000000); - lY4 = (lY & 0x40000000); - lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF); - if (lX4 & lY4) { - return (lResult ^ 0x80000000 ^ lX8 ^ lY8); - } - if (lX4 | lY4) { - if (lResult & 0x40000000) { - return (lResult ^ 0xC0000000 ^ lX8 ^ lY8); - } else { - return (lResult ^ 0x40000000 ^ lX8 ^ lY8); - } - } else { - return (lResult ^ lX8 ^ lY8); - } - }; - - var _F = function (x, y, z) { - return (x & y) | ((~x) & z); - }; - var _G = function (x, y, z) { - return (x & z) | (y & (~z)); - }; - var _H = function (x, y, z) { - return (x ^ y ^ z); - }; - var _I = function (x, y, z) { - return (y ^ (x | (~z))); - }; - - var _FF = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_F(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _GG = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_G(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _HH = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_H(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _II = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_I(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var convertToWordArray = function (str) { - var lWordCount; - var lMessageLength = str.length; - var lNumberOfWords_temp1 = lMessageLength + 8; - var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; - var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; - var lWordArray = new Array(lNumberOfWords - 1); - var lBytePosition = 0; - var lByteCount = 0; - while (lByteCount < lMessageLength) { - lWordCount = (lByteCount - (lByteCount % 4)) / 4; - lBytePosition = (lByteCount % 4) * 8; - lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition)); - lByteCount++; - } - lWordCount = (lByteCount - (lByteCount % 4)) / 4; - lBytePosition = (lByteCount % 4) * 8; - lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); - lWordArray[lNumberOfWords - 2] = lMessageLength << 3; - lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; - return lWordArray; - }; - - var wordToHex = function (lValue) { - var wordToHexValue = "", - wordToHexValue_temp = "", - lByte, lCount; - for (lCount = 0; lCount <= 3; lCount++) { - lByte = (lValue >>> (lCount * 8)) & 255; - wordToHexValue_temp = "0" + lByte.toString(16); - wordToHexValue = wordToHexValue + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2); - } - return wordToHexValue; - }; - - var x = [], - k, AA, BB, CC, DD, a, b, c, d, S11 = 7, - S12 = 12, - S13 = 17, - S14 = 22, - S21 = 5, - S22 = 9, - S23 = 14, - S24 = 20, - S31 = 4, - S32 = 11, - S33 = 16, - S34 = 23, - S41 = 6, - S42 = 10, - S43 = 15, - S44 = 21; - - str = this.utf8_encode(str); - x = convertToWordArray(str); - a = 0x67452301; - b = 0xEFCDAB89; - c = 0x98BADCFE; - d = 0x10325476; - - xl = x.length; - for (k = 0; k < xl; k += 16) { - AA = a; - BB = b; - CC = c; - DD = d; - a = _FF(a, b, c, d, x[k + 0], S11, 0xD76AA478); - d = _FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756); - c = _FF(c, d, a, b, x[k + 2], S13, 0x242070DB); - b = _FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE); - a = _FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF); - d = _FF(d, a, b, c, x[k + 5], S12, 0x4787C62A); - c = _FF(c, d, a, b, x[k + 6], S13, 0xA8304613); - b = _FF(b, c, d, a, x[k + 7], S14, 0xFD469501); - a = _FF(a, b, c, d, x[k + 8], S11, 0x698098D8); - d = _FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF); - c = _FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1); - b = _FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE); - a = _FF(a, b, c, d, x[k + 12], S11, 0x6B901122); - d = _FF(d, a, b, c, x[k + 13], S12, 0xFD987193); - c = _FF(c, d, a, b, x[k + 14], S13, 0xA679438E); - b = _FF(b, c, d, a, x[k + 15], S14, 0x49B40821); - a = _GG(a, b, c, d, x[k + 1], S21, 0xF61E2562); - d = _GG(d, a, b, c, x[k + 6], S22, 0xC040B340); - c = _GG(c, d, a, b, x[k + 11], S23, 0x265E5A51); - b = _GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA); - a = _GG(a, b, c, d, x[k + 5], S21, 0xD62F105D); - d = _GG(d, a, b, c, x[k + 10], S22, 0x2441453); - c = _GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681); - b = _GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8); - a = _GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6); - d = _GG(d, a, b, c, x[k + 14], S22, 0xC33707D6); - c = _GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87); - b = _GG(b, c, d, a, x[k + 8], S24, 0x455A14ED); - a = _GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905); - d = _GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8); - c = _GG(c, d, a, b, x[k + 7], S23, 0x676F02D9); - b = _GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A); - a = _HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942); - d = _HH(d, a, b, c, x[k + 8], S32, 0x8771F681); - c = _HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122); - b = _HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C); - a = _HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44); - d = _HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9); - c = _HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60); - b = _HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70); - a = _HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6); - d = _HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA); - c = _HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085); - b = _HH(b, c, d, a, x[k + 6], S34, 0x4881D05); - a = _HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039); - d = _HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5); - c = _HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8); - b = _HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665); - a = _II(a, b, c, d, x[k + 0], S41, 0xF4292244); - d = _II(d, a, b, c, x[k + 7], S42, 0x432AFF97); - c = _II(c, d, a, b, x[k + 14], S43, 0xAB9423A7); - b = _II(b, c, d, a, x[k + 5], S44, 0xFC93A039); - a = _II(a, b, c, d, x[k + 12], S41, 0x655B59C3); - d = _II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92); - c = _II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D); - b = _II(b, c, d, a, x[k + 1], S44, 0x85845DD1); - a = _II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F); - d = _II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0); - c = _II(c, d, a, b, x[k + 6], S43, 0xA3014314); - b = _II(b, c, d, a, x[k + 13], S44, 0x4E0811A1); - a = _II(a, b, c, d, x[k + 4], S41, 0xF7537E82); - d = _II(d, a, b, c, x[k + 11], S42, 0xBD3AF235); - c = _II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB); - b = _II(b, c, d, a, x[k + 9], S44, 0xEB86D391); - a = addUnsigned(a, AA); - b = addUnsigned(b, BB); - c = addUnsigned(c, CC); - d = addUnsigned(d, DD); - } - - var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d); - - return temp.toLowerCase(); -} diff --git a/app/assets/javascripts/lib/utils/utf8_encode.js b/app/assets/javascripts/lib/utils/utf8_encode.js deleted file mode 100644 index 39ffe44dae0..00000000000 --- a/app/assets/javascripts/lib/utils/utf8_encode.js +++ /dev/null @@ -1,70 +0,0 @@ -function utf8_encode (argString) {
- // http://kevin.vanzonneveld.net
- // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
- // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
- // + improved by: sowberry
- // + tweaked by: Jack
- // + bugfixed by: Onno Marsman
- // + improved by: Yves Sucaet
- // + bugfixed by: Onno Marsman
- // + bugfixed by: Ulrich
- // + bugfixed by: Rafal Kukawski
- // + improved by: kirilloid
- // + bugfixed by: kirilloid
- // * example 1: utf8_encode('Kevin van Zonneveld');
- // * returns 1: 'Kevin van Zonneveld'
-
- if (argString === null || typeof argString === "undefined") {
- return "";
- }
-
- var string = (argString + ''); // .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
- var utftext = '',
- start, end, stringl = 0;
-
- start = end = 0;
- stringl = string.length;
- for (var n = 0; n < stringl; n++) {
- var c1 = string.charCodeAt(n);
- var enc = null;
-
- if (c1 < 128) {
- end++;
- } else if (c1 > 127 && c1 < 2048) {
- enc = String.fromCharCode(
- (c1 >> 6) | 192,
- ( c1 & 63) | 128
- );
- } else if (c1 & 0xF800 != 0xD800) {
- enc = String.fromCharCode(
- (c1 >> 12) | 224,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- } else { // surrogate pairs
- if (c1 & 0xFC00 != 0xD800) { throw new RangeError("Unmatched trail surrogate at " + n); }
- var c2 = string.charCodeAt(++n);
- if (c2 & 0xFC00 != 0xDC00) { throw new RangeError("Unmatched lead surrogate at " + (n-1)); }
- c1 = ((c1 & 0x3FF) << 10) + (c2 & 0x3FF) + 0x10000;
- enc = String.fromCharCode(
- (c1 >> 18) | 240,
- ((c1 >> 12) & 63) | 128,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- }
- if (enc !== null) {
- if (end > start) {
- utftext += string.slice(start, end);
- }
- utftext += enc;
- start = end = n + 1;
- }
- }
-
- if (end > start) {
- utftext += string.slice(start, stringl);
- }
-
- return utftext;
-}
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 52c2ed61012..1bba69a255a 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(); } @@ -81,7 +86,7 @@ if (action === 'show') { action = 'notes'; } - 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; } @@ -177,6 +182,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/pipeline.js.es6 b/app/assets/javascripts/pipeline.js.es6 new file mode 100644 index 00000000000..bf33eb10100 --- /dev/null +++ b/app/assets/javascripts/pipeline.js.es6 @@ -0,0 +1,15 @@ +(function() { + function toggleGraph() { + const $pipelineBtn = $(this).closest('.toggle-pipeline-btn'); + const $pipelineGraph = $(this).closest('.row-content-block').next('.pipeline-graph'); + const $btnText = $(this).find('.toggle-btn-text'); + + $($pipelineBtn).add($pipelineGraph).toggleClass('graph-collapsed'); + + const graphCollapsed = $pipelineGraph.hasClass('graph-collapsed'); + + graphCollapsed ? $btnText.text('Expand') : $btnText.text('Hide') + } + + $(document).on('click', '.toggle-pipeline-btn', toggleGraph); +})(); diff --git a/app/assets/javascripts/markdown_preview.js b/app/assets/javascripts/preview_markdown.js index 18fc7bae09a..5fd75799640 100644 --- a/app/assets/javascripts/markdown_preview.js +++ b/app/assets/javascripts/preview_markdown.js @@ -28,7 +28,7 @@ }; MarkdownPreview.prototype.renderMarkdown = function(text, success) { - if (!window.markdown_preview_path) { + if (!window.preview_markdown_path) { return; } if (text === this.ajaxCache.text) { @@ -36,7 +36,7 @@ } return $.ajax({ type: 'POST', - url: window.markdown_preview_path, + url: window.preview_markdown_path, data: { text: text }, diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index e6663177161..b97f6d22715 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -89,8 +89,14 @@ toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(e) { - return $dropdown.closest('form').submit(); + clicked: function(selected, $el, e) { + e.preventDefault() + if ($('input[name="ref"]').length) { + var $form = $dropdown.closest('form'), + action = $form.attr('action'), + divider = action.indexOf('?') < 0 ? '?' : '&'; + Turbolinks.visit(action + '' + divider + '' + $form.serialize()); + } } }); }); diff --git a/app/assets/javascripts/protected_branch_access_dropdown.js.es6 b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 new file mode 100644 index 00000000000..2fbb088fa04 --- /dev/null +++ b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 @@ -0,0 +1,24 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchAccessDropdown = class { + constructor(options) { + const { $dropdown, data, onSelect } = options; + + $dropdown.glDropdown({ + data: data, + selectable: true, + inputId: $dropdown.data('input-id'), + fieldName: $dropdown.data('field-name'), + toggleLabel(item) { + return item.text; + }, + clicked(item, $el, e) { + e.preventDefault(); + onSelect(); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6 new file mode 100644 index 00000000000..2efca2414dc --- /dev/null +++ b/app/assets/javascripts/protected_branch_create.js.es6 @@ -0,0 +1,56 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchCreate = class { + constructor() { + this.$wrap = this.$form = $('#new_protected_branch'); + this.buildDropdowns(); + } + + buildDropdowns() { + const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + // Cache callback + this.onSelectCallback = this.onSelect.bind(this); + + // Allowed to Merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelectCallback + }); + + // Allowed to Push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelectCallback + }); + + // Select default + $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0); + $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0); + + // Protected branch dropdown + new ProtectedBranchDropdown({ + $dropdown: this.$wrap.find('.js-protected-branch-select'), + onSelect: this.onSelectCallback + }); + } + + // This will run after clicked callback + onSelect() { + + // Enable submit button + const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]'); + const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]'); + const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]'); + + if ($branchInput.val() && $allowedToMergeInput.val() && $allowedToPushInput.val()){ + this.$form.find('input[type="submit"]').removeAttr('disabled'); + } + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_dropdown.js.es6 b/app/assets/javascripts/protected_branch_dropdown.js.es6 new file mode 100644 index 00000000000..6738dc8862d --- /dev/null +++ b/app/assets/javascripts/protected_branch_dropdown.js.es6 @@ -0,0 +1,75 @@ +class ProtectedBranchDropdown { + constructor(options) { + this.onSelect = options.onSelect; + this.$dropdown = options.$dropdown; + this.$dropdownContainer = this.$dropdown.parent(); + this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); + this.$protectedBranch = this.$dropdownContainer.find('.create-new-protected-branch'); + + this.buildDropdown(); + this.bindEvents(); + + // Hide footer + this.$dropdownFooter.addClass('hidden'); + } + + buildDropdown() { + this.$dropdown.glDropdown({ + data: this.getProtectedBranches.bind(this), + filterable: true, + remote: false, + search: { + fields: ['title'] + }, + selectable: true, + toggleLabel(selected) { + return (selected && 'id' in selected) ? selected.title : 'Protected Branch'; + }, + fieldName: 'protected_branch[name]', + text(protectedBranch) { + return _.escape(protectedBranch.title); + }, + id(protectedBranch) { + return _.escape(protectedBranch.id); + }, + onFilter: this.toggleCreateNewButton.bind(this), + clicked: (item, $el, e) => { + e.preventDefault(); + this.onSelect(); + } + }); + } + + bindEvents() { + this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this)); + } + + onClickCreateWildcard() { + this.$dropdown.data('glDropdown').remote.execute(); + this.$dropdown.data('glDropdown').selectRowAtIndex(0); + } + + getProtectedBranches(term, callback) { + if (this.selectedBranch) { + callback(gon.open_branches.concat(this.selectedBranch)); + } else { + callback(gon.open_branches); + } + } + + toggleCreateNewButton(branchName) { + this.selectedBranch = { + title: branchName, + id: branchName, + text: branchName + }; + + if (branchName) { + this.$dropdownContainer + .find('.create-new-protected-branch code') + .text(branchName); + } + + this.$dropdownFooter.toggleClass('hidden', !branchName); + } +} diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6 new file mode 100644 index 00000000000..a59fcbfa082 --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit.js.es6 @@ -0,0 +1,63 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEdit = class { + constructor(options) { + this.$wrap = options.$wrap; + this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + this.buildDropdowns(); + } + + buildDropdowns() { + + // Allowed to merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelect.bind(this) + }); + + // Allowed to push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelect.bind(this) + }); + } + + onSelect() { + const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`); + const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`); + + $.ajax({ + type: 'POST', + url: this.$wrap.data('url'), + dataType: 'json', + data: { + _method: 'PATCH', + id: this.$wrap.data('banchId'), + protected_branch: { + merge_access_levels_attributes: [{ + id: this.$allowedToMergeDropdown.data('access-level-id'), + access_level: $allowedToMergeInput.val() + }], + push_access_levels_attributes: [{ + id: this.$allowedToPushDropdown.data('access-level-id'), + access_level: $allowedToPushInput.val() + }] + } + }, + success: () => { + this.$wrap.effect('highlight'); + }, + error() { + $.scrollTo(0); + new Flash('Failed to update branch!'); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_edit_list.js.es6 b/app/assets/javascripts/protected_branch_edit_list.js.es6 new file mode 100644 index 00000000000..9ff0fd12c76 --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit_list.js.es6 @@ -0,0 +1,17 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEditList = class { + constructor() { + this.$wrap = $('.protected-branches-list'); + + // Build edit forms + this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => { + new gl.ProtectedBranchEdit({ + $wrap: $(el) + }); + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_select.js b/app/assets/javascripts/protected_branch_select.js deleted file mode 100644 index 3a47fc972dc..00000000000 --- a/app/assets/javascripts/protected_branch_select.js +++ /dev/null @@ -1,72 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.ProtectedBranchSelect = (function() { - function ProtectedBranchSelect(currentProject) { - this.toggleCreateNewButton = bind(this.toggleCreateNewButton, this); - this.getProtectedBranches = bind(this.getProtectedBranches, this); - $('.dropdown-footer').hide(); - this.dropdown = $('.js-protected-branch-select').glDropdown({ - data: this.getProtectedBranches, - filterable: true, - remote: false, - search: { - fields: ['title'] - }, - selectable: true, - toggleLabel: function(selected) { - if (selected && 'id' in selected) { - return selected.title; - } else { - return 'Protected Branch'; - } - }, - fieldName: 'protected_branch[name]', - text: function(protected_branch) { - return _.escape(protected_branch.title); - }, - id: function(protected_branch) { - return _.escape(protected_branch.id); - }, - onFilter: this.toggleCreateNewButton, - clicked: function() { - return $('.protect-branch-btn').attr('disabled', false); - } - }); - $('.create-new-protected-branch').on('click', (function(_this) { - return function(event) { - _this.dropdown.data('glDropdown').remote.execute(); - return _this.dropdown.data('glDropdown').selectRowAtIndex(event, 0); - }; - })(this)); - } - - ProtectedBranchSelect.prototype.getProtectedBranches = function(term, callback) { - if (this.selectedBranch) { - return callback(gon.open_branches.concat(this.selectedBranch)); - } else { - return callback(gon.open_branches); - } - }; - - ProtectedBranchSelect.prototype.toggleCreateNewButton = function(branchName) { - this.selectedBranch = { - title: branchName, - id: branchName, - text: branchName - }; - if (branchName === '') { - $('.protected-branch-select-footer-list').addClass('hidden'); - return $('.dropdown-footer').hide(); - } else { - $('.create-new-protected-branch').text("Create Protected Branch: " + branchName); - $('.protected-branch-select-footer-list').removeClass('hidden'); - return $('.dropdown-footer').show(); - } - }; - - return ProtectedBranchSelect; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/protected_branches_access_select.js.es6 b/app/assets/javascripts/protected_branches_access_select.js.es6 deleted file mode 100644 index e98312bbf37..00000000000 --- a/app/assets/javascripts/protected_branches_access_select.js.es6 +++ /dev/null @@ -1,63 +0,0 @@ -class ProtectedBranchesAccessSelect { - constructor(container, saveOnSelect, selectDefault) { - this.container = container; - this.saveOnSelect = saveOnSelect; - - this.container.find(".allowed-to-merge").each((i, element) => { - var fieldName = $(element).data('field-name'); - var dropdown = $(element).glDropdown({ - data: gon.merge_access_levels, - selectable: true, - fieldName: fieldName, - clicked: _.chain(this.onSelect).partial(element).bind(this).value() - }); - - if (selectDefault) { - dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0); - } - }); - - - this.container.find(".allowed-to-push").each((i, element) => { - var fieldName = $(element).data('field-name'); - var dropdown = $(element).glDropdown({ - data: gon.push_access_levels, - selectable: true, - fieldName: fieldName, - clicked: _.chain(this.onSelect).partial(element).bind(this).value() - }); - - if (selectDefault) { - dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0); - } - }); - } - - onSelect(dropdown, selected, element, e) { - $(dropdown).find('.dropdown-toggle-text').text(selected.text); - if (this.saveOnSelect) { - return $.ajax({ - type: "POST", - url: $(dropdown).data('url'), - dataType: "json", - data: { - _method: 'PATCH', - id: $(dropdown).data('id'), - protected_branch: { - ["" + ($(dropdown).data('type')) + "_attributes"]: { - "access_level": selected.id - } - } - }, - success: function() { - var row; - row = $(e.target); - return row.closest('tr').effect('highlight'); - }, - error: function() { - return new Flash("Failed to update branch!", "alert"); - } - }); - } - } -} diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6 new file mode 100644 index 00000000000..c32ddf80219 --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6 @@ -0,0 +1,51 @@ +/*= require ../blob/template_selector */ + +((global) => { + class IssuableTemplateSelector extends TemplateSelector { + constructor(...args) { + super(...args); + this.projectPath = this.dropdown.data('project-path'); + this.namespacePath = this.dropdown.data('namespace-path'); + this.issuableType = this.wrapper.data('issuable-type'); + this.titleInput = $(`#${this.issuableType}_title`); + + let initialQuery = { + name: this.dropdown.data('selected') + }; + + if (initialQuery.name) this.requestFile(initialQuery); + + $('.reset-template', this.dropdown.parent()).on('click', () => { + if (this.currentTemplate) this.setInputValueToTemplateContent(); + }); + } + + requestFile(query) { + this.startLoadingSpinner(); + Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => { + this.currentTemplate = currentTemplate; + if (err) return; // Error handled by global AJAX error handler + this.stopLoadingSpinner(); + this.setInputValueToTemplateContent(); + }); + return; + } + + setInputValueToTemplateContent() { + // `this.requestFileSuccess` sets the value of the description input field + // to the content of the template selected. + if (this.titleInput.val() === '') { + // If the title has not yet been set, focus the title input and + // skip focusing the description input by setting `true` as the 2nd + // argument to `requestFileSuccess`. + this.requestFileSuccess(this.currentTemplate, true); + this.titleInput.focus(); + } else { + this.requestFileSuccess(this.currentTemplate); + } + return; + } + } + + global.IssuableTemplateSelector = IssuableTemplateSelector; +})(window); diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 new file mode 100644 index 00000000000..bd8cdde033e --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 @@ -0,0 +1,29 @@ +((global) => { + class IssuableTemplateSelectors { + constructor(opts = {}) { + this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector'); + this.editor = opts.editor || this.initEditor(); + + this.$dropdowns.each((i, dropdown) => { + let $dropdown = $(dropdown); + new IssuableTemplateSelector({ + pattern: /(\.md)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-issuable-selector-wrap'), + dropdown: $dropdown, + editor: this.editor + }); + }); + } + + initEditor() { + let editor = $('.markdown-area'); + // Proxy ace-editor's .setValue to jQuery's .val + editor.setValue = editor.val; + editor.getValue = editor.val; + return editor; + } + } + + global.IssuableTemplateSelectors = IssuableTemplateSelectors; +})(window); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 4af2a214e12..bad82868ab0 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -13,14 +13,15 @@ } $('.js-user-search').each((function(_this) { return function(i, dropdown) { + var options = {}; var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser; $dropdown = $(dropdown); - _this.projectId = $dropdown.data('project-id'); - _this.showCurrentUser = $dropdown.data('current-user'); + options.projectId = $dropdown.data('project-id'); + options.showCurrentUser = $dropdown.data('current-user'); showNullUser = $dropdown.data('null-user'); showAnyUser = $dropdown.data('any-user'); firstUser = $dropdown.data('first-user'); - _this.authorId = $dropdown.data('author-id'); + options.authorId = $dropdown.data('author-id'); selectedId = $dropdown.data('selected'); defaultLabel = $dropdown.data('default-label'); issueURL = $dropdown.data('issueUpdate'); @@ -75,7 +76,7 @@ data: function(term, callback) { var isAuthorFilter; isAuthorFilter = $('.js-author-search'); - return _this.users(term, function(users) { + return _this.users(term, options, function(users) { var anyUser, index, j, len, name, obj, showDivider; if (term.length === 0) { showDivider = 0; @@ -140,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'; @@ -148,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')) { @@ -185,11 +191,14 @@ $('.ajax-users-select').each((function(_this) { return function(i, select) { var firstUser, showAnyUser, showEmailUser, showNullUser; - _this.projectId = $(select).data('project-id'); - _this.groupId = $(select).data('group-id'); - _this.showCurrentUser = $(select).data('current-user'); - _this.authorId = $(select).data('author-id'); - _this.skipUsers = $(select).data('skip-users'); + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); showNullUser = $(select).data('null-user'); showAnyUser = $(select).data('any-user'); showEmailUser = $(select).data('email-user'); @@ -199,7 +208,7 @@ multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query: function(query) { - return _this.users(query.term, function(users) { + return _this.users(query.term, options, function(users) { var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; data = { results: users @@ -309,7 +318,7 @@ }); }; - UsersSelect.prototype.users = function(query, callback) { + UsersSelect.prototype.users = function(query, options, callback) { var url; url = this.buildUrl(this.usersPath); return $.ajax({ @@ -318,11 +327,13 @@ search: query, per_page: 20, active: true, - project_id: this.projectId, - group_id: this.groupId, - current_user: this.showCurrentUser, - author_id: this.authorId, - skip_users: this.skipUsers + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null }, dataType: "json" }).done(function(users) { diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss index 542a53f0377..a6b9efc49c9 100644 --- a/app/assets/stylesheets/behaviors.scss +++ b/app/assets/stylesheets/behaviors.scss @@ -20,3 +20,8 @@ .turn-off { display: block; } } } + + +[v-cloak] { + display: none; +} diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 473530cf094..6c3786b49bb 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -164,6 +164,10 @@ @include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light); } + &.btn-spam { + @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light); + } + &.btn-danger, &.btn-remove, &.btn-red { @@ -200,6 +204,10 @@ position: relative; top: 2px; } + + svg, .fa { + margin-right: 3px; + } } .btn-lg { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index c54eb0d6479..f1635a53763 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -56,9 +56,13 @@ position: absolute; top: 50%; right: 6px; - margin-top: -4px; + margin-top: -6px; color: $dropdown-toggle-icon-color; font-size: 10px; + &.fa-spinner { + font-size: 16px; + margin-top: -8px; + } } &:hover, { @@ -72,6 +76,14 @@ &.large { width: 200px; } + + &.wide { + width: 100%; + + + .dropdown-select { + width: 100%; + } + } } .dropdown-menu, @@ -398,6 +410,7 @@ font-size: 14px; a { + cursor: pointer; padding-left: 10px; } } diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 7cf4d4fba42..07c8874bf03 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -6,11 +6,11 @@ table-layout: fixed; pre { - padding: 10px; + padding: 10px 0; border: none; border-radius: 0; font-family: $monospace_font; - font-size: $code_font_size !important; + font-size: $code_font_size; line-height: $code_line_height !important; margin: 0; overflow: auto; @@ -20,13 +20,20 @@ border-left: 1px solid; code { + display: inline-block; + min-width: 100%; font-family: $monospace_font; - white-space: pre; + white-space: normal; word-wrap: normal; padding: 0; .line { - display: inline-block; + display: block; + width: 100%; + min-height: 19px; + padding-left: 10px; + padding-right: 10px; + white-space: pre; } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 2c40ec430ca..965fcc06518 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -114,6 +114,12 @@ ul.content-list { font-size: $list-font-size; color: $list-text-color; + &.no-description { + .title { + line-height: $list-text-height; + } + } + .title { font-weight: 600; } @@ -134,12 +140,11 @@ ul.content-list { } .controls { - padding-top: 1px; float: right; > .control-text { margin-right: $gl-padding-top; - line-height: 40px; + line-height: $list-text-height; &:last-child { margin-right: 0; @@ -150,7 +155,7 @@ ul.content-list { > .btn-group { margin-right: $gl-padding-top; display: inline-block; - margin-top: 4px; + margin-top: 3px; margin-bottom: 4px; &:last-child { 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/nav.scss b/app/assets/stylesheets/framework/nav.scss index 364952d3b4a..7852fc9a424 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -182,7 +182,6 @@ > form { display: inline-block; - margin-top: -1px; } .icon-label { @@ -193,7 +192,6 @@ height: 35px; display: inline-block; position: relative; - top: 2px; margin-right: $gl-padding-top; /* Medium devices (desktops, 992px and up) */ diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index 874416e1007..c6f30e144fd 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -23,4 +23,9 @@ margin-top: $gl-padding; } } + + .panel-title { + font-size: inherit; + line-height: inherit; + } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3fa4a22258d..015fe3debf9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -222,3 +222,7 @@ header.header-pinned-nav { padding-right: $sidebar_collapsed_width; } } + +.right-sidebar { + border-left: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1882d4e888d..5da390118c6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -43,6 +43,7 @@ $gl-header-color: $gl-title-color; $list-font-size: $gl-font-size; $list-title-color: $gl-title-color; $list-text-color: $gl-text-color; +$list-text-height: 42px; /* * Markdown @@ -275,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/builds.scss b/app/assets/stylesheets/pages/builds.scss index e26f8f7080d..81fce55853c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -53,14 +53,6 @@ left: 70px; } } - - .nav-links { - svg { - position: relative; - top: 2px; - margin-right: 3px; - } - } } .build-header { @@ -108,24 +100,98 @@ } .right-sidebar.build-sidebar { - padding-top: $gl-padding; - padding-bottom: $gl-padding; + padding: $gl-padding 0; &.right-sidebar-collapsed { display: none; } + .blocks-container { + padding: $gl-padding; + } + .block { width: 100%; } .build-sidebar-header { - padding-top: 0; + padding: 0 $gl-padding $gl-padding; .gutter-toggle { margin-top: 0; } } + + .stage-item { + cursor: pointer; + + &:hover { + color: $gl-text-color; + } + } + + .build-dropdown { + padding: 0 $gl-padding; + + .dropdown-menu-toggle { + margin-top: 8px; + } + + .dropdown-menu { + right: $gl-padding; + left: $gl-padding; + width: auto; + } + } + + .builds-container { + margin-top: $gl-padding; + background-color: $white-light; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + max-height: 300px; + overflow: scroll; + + svg { + position: relative; + top: 2px; + margin-right: 3px; + height: 13px; + } + + a { + display: block; + padding: $gl-padding 10px $gl-padding 40px; + width: 270px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: $row-hover; + color: $gl-text-color; + } + } + + .build-job { + position: relative; + + .fa { + position: absolute; + left: 15px; + top: 20px; + display: none; + } + + &.active { + font-weight: bold; + + .fa { + display: block; + } + } + } + } } .build-detail-row { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 0298577c494..6a58b445afa 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -1,8 +1,6 @@ .commits-compare-switch { @include btn-default; @include btn-white; - background: image-url("switch_icon.png") no-repeat center center; - text-indent: -9999px; float: left; margin-right: 9px; } @@ -61,6 +59,10 @@ font-size: 0; } + .ci-status-link { + display: inline-block; + } + .btn-clipboard, .btn-transparent { padding-left: 0; padding-right: 0; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 21b1c223c88..21cee2e3a70 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -164,7 +164,10 @@ line-height: 0; img { border: 1px solid #fff; - background: image-url('trans_bg.gif'); + background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%), + linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%); + background-size: 10px 10px; + background-position: 0 0, 5px 5px; max-width: 100%; } &.deleted { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index e160d676e35..55f9d4a0011 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -1,5 +1,35 @@ .environments { + .commit-title { margin: 0; } + + .fa-play { + font-size: 14px; + } + + .dropdown-new { + color: $table-text-gray; + } + + .dropdown-menu { + + .fa { + margin-right: 6px; + color: $table-text-gray; + } + } + + .branch-name { + color: $gl-dark-link-color; + } +} + +.table.builds.environments { + min-width: 500px; + + .icon-container { + width: 20px; + text-align: center; + } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 2a3acc3eb4c..b657ca47d38 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -23,15 +23,9 @@ } .group-row { - &.no-description { - .group-name { - line-height: 44px; - } - } - .stats { float: right; - line-height: 44px; + line-height: $list-text-height; color: $gl-gray; span { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7a50bc9c832..46c4a11aa2e 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -395,3 +395,12 @@ display: inline-block; line-height: 18px; } + +.js-issuable-selector-wrap { + .js-issuable-selector { + width: 100%; + } + @media (max-width: $screen-sm-max) { + margin-bottom: $gl-padding; + } +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 3b1e38fc07d..606459f82cd 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -182,6 +182,17 @@ .btn { color: inherit; } + + a.btn { + padding: 0; + + .has-tooltip { + top: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + line-height: 1.1; + } + } } .label-options-toggle { 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/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 0a661e529f0..b4636269518 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -69,6 +69,10 @@ &.ci-success { color: $gl-success; + + a.environment { + color: inherit; + } } &.ci-success_with_warnings { @@ -126,7 +130,6 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } - } p:last-child { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index c58e2ffe7f5..ce1c424624f 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -18,6 +18,10 @@ .btn { margin: 4px; } + + .table.builds { + min-width: 1200px; + } } .content-list { @@ -35,7 +39,7 @@ } .table.builds { - min-width: 1200px; + min-width: 900px; &.pipeline { min-width: 650px; @@ -128,7 +132,7 @@ .icon-container { display: inline-block; text-align: right; - width: 20px; + width: 15px; .fa { position: relative; @@ -225,3 +229,196 @@ box-shadow: none; } } + +// Pipeline visualization + +.toggle-pipeline-btn { + background-color: $gray-dark; + + .caret { + border-top: none; + border-bottom: 4px solid; + } + + &.graph-collapsed { + background-color: $white-light; + + .caret { + border-bottom: none; + border-top: 4px solid; + } + } +} + +.pipeline-graph { + width: 100%; + overflow: auto; + white-space: nowrap; + max-height: 500px; + transition: max-height 0.3s, padding 0.3s; + + &.graph-collapsed { + max-height: 0; + padding: 0 16px; + } +} + +.pipeline-visualization { + position: relative; + min-width: 1220px; + + ul { + padding: 0; + } +} + +.stage-column { + display: inline-block; + vertical-align: top; + margin-right: 50px; + + li { + list-style: none; + } + + .stage-name { + margin-bottom: 15px; + font-weight: bold; + width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .build { + border: 1px solid $border-color; + position: relative; + padding: 6px 10px; + border-radius: 30px; + width: 150px; + margin-bottom: 10px; + + &.playable { + background-color: $gray-light; + } + + .build-content { + width: 130px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + a { + color: $layout-link-gray; + } + } + + svg { + position: relative; + top: 2px; + margin-right: 5px; + } + + .fa { + font-size: 13px; + } + + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 50%; + right: -54px; + border-top: 2px solid $border-color; + width: 54px; + height: 1px; + } + } + + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, &::before { + content: ''; + top: -47px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 20px; + height: 65px; + } + + // Right connecting curves + &::after { + right: -20px; + border-right: 2px solid $border-color; + border-radius: 0 0 50px; + } + + // Left connecting curves + &::before { + left: -20px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 50px; + } + } + + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, &::before { + height: 45px; + top: -26px; + } + } + } + + &:last-child { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child { + &::after, &::before { + border: none; + } + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child) { + &::after { + border: none; + } + } + } + } + + &:first-child { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child) { + &::before { + border: none; + } + } + } + } +} + +.pipeline-actions { + border-bottom: none; +} + +.toggle-pipeline-btn { + + .fa { + color: $dropdown-header-color; + } +} + +.pipelines.tab-pane { + + .content-list.pipelines { + overflow: scroll; + } + + .stage { + max-width: 60px; + width: 60px; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index abda079ad4c..eaf2d3270b3 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -99,7 +99,7 @@ margin-left: auto; margin-right: auto; margin-bottom: 15px; - max-width: 480px; + max-width: 700px; > p { margin-bottom: 0; @@ -512,18 +512,12 @@ pre.light-well { .project-row { border-color: $table-border-color; - &.no-description { - .project { - line-height: 40px; - } - } - .project-full-name { @include str-truncated; } .controls { - line-height: 40px; + line-height: $list-text-height; a:hover { text-decoration: none; @@ -662,13 +656,9 @@ pre.light-well { } .new_protected_branch { - .dropdown { - display: inline; - margin-left: 15px; - } - label { - min-width: 120px; + margin-top: 6px; + font-weight: normal; } } @@ -684,6 +674,21 @@ pre.light-well { font-weight: 600; } } + + .settings-message { + margin: 0; + border-radius: 0 0 1px 1px; + padding: 20px 0; + border: none; + } + + .table-bordered { + border-radius: 1px; + + th:not(:last-child), td:not(:last-child) { + border-right: solid 1px transparent; + } + } } .custom-notifications-form { diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index cf16d070cfe..0340526a53a 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -20,10 +20,43 @@ } } -.todo { +.todos-list > .todo { + // workaround because we cannot use border-colapse + border-top: 1px solid transparent; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + &:hover { + background-color: $row-hover; + border-color: $row-hover-border; cursor: pointer; } + + // overwrite border style of .content-list + &:last-child { + border-bottom: 1px solid transparent; + + &:hover { + border-color: $row-hover-border; + } + } + + .todo-actions { + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + -webkit-flex-direction: column; + flex-direction: column; + margin-left: 10px; + } + + .todo-item { + -webkit-flex: auto; + flex: auto; + } } .todo-item { @@ -43,8 +76,6 @@ } .todo-body { - margin-right: 174px; - .todo-note { word-wrap: break-word; @@ -90,6 +121,12 @@ } @media (max-width: $screen-xs-max) { + .todo { + .avatar { + display: none; + } + } + .todo-item { .todo-title { white-space: normal; @@ -98,10 +135,6 @@ margin-bottom: 10px; } - .avatar { - display: none; - } - .todo-body { margin: 0; border-left: 2px solid #ddd; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 390977297fb..9da40fe2b09 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -58,6 +58,10 @@ .tree_commit { max-width: 320px; + + .str-truncated { + max-width: 100%; + } } .tree_time_ago { diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index f3a88a8e6c8..4ce18321649 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -48,9 +48,9 @@ class Admin::GroupsController < Admin::ApplicationController end def destroy - DestroyGroupService.new(@group, current_user).execute + DestroyGroupService.new(@group, current_user).async_execute - redirect_to admin_groups_path, notice: 'Group was successfully deleted.' + redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion." end private diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 3a2f0185315..2abfa22712d 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -14,4 +14,14 @@ class Admin::SpamLogsController < Admin::ApplicationController head :ok end end + + def mark_as_ham + spam_log = SpamLog.find(params[:id]) + + if HamService.new(spam_log).mark_as_ham! + redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' + else + redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.' + end + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a1004d9bcea..634d36a4467 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -243,42 +243,6 @@ class ApplicationController < ActionController::Base end end - def set_filters_params - set_default_sort - - params[:scope] = 'all' if params[:scope].blank? - params[:state] = 'opened' if params[:state].blank? - - @sort = params[:sort] - @filter_params = params.dup - - if @project - @filter_params[:project_id] = @project.id - elsif @group - @filter_params[:group_id] = @group.id - else - # TODO: this filter ignore issues/mr created in public or - # internal repos where you are not a member. Enable this filter - # or improve current implementation to filter only issues you - # created or assigned or mentioned - # @filter_params[:authorized_only] = true - end - - @filter_params - end - - def get_issues_collection - set_filters_params - @issuable_finder = IssuesFinder.new(current_user, @filter_params) - @issuable_finder.execute - end - - def get_merge_requests_collection - set_filters_params - @issuable_finder = MergeRequestsFinder.new(current_user, @filter_params) - @issuable_finder.execute - end - def import_sources_enabled? !current_application_settings.import_sources.empty? end @@ -363,24 +327,4 @@ class ApplicationController < ActionController::Base def u2f_app_id request.base_url end - - private - - def set_default_sort - key = if is_a_listing_page_for?('issues') || is_a_listing_page_for?('merge_requests') - 'issuable_sort' - end - - cookies[key] = params[:sort] if key && params[:sort].present? - params[:sort] = cookies[key] if key - params[:sort] ||= 'id_desc' - end - - def is_a_listing_page_for?(page_type) - controller_name, action_name = params.values_at(:controller, :action) - - (controller_name == "projects/#{page_type}" && action_name == 'index') || - (controller_name == 'groups' && action_name == page_type) || - (controller_name == 'dashboard' && action_name == page_type) - end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index d828d163c28..b48668eea87 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,5 +1,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users] + before_action :load_project, only: [:users] before_action :find_users, only: [:users] def users @@ -34,19 +35,13 @@ class AutocompleteController < ApplicationController def projects project = Project.find_by_id(params[:project_id]) - - projects = current_user.authorized_projects - projects = projects.search(params[:search]) if params[:search].present? - projects = projects.select do |project| - current_user.can?(:admin_issue, project) - end + projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) no_project = { id: 0, name_with_namespace: 'No project', } - projects.unshift(no_project) - projects.delete(project) + projects.unshift(no_project) unless params[:offset_id].present? render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) end @@ -55,11 +50,8 @@ class AutocompleteController < ApplicationController def find_users @users = - if params[:project_id].present? - project = Project.find(params[:project_id]) - return render_404 unless can?(current_user, :read_project, project) - - project.team.users + if @project + @project.team.users elsif params[:group_id].present? group = Group.find(params[:group_id]) return render_404 unless can?(current_user, :read_group, group) @@ -71,4 +63,18 @@ class AutocompleteController < ApplicationController User.none end end + + def load_project + @project ||= begin + if params[:project_id].present? + project = Project.find(params[:project_id]) + return render_404 unless can?(current_user, :read_project, project) + project + end + end + end + + def projects_finder + MoveToProjectFinder.new(current_user) + end end diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb index 026d8b2e1e0..aeec3009f15 100644 --- a/app/controllers/concerns/diff_for_path.rb +++ b/app/controllers/concerns/diff_for_path.rb @@ -1,8 +1,8 @@ module DiffForPath extend ActiveSupport::Concern - def render_diff_for_path(diffs, diff_refs, project) - diff_file = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository).find do |diff| + def render_diff_for_path(diffs) + diff_file = diffs.diff_files.find do |diff| diff.old_path == params[:old_path] && diff.new_path == params[:new_path] end @@ -14,7 +14,7 @@ module DiffForPath locals = { diff_file: diff_file, diff_commit: diff_commit, - diff_refs: diff_refs, + diff_refs: diffs.diff_refs, blob: blob, project: project } diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb new file mode 100644 index 00000000000..c802922e0af --- /dev/null +++ b/app/controllers/concerns/issuable_collections.rb @@ -0,0 +1,79 @@ +module IssuableCollections + extend ActiveSupport::Concern + include SortingHelper + + included do + helper_method :issues_finder + helper_method :merge_requests_finder + end + + private + + def issues_collection + issues_finder.execute + end + + def merge_requests_collection + merge_requests_finder.execute + end + + def issues_finder + @issues_finder ||= issuable_finder_for(IssuesFinder) + end + + def merge_requests_finder + @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder) + end + + def issuable_finder_for(finder_class) + finder_class.new(current_user, filter_params) + end + + def filter_params + set_sort_order_from_cookie + set_default_scope + set_default_state + + @filter_params = params.dup + @filter_params[:sort] ||= default_sort_order + + @sort = @filter_params[:sort] + + if @project + @filter_params[:project_id] = @project.id + elsif @group + @filter_params[:group_id] = @group.id + else + # TODO: this filter ignore issues/mr created in public or + # internal repos where you are not a member. Enable this filter + # or improve current implementation to filter only issues you + # created or assigned or mentioned + # @filter_params[:authorized_only] = true + end + + @filter_params + end + + def set_default_scope + params[:scope] = 'all' if params[:scope].blank? + end + + def set_default_state + params[:state] = 'opened' if params[:state].blank? + end + + def set_sort_order_from_cookie + key = 'issuable_sort' + + cookies[key] = params[:sort] if params[:sort].present? + params[:sort] = cookies[key] + end + + def default_sort_order + case params[:state] + when 'opened', 'all' then sort_value_recently_created + when 'merged', 'closed' then sort_value_recently_updated + else sort_value_recently_created + end + end +end diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index 4feabc32b1c..b89fb94be6e 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -1,12 +1,14 @@ module IssuesAction extend ActiveSupport::Concern + include IssuableCollections def issues - @issues = get_issues_collection.non_archived - @issues = @issues.page(params[:page]) - @issues = @issues.preload(:author, :project) + @label = issues_finder.labels.first - @label = @issuable_finder.labels.first + @issues = issues_collection + .non_archived + .preload(:author, :project) + .page(params[:page]) respond_to do |format| format.html diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index 06a6b065e7e..a1b0eee37f9 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -1,11 +1,13 @@ module MergeRequestsAction extend ActiveSupport::Concern + include IssuableCollections def merge_requests - @merge_requests = get_merge_requests_collection.non_archived - @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.preload(:author, :target_project) + @label = merge_requests_finder.labels.first - @label = @issuable_finder.labels.first + @merge_requests = merge_requests_collection + .non_archived + .preload(:author, :target_project) + .page(params[:page]) end end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 471d15af913..a69877edfd4 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -7,11 +7,16 @@ module ServiceParams :build_key, :server, :teamcity_url, :drone_url, :build_type, :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, :colorize_messages, :channels, - :push_events, :issues_events, :merge_requests_events, :tag_push_events, - :note_events, :build_events, :wiki_page_events, - :notify_only_broken_builds, :add_pusher, - :send_from_committer_email, :disable_diffs, :external_wiki_url, - :notify, :color, + # We're using `issues_events` and `merge_requests_events` + # in the view so we still need to explicitly state them + # here. `Service#event_names` would only give + # `issue_events` and `merge_request_events` (singular!) + # See app/helpers/services_helper.rb for how we + # make those event names plural as special case. + :issues_events, :merge_requests_events, + :notify_only_broken_builds, :notify_only_broken_pipelines, + :add_pusher, :send_from_committer_email, :disable_diffs, + :external_wiki_url, :notify, :color, :server_host, :server_port, :default_irc_uri, :enable_ssl_verification, :jira_issue_transition_id] @@ -19,9 +24,7 @@ module ServiceParams FILTER_BLANK_PARAMS = [:password] def service_params - dynamic_params = [] - dynamic_params.concat(@service.event_channel_names) - + dynamic_params = @service.event_channel_names + @service.event_names service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params) if service_params[:service].is_a?(Hash) diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb new file mode 100644 index 00000000000..29e243c66a3 --- /dev/null +++ b/app/controllers/concerns/spammable_actions.rb @@ -0,0 +1,25 @@ +module SpammableActions + extend ActiveSupport::Concern + + included do + before_action :authorize_submit_spammable!, only: :mark_as_spam + end + + def mark_as_spam + if SpamService.new(spammable).mark_as_spam! + redirect_to spammable, notice: "#{spammable.class.to_s} was submitted to Akismet successfully." + else + redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.' + end + end + + private + + def spammable + raise NotImplementedError, "#{self.class} does not implement #{__method__}" + end + + def authorize_submit_spammable! + access_denied! unless current_user.admin? + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 19a76a5b5d8..1243bb96d4d 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -37,8 +37,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController def todos_counts { - count: TodosFinder.new(current_user, state: :pending).execute.count, - done_count: TodosFinder.new(current_user, state: :done).execute.count + count: current_user.todos_pending_count, + done_count: current_user.todos_done_count } end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 6780a6d4d87..cb82d62616c 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -87,9 +87,9 @@ class GroupsController < Groups::ApplicationController end def destroy - DestroyGroupService.new(@group, current_user).execute + DestroyGroupService.new(@group, current_user).async_execute - redirect_to root_path, alert: "Group '#{@group.name}' was successfully deleted." + redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion." end protected diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 30df1fb2fec..7d0eff37635 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -1,5 +1,6 @@ class Import::GitlabProjectsController < Import::BaseController before_action :verify_gitlab_project_import_enabled + before_action :authenticate_admin! def new @namespace_id = project_params[:namespace_id] @@ -12,13 +13,14 @@ class Import::GitlabProjectsController < Import::BaseController return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." }) end - imported_file = project_params[:file].path + "-import" + import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename) - FileUtils.copy_entry(project_params[:file].path, imported_file) + FileUtils.mkdir_p(File.dirname(import_upload_path)) + FileUtils.copy_entry(project_params[:file].path, import_upload_path) @project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id], current_user, - File.expand_path(imported_file), + import_upload_path, project_params[:path]).execute if @project.saved? @@ -46,4 +48,8 @@ class Import::GitlabProjectsController < Import::BaseController :path, :namespace_id, :file ) end + + def authenticate_admin! + render_404 unless current_user.is_admin? + end end diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c780e0983f9..6217ec5ecef 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -50,6 +50,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController flash[:notice] = "Password was successfully updated. Please login with it" redirect_to new_user_session_path else + @user.reload render 'edit' end end diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index a9f482c8787..6c25cd83a24 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -4,12 +4,26 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, except: [:index] def build - badge = Gitlab::Badge::Build.new(project, params[:ref]) + build_status = Gitlab::Badge::Build::Status + .new(project, params[:ref]) + render_badge build_status + end + + def coverage + coverage_report = Gitlab::Badge::Coverage::Report + .new(project, params[:ref], params[:job]) + + render_badge coverage_report + end + + private + + def render_badge(badge) respond_to do |format| format.html { render_404 } format.svg do - send_data(badge.data, type: badge.type, disposition: 'inline') + render 'badge', locals: { badge: badge.template } end end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index eda3727a28d..cdf9a04bacf 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -17,6 +17,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_branch_head, only: [:edit, :update] before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff + before_action :set_last_commit_sha, only: [:edit, :update] def new commit unless @repository.empty? @@ -33,7 +34,6 @@ class Projects::BlobController < Projects::ApplicationController end def edit - @last_commit = Gitlab::Git::Commit.last_for_path(@repository, @ref, @path).sha blob.load_all_data!(@repository) end @@ -55,6 +55,10 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: after_edit_path, failure_view: :edit, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + + rescue Files::UpdateService::FileChangedError + @conflict = true + render :edit end def preview @@ -76,6 +80,8 @@ class Projects::BlobController < Projects::ApplicationController end def diff + apply_diff_view_cookie! + @form = UnfoldForm.new(params) @lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path) @lines = @lines[@form.since - 1..@form.to - 1] @@ -150,7 +156,8 @@ class Projects::BlobController < Projects::ApplicationController file_path: @file_path, commit_message: params[:commit_message], file_content: params[:content], - file_content_encoding: params[:encoding] + file_content_encoding: params[:encoding], + last_commit_sha: params[:last_commit_sha] } end @@ -159,4 +166,9 @@ class Projects::BlobController < Projects::ApplicationController render nothing: true end end + + def set_last_commit_sha + @last_commit_sha = Gitlab::Git::Commit. + last_for_path(@repository, @ref, @path).sha + end end 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/branches_controller.rb b/app/controllers/projects/branches_controller.rb index e926043f3eb..48fe81b0d74 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -1,12 +1,13 @@ class Projects::BranchesController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper + include SortingHelper # Authorize before_action :require_non_empty_project before_action :authorize_download_code! before_action :authorize_push_code!, only: [:new, :create, :destroy] def index - @sort = params[:sort].presence || 'name' + @sort = params[:sort].presence || sort_value_name @branches = BranchesFinder.new(@repository, params).execute @branches = Kaminari.paginate_array(@branches).page(params[:page]) diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 553b62741a5..12195c3cbb8 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -6,7 +6,7 @@ class Projects::BuildsController < Projects::ApplicationController def index @scope = params[:scope] - @all_builds = project.builds + @all_builds = project.builds.relevant @builds = @all_builds.order('created_at DESC') @builds = case @scope diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 7ae034f9398..f44e9bb3fd7 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -28,7 +28,7 @@ class Projects::CommitController < Projects::ApplicationController end def diff_for_path - render_diff_for_path(@diffs, @commit.diff_refs, @project) + render_diff_for_path(@commit.diffs(diff_options)) end def builds @@ -134,8 +134,8 @@ class Projects::CommitController < Projects::ApplicationController end def define_status_vars - @statuses = CommitStatus.where(pipeline: pipelines) - @builds = Ci::Build.where(pipeline: pipelines) + @statuses = CommitStatus.where(pipeline: pipelines).relevant + @builds = Ci::Build.where(pipeline: pipelines).relevant end def assign_change_commit_vars(mr_source_branch) diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 8c004724f02..bee3d56076c 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -21,7 +21,7 @@ class Projects::CompareController < Projects::ApplicationController def diff_for_path return render_404 unless @compare - render_diff_for_path(@diffs, @diff_refs, @project) + render_diff_for_path(@compare.diffs(diff_options)) end def create @@ -40,18 +40,12 @@ class Projects::CompareController < Projects::ApplicationController @compare = CompareService.new.execute(@project, @head_ref, @project, @start_ref) if @compare - @commits = Commit.decorate(@compare.commits, @project) - - @start_commit = @project.commit(@start_ref) - @commit = @project.commit(@head_ref) - @base_commit = @project.merge_base_commit(@start_ref, @head_ref) + @commits = @compare.commits + @start_commit = @compare.start_commit + @commit = @compare.commit + @base_commit = @compare.base_commit @diffs = @compare.diffs(diff_options) - @diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: @base_commit.try(:sha), - start_sha: @start_commit.try(:sha), - head_sha: @commit.try(:sha) - ) @diff_notes_disabled = true @grouped_diff_discussions = {} diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 83d5ced9be8..529e0aa2d33 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -12,8 +12,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def new - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def create @@ -21,19 +20,16 @@ class Projects::DeployKeysController < Projects::ApplicationController set_index_vars if @key.valid? && @project.deploy_keys << @key - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) else render "index" end end def enable - @key = accessible_keys.find(params[:id]) - @project.deploy_keys << @key + Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def disable @@ -45,9 +41,9 @@ class Projects::DeployKeysController < Projects::ApplicationController protected def set_index_vars - @enabled_keys ||= @project.deploy_keys + @enabled_keys ||= @project.deploy_keys - @available_keys ||= accessible_keys - @enabled_keys + @available_keys ||= current_user.accessible_deploy_keys - @enabled_keys @available_project_keys ||= current_user.project_deploy_keys - @enabled_keys @available_public_keys ||= DeployKey.are_public - @enabled_keys @@ -56,10 +52,6 @@ class Projects::DeployKeysController < Projects::ApplicationController @available_public_keys -= @available_project_keys end - def accessible_keys - @accessible_keys ||= current_user.accessible_deploy_keys - end - def deploy_key_params params.require(:deploy_key).permit(:key, :title) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4b433796161..58678f96879 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -2,8 +2,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] - before_action :authorize_update_environment!, only: [:destroy] - before_action :environment, only: [:show, :destroy] + before_action :authorize_update_environment!, only: [:edit, :update, :destroy] + before_action :environment, only: [:show, :edit, :update, :destroy] def index @environments = project.environments @@ -17,13 +17,24 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment = project.environments.new end + def edit + end + def create - @environment = project.environments.create(create_params) + @environment = project.environments.create(environment_params) if @environment.persisted? redirect_to namespace_project_environment_path(project.namespace, project, @environment) else - render 'new' + render :new + end + end + + def update + if @environment.update(environment_params) + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + else + render :edit end end @@ -39,8 +50,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController private - def create_params - params.require(:environment).permit(:name) + def environment_params + params.require(:environment).permit(:name, :external_url) end def environment diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb new file mode 100644 index 00000000000..7c21bd181dc --- /dev/null +++ b/app/controllers/projects/git_http_client_controller.rb @@ -0,0 +1,110 @@ +# This file should be identical in GitLab Community Edition and Enterprise Edition + +class Projects::GitHttpClientController < Projects::ApplicationController + include ActionController::HttpAuthentication::Basic + include KerberosSpnegoHelper + + attr_reader :user + + # Git clients will not know what authenticity token to send along + skip_before_action :verify_authenticity_token + skip_before_action :repository + before_action :authenticate_user + before_action :ensure_project_found! + + private + + def authenticate_user + if project && project.public? && download_request? + return # Allow access + end + + if allow_basic_auth? && basic_auth_provided? + login, password = user_name_and_password(request) + auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip) + + if auth_result.type == :ci && download_request? + @ci = true + elsif auth_result.type == :oauth && !download_request? + # Not allowed + else + @user = auth_result.user + end + + if ci? || user + return # Allow access + end + elsif allow_kerberos_spnego_auth? && spnego_provided? + @user = find_kerberos_user + + if user + send_final_spnego_response + return # Allow access + end + end + + send_challenges + render plain: "HTTP Basic: Access denied\n", status: 401 + end + + def basic_auth_provided? + has_basic_credentials?(request) + end + + def send_challenges + challenges = [] + challenges << 'Basic realm="GitLab"' if allow_basic_auth? + challenges << spnego_challenge if allow_kerberos_spnego_auth? + headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? + end + + def ensure_project_found! + render_not_found if project.blank? + end + + def project + return @project if defined?(@project) + + project_id, _ = project_id_with_suffix + if project_id.blank? + @project = nil + else + @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}") + end + end + + # This method returns two values so that we can parse + # params[:project_id] (untrusted input!) in exactly one place. + def project_id_with_suffix + id = params[:project_id] || '' + + %w[.wiki.git .git].each do |suffix| + if id.end_with?(suffix) + # Be careful to only remove the suffix from the end of 'id'. + # Accidentally removing it from the middle is how security + # vulnerabilities happen! + return [id.slice(0, id.length - suffix.length), suffix] + end + end + + # Something is wrong with params[:project_id]; do not pass it on. + [nil, nil] + end + + def repository + _, suffix = project_id_with_suffix + if suffix == '.wiki.git' + project.wiki.repository + else + project.repository + end + end + + def render_not_found + render plain: 'Not Found', status: :not_found + end + + def ci? + @ci.present? + end +end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 40a8b7940d9..b4373ef89ef 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -1,17 +1,6 @@ # This file should be identical in GitLab Community Edition and Enterprise Edition -class Projects::GitHttpController < Projects::ApplicationController - include ActionController::HttpAuthentication::Basic - include KerberosSpnegoHelper - - attr_reader :user - - # Git clients will not know what authenticity token to send along - skip_before_action :verify_authenticity_token - skip_before_action :repository - before_action :authenticate_user - before_action :ensure_project_found! - +class Projects::GitHttpController < Projects::GitHttpClientController # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) def info_refs @@ -20,9 +9,9 @@ class Projects::GitHttpController < Projects::ApplicationController elsif receive_pack? && receive_pack_allowed? render_ok elsif http_blocked? - render_not_allowed + render_http_not_allowed else - render_not_found + render_denied end end @@ -31,7 +20,7 @@ class Projects::GitHttpController < Projects::ApplicationController if upload_pack? && upload_pack_allowed? render_ok else - render_not_found + render_denied end end @@ -40,87 +29,14 @@ class Projects::GitHttpController < Projects::ApplicationController if receive_pack? && receive_pack_allowed? render_ok else - render_not_found + render_denied end end private - def authenticate_user - if project && project.public? && upload_pack? - return # Allow access - end - - if allow_basic_auth? && basic_auth_provided? - login, password = user_name_and_password(request) - auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip) - - if auth_result.type == :ci && upload_pack? - @ci = true - elsif auth_result.type == :oauth && !upload_pack? - # Not allowed - else - @user = auth_result.user - end - - if ci? || user - return # Allow access - end - elsif allow_kerberos_spnego_auth? && spnego_provided? - @user = find_kerberos_user - - if user - send_final_spnego_response - return # Allow access - end - end - - send_challenges - render plain: "HTTP Basic: Access denied\n", status: 401 - end - - def basic_auth_provided? - has_basic_credentials?(request) - end - - def send_challenges - challenges = [] - challenges << 'Basic realm="GitLab"' if allow_basic_auth? - challenges << spnego_challenge if allow_kerberos_spnego_auth? - headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? - end - - def ensure_project_found! - render_not_found if project.blank? - end - - def project - return @project if defined?(@project) - - project_id, _ = project_id_with_suffix - if project_id.blank? - @project = nil - else - @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}") - end - end - - # This method returns two values so that we can parse - # params[:project_id] (untrusted input!) in exactly one place. - def project_id_with_suffix - id = params[:project_id] || '' - - %w[.wiki.git .git].each do |suffix| - if id.end_with?(suffix) - # Be careful to only remove the suffix from the end of 'id'. - # Accidentally removing it from the middle is how security - # vulnerabilities happen! - return [id.slice(0, id.length - suffix.length), suffix] - end - end - - # Something is wrong with params[:project_id]; do not pass it on. - [nil, nil] + def download_request? + upload_pack? end def upload_pack? @@ -143,47 +59,37 @@ class Projects::GitHttpController < Projects::ApplicationController render json: Gitlab::Workhorse.git_http_ok(repository, user) end - def repository - _, suffix = project_id_with_suffix - if suffix == '.wiki.git' - project.wiki.repository - else - project.repository - end - end - - def render_not_found - render plain: 'Not Found', status: :not_found + def render_http_not_allowed + render plain: access_check.message, status: :forbidden end - def render_not_allowed - render plain: download_access.message, status: :forbidden - end - - def ci? - @ci.present? + def render_denied + if user && user.can?(:read_project, project) + render plain: 'Access denied', status: :forbidden + else + # Do not leak information about project existence + render_not_found + end end def upload_pack_allowed? return false unless Gitlab.config.gitlab_shell.upload_pack if user - download_access.allowed? + access_check.allowed? else ci? || project.public? end end def access - return @access if defined?(@access) - - @access = Gitlab::GitAccess.new(user, project, 'http') + @access ||= Gitlab::GitAccess.new(user, project, 'http') end - def download_access - return @download_access if defined?(@download_access) - - @download_access = access.check('git-upload-pack') + def access_check + # Use the magic string '_any' to indicate we do not know what the + # changes are. This is also what gitlab-shell does. + @access_check ||= access.check(git_command, '_any') end def http_blocked? @@ -193,8 +99,6 @@ class Projects::GitHttpController < Projects::ApplicationController def receive_pack_allowed? return false unless Gitlab.config.gitlab_shell.receive_pack - # Skip user authorization on upload request. - # It will be done by the pre-receive hook in the repository. - user.present? + access_check.allowed? end end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index a60027ff477..b5624046387 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -56,6 +56,7 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( :build_events, + :pipeline_events, :enable_ssl_verification, :issues_events, :merge_requests_events, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 3c6f29ac0ba..e9fb11e8f94 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -3,7 +3,10 @@ class Projects::IssuesController < Projects::ApplicationController include ToggleSubscriptionAction include IssuableActions include ToggleAwardEmoji + include IssuableCollections + include SpammableActions + before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, :related_branches, :can_create_branch] @@ -24,7 +27,7 @@ class Projects::IssuesController < Projects::ApplicationController def index terms = params['issue_search'] - @issues = get_issues_collection + @issues = issues_collection if terms.present? if terms =~ /\A#(\d+)\z/ @@ -183,6 +186,7 @@ class Projects::IssuesController < Projects::ApplicationController alias_method :subscribable_resource, :issue alias_method :issuable, :issue alias_method :awardable, :issue + alias_method :spammable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) @@ -200,6 +204,18 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless @project.issues_enabled && @project.default_issues_tracker? end + def redirect_to_external_issue_tracker + external = @project.external_issue_tracker + + return unless external + + if action_name == 'new' + redirect_to external.new_issue_path + else + redirect_to external.issues_url + end + end + # Since iids are implemented only in 6.1 # user may navigate to issue page using old global ids. # diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb new file mode 100644 index 00000000000..ece49dcd922 --- /dev/null +++ b/app/controllers/projects/lfs_api_controller.rb @@ -0,0 +1,94 @@ +class Projects::LfsApiController < Projects::GitHttpClientController + include LfsHelper + + before_action :require_lfs_enabled! + before_action :lfs_check_access!, except: [:deprecated] + + def batch + unless objects.present? + render_lfs_not_found + return + end + + if download_request? + render json: { objects: download_objects! } + elsif upload_request? + render json: { objects: upload_objects! } + else + raise "Never reached" + end + end + + def deprecated + render( + json: { + message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', + documentation_url: "#{Gitlab.config.gitlab.url}/help", + }, + status: 501 + ) + end + + private + + def objects + @objects ||= (params[:objects] || []).to_a + end + + def existing_oids + @existing_oids ||= begin + storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) + end + end + + def download_objects! + objects.each do |object| + if existing_oids.include?(object[:oid]) + object[:actions] = download_actions(object) + else + object[:error] = { + code: 404, + message: "Object does not exist on the server or you don't have permissions to access it", + } + end + end + objects + end + + def upload_objects! + objects.each do |object| + object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid]) + end + objects + end + + def download_actions(object) + { + download: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}", + header: { + Authorization: request.headers['Authorization'] + }.compact + } + } + end + + def upload_actions(object) + { + upload: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", + header: { + Authorization: request.headers['Authorization'] + }.compact + } + } + end + + def download_request? + params[:operation] == 'download' + end + + def upload_request? + params[:operation] == 'upload' + end +end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb new file mode 100644 index 00000000000..69066cb40e6 --- /dev/null +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -0,0 +1,92 @@ +class Projects::LfsStorageController < Projects::GitHttpClientController + include LfsHelper + + before_action :require_lfs_enabled! + before_action :lfs_check_access! + + def download + lfs_object = LfsObject.find_by_oid(oid) + unless lfs_object && lfs_object.file.exists? + render_lfs_not_found + return + end + + send_file lfs_object.file.path, content_type: "application/octet-stream" + end + + def upload_authorize + render( + json: { + StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload", + LfsOid: oid, + LfsSize: size, + }, + content_type: 'application/json; charset=utf-8' + ) + end + + def upload_finalize + unless tmp_filename + render_lfs_forbidden + return + end + + if store_file(oid, size, tmp_filename) + head 200 + else + render plain: 'Unprocessable entity', status: 422 + end + end + + private + + def download_request? + action_name == 'download' + end + + def upload_request? + %w[upload_authorize upload_finalize].include? action_name + end + + def oid + params[:oid].to_s + end + + def size + params[:size].to_i + end + + def tmp_filename + name = request.headers['X-Gitlab-Lfs-Tmp'] + return if name.include?('/') + return unless oid.present? && name.start_with?(oid) + name + end + + def store_file(oid, size, tmp_file) + # Define tmp_file_path early because we use it in "ensure" + tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file) + + object = LfsObject.find_or_create_by(oid: oid, size: size) + file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path) + file_exists && link_to_project(object) + ensure + FileUtils.rm_f(tmp_file_path) + end + + def move_tmp_file_to_storage(object, path) + File.open(path) do |f| + object.file = f + end + + object.file.store! + object.save + end + + def link_to_project(object) + if object && !object.projects.exists?(storage_project.id) + object.projects << storage_project + object.save + end + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 47c21a18b33..00a3022cbf7 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -5,18 +5,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController include IssuableActions include NotesHelper include ToggleAwardEmoji + include IssuableCollections 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! @@ -27,9 +28,11 @@ 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 = get_merge_requests_collection + @merge_requests = merge_requests_collection if terms.present? if terms =~ /\A[#!](\d+)\z/ @@ -84,7 +87,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController respond_to do |format| format.html { define_discussion_vars } - format.json { render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } } + format.json do + @diffs = @merge_request.diffs(diff_options) + + render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } + end end end @@ -102,9 +109,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController end define_commit_vars - diffs = @merge_request.diffs(diff_options) - render_diff_for_path(diffs, @merge_request.diff_refs, @merge_request.project) + render_diff_for_path(@merge_request.diffs(diff_options)) end def commits @@ -126,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 @@ -137,7 +184,22 @@ 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 + apply_diff_view_cookie! + build_merge_request @noteable = @merge_request @@ -152,11 +214,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController @commits = @merge_request.compare_commits.reverse @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit - @diffs = @merge_request.compare.diffs(diff_options) if @merge_request.compare + @diffs = @merge_request.diffs(diff_options) if @merge_request.compare @diff_notes_disabled = true @pipeline = @merge_request.pipeline - @statuses = @pipeline.statuses if @pipeline + @statuses = @pipeline.statuses.relevant if @pipeline @note_counts = Note.where(commit_id: @commits.map(&:id)). group(:commit_id).count @@ -334,6 +396,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 @@ -358,7 +424,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @commits_count = @merge_request.commits.count @pipeline = @merge_request.pipeline - @statuses = @pipeline.statuses if @pipeline + @statuses = @pipeline.statuses.relevant if @pipeline if @merge_request.locked_long_ago? @merge_request.unlock_mr @@ -377,6 +443,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController fresh. discussions + preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) + # This is not executed lazily @notes = Banzai::NoteRenderer.render( @discussions.flat_map(&:notes), @@ -406,7 +474,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/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 487963fdcd7..b0c72cfe4b4 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -19,7 +19,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def create - @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute + @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute(ignore_skip_ci: true, save_on_errors: false) unless @pipeline.persisted? render 'new' return diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 85ba706e5cd..9136633b87a 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -3,7 +3,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show @ref = params[:ref] || @project.default_branch || 'master' - @build_badge = Gitlab::Badge::Build.new(@project, @ref) + + @badges = [Gitlab::Badge::Build::Status, + Gitlab::Badge::Coverage::Report] + + @badges.map! do |badge| + badge.new(@project, @ref).metadata + end end def update diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index d28ec6e2eac..9a438d5512c 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def index @protected_branch = @project.protected_branches.new - load_protected_branches_gon_variables + load_gon_index end def create - @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute + @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute if @protected_branch.persisted? redirect_to namespace_project_protected_branches_path(@project.namespace, @project) else load_protected_branches - load_protected_branches_gon_variables + load_gon_index render :index end end @@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def update - @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) + @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) if @protected_branch.valid? respond_to do |format| @@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def protected_branch_params params.require(:protected_branch).permit(:name, - merge_access_level_attributes: [:access_level], - push_access_level_attributes: [:access_level]) + merge_access_levels_attributes: [:access_level, :id], + push_access_levels_attributes: [:access_level, :id]) end def load_protected_branches @protected_branches = @project.protected_branches.order(:name).page(params[:page]) end - def load_protected_branches_gon_variables - gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }, - push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } }, - merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } }) + def access_levels_options + { + push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, + merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + } + end + + def load_gon_index + params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } } + gon.push(params.merge(access_levels_options)) end end diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb new file mode 100644 index 00000000000..694b468c8d3 --- /dev/null +++ b/app/controllers/projects/templates_controller.rb @@ -0,0 +1,19 @@ +class Projects::TemplatesController < Projects::ApplicationController + before_action :authenticate_user!, :get_template_class + + def show + template = @template_type.find(params[:key], project) + + respond_to do |format| + format.json { render json: template.to_json } + end + end + + private + + def get_template_class + template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access + @template_type = template_types[params[:template_type]] + render json: [], status: 404 unless @template_type + end +end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 607fe9c7fed..177ccf5eec9 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -91,7 +91,7 @@ class Projects::WikisController < Projects::ApplicationController ) end - def markdown_preview + def preview_markdown text = params[:text] ext = Gitlab::ReferenceExtractor.new(@project, current_user) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a6e1aa5ccc1..47efbd4a939 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -125,7 +125,7 @@ class ProjectsController < Projects::ApplicationController def destroy return access_denied! unless can?(current_user, :remove_project, @project) - ::Projects::DestroyService.new(@project, current_user, {}).pending_delete! + ::Projects::DestroyService.new(@project, current_user, {}).async_execute flash[:alert] = "Project '#{@project.name}' will be deleted." redirect_to dashboard_projects_path @@ -238,7 +238,7 @@ class ProjectsController < Projects::ApplicationController } end - def markdown_preview + def preview_markdown text = params[:text] ext = Gitlab::ReferenceExtractor.new(@project, current_user) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 75b78a49eab..3327f4f2b87 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -33,7 +33,7 @@ class RegistrationsController < Devise::RegistrationsController protected - def build_resource(hash=nil) + def build_resource(hash = nil) super end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 17aed816cbd..5d7ecfeacf4 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -101,7 +101,7 @@ class SessionsController < Devise::SessionsController # Prevent alert from popping up on the first page shown after authentication. flash[:alert] = nil - redirect_to user_omniauth_authorize_path(provider.to_sym) + redirect_to omniauth_authorize_path(:user, provider) end def valid_otp_attempt?(user) diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index a0932712bd0..33daac0399e 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -109,7 +109,7 @@ class IssuableFinder scope.where(title: params[:milestone_title]) else - nil + Milestone.none end end diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb new file mode 100644 index 00000000000..3334b8556df --- /dev/null +++ b/app/finders/move_to_project_finder.rb @@ -0,0 +1,14 @@ +class MoveToProjectFinder + def initialize(user) + @user = user + end + + def execute(from_project, search: nil, offset_id: nil) + projects = @user.projects_where_can_admin_issues + projects = projects.search(search) if search.present? + projects = projects.excluding_project(from_project) + + # to ask for Project#name_with_namespace + projects.includes(namespace: :owner) + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 2f0a9659d15..c7911736812 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -1,6 +1,7 @@ class ProjectsFinder < UnionFinder - def execute(current_user = nil, options = {}) + def execute(current_user = nil, project_ids_relation = nil) segments = all_projects(current_user) + segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation find_union(segments, Project) end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index ff866c2faa5..4fe0070552e 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -27,9 +27,11 @@ class TodosFinder items = by_action_id(items) items = by_action(items) items = by_author(items) - items = by_project(items) items = by_state(items) items = by_type(items) + # Filtering by project HAS TO be the last because we use + # the project IDs yielded by the todos query thus far + items = by_project(items) items.reorder(id: :desc) end @@ -91,14 +93,9 @@ class TodosFinder @project end - def projects - return @projects if defined?(@projects) - - if project? - @projects = project - else - @projects = ProjectsFinder.new.execute(current_user) - end + def projects(items) + item_project_ids = items.reorder(nil).select(:project_id) + ProjectsFinder.new.execute(current_user, item_project_ids) end def type? @@ -136,8 +133,9 @@ class TodosFinder def by_project(items) if project? items = items.where(project: project) - elsif projects - items = items.merge(projects).joins(:project) + else + item_projects = projects(items) + items = items.merge(item_projects).joins(:project) end items diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 03495cf5ec4..f3733b01721 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -163,9 +163,13 @@ module ApplicationHelper # `html_class` argument is provided. # # Returns an HTML-safe String - def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) + def time_ago_with_tooltip(time, placement: 'top', html_class: '', skip_js: false, short_format: false) + css_classes = short_format ? 'js-short-timeago' : 'js-timeago' + css_classes << " #{html_class}" unless html_class.blank? + css_classes << ' js-timeago-pending' unless skip_js + element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", + class: css_classes, datetime: time.to_time.getutc.iso8601, title: time.to_time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } @@ -245,7 +249,6 @@ module ApplicationHelper milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], author_id: params[:author_id], - sort: params[:sort], issue_search: params[:issue_search], label_name: params[:label_name] } @@ -317,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/avatars_helper.rb b/app/helpers/avatars_helper.rb index 6ff40c6b461..aa8acbe7567 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,5 +1,4 @@ module AvatarsHelper - def author_avatar(commit_or_event, options = {}) user_avatar(options.merge({ user: commit_or_event.author, @@ -8,8 +7,6 @@ module AvatarsHelper })) end - private - def user_avatar(options = {}) avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] @@ -26,5 +23,4 @@ module AvatarsHelper mail_to(options[:user_email], avatar) end end - end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 48c27828219..1cb5d847626 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -182,17 +182,42 @@ module BlobHelper } end + def selected_template(issuable) + templates = issuable_templates(issuable) + params[:issuable_template] if templates.include?(params[:issuable_template]) + end + + def can_add_template?(issuable) + names = issuable_templates(issuable) + names.empty? && can?(current_user, :push_code, @project) && !@project.private? + end + + def merge_request_template_names + @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project) + end + + def issue_template_names + @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project) + end + + def issuable_templates(issuable) + @issuable_templates ||= + if issuable.is_a?(Issue) + issue_template_names + elsif issuable.is_a?(MergeRequest) + merge_request_template_names + end + end + + def ref_project + @ref_project ||= @target_project || @project + end + def gitignore_names - @gitignore_names ||= - Gitlab::Template::Gitignore.categories.keys.map do |k| - [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names end def gitlab_ci_ymls - @gitlab_ci_ymls ||= - Gitlab::Template::GitlabCiYml.categories.keys.map do |k| - [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index ea2f5f9281a..94df7d131ca 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -38,6 +38,10 @@ module CiStatusHelper 'icon_status_pending' when 'running' 'icon_status_running' + when 'play' + return icon('play fw') + when 'created' + 'icon_status_pending' else 'icon_status_cancel' end @@ -48,13 +52,13 @@ module CiStatusHelper def render_commit_status(commit, tooltip_placement: 'auto left') project = commit.project path = builds_namespace_project_commit_path(project.namespace, project, commit) - render_status_with_link('commit', commit.status, path, tooltip_placement) + render_status_with_link('commit', commit.status, path, tooltip_placement: tooltip_placement) end def render_pipeline_status(pipeline, tooltip_placement: 'auto left') project = pipeline.project path = namespace_project_pipeline_path(project.namespace, project, pipeline) - render_status_with_link('pipeline', pipeline.status, path, tooltip_placement) + render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement) end def no_runners_for_project?(project) @@ -62,13 +66,17 @@ module CiStatusHelper Ci::Runner.shared.blank? end - private + def render_status_with_link(type, status, path = nil, tooltip_placement: 'auto left', cssclass: '') + klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}" + title = "#{type.titleize}: #{ci_label_for_status(status)}" + data = { toggle: 'tooltip', placement: tooltip_placement } - def render_status_with_link(type, status, path, tooltip_placement, cssclass: '') - link_to ci_icon_for_status(status), - path, - class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}", - title: "#{type.titleize}: #{ci_label_for_status(status)}", - data: { toggle: 'tooltip', placement: tooltip_placement } + if path + link_to ci_icon_for_status(status), path, + class: klass, title: title, data: data + else + content_tag :span, ci_icon_for_status(status), + class: klass, title: title, data: data + end end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index f497626e21a..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) @@ -206,10 +209,10 @@ module CommitsHelper end end - def view_file_btn(commit_sha, diff, project) + def view_file_btn(commit_sha, diff_new_path, project) link_to( namespace_project_blob_path(project.namespace, project, - tree_join(commit_sha, diff.new_path)), + tree_join(commit_sha, diff_new_path)), class: 'btn view-file js-view-file btn-file-option' ) do raw('View file @') + content_tag(:span, commit_sha[0..6], diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index f35e2f6ddcd..0725c3f4c56 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -13,12 +13,11 @@ module DiffHelper end def diff_view - diff_views = %w(inline parallel) - - if diff_views.include?(cookies[:diff_view]) - cookies[:diff_view] - else - diff_views.first + @diff_view ||= begin + diff_views = %w(inline parallel) + diff_view = cookies[:diff_view] + diff_view = diff_views.first unless diff_views.include?(diff_view) + diff_view.to_sym end end @@ -30,19 +29,26 @@ module DiffHelper options[:paths] = params.values_at(:old_path, :new_path) end - Commit.max_diff_options.merge(options) + options end - def safe_diff_files(diffs, diff_refs: nil, repository: nil) - diffs.decorate! { |diff| Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } - end + def diff_match_line(old_pos, new_pos, text: '', view: :inline, bottom: false) + content = content_tag :td, text, class: "line_content match #{view == :inline ? '' : view}" + cls = ['diff-line-num', 'unfold', 'js-unfold'] + cls << 'js-unfold-bottom' if bottom - def unfold_bottom_class(bottom) - bottom ? 'js-unfold js-unfold-bottom' : '' - end + html = '' + if old_pos + html << content_tag(:td, '...', class: cls + ['old_line'], data: { linenumber: old_pos }) + html << content unless view == :inline + end + + if new_pos + html << content_tag(:td, '...', class: cls + ['new_line'], data: { linenumber: new_pos }) + html << content + end - def unfold_class(unfold) - unfold ? 'unfold js-unfold' : '' + html.html_safe end def diff_line_content(line, line_type = nil) @@ -71,11 +77,11 @@ module DiffHelper end def inline_diff_btn - diff_btn('Inline', 'inline', diff_view == 'inline') + diff_btn('Inline', 'inline', diff_view == :inline) end def parallel_diff_btn - diff_btn('Side-by-side', 'parallel', diff_view == 'parallel') + diff_btn('Side-by-side', 'parallel', diff_view == :parallel) end def submodule_link(blob, ref, repository = @repository) @@ -103,11 +109,11 @@ module DiffHelper end end - def diff_file_html_data(project, diff_file) - commit = commit_for_diff(diff_file) + def diff_file_html_data(project, diff_file_path, diff_commit_id) { blob_diff_path: namespace_project_blob_diff_path(project.namespace, project, - tree_join(commit.id, diff_file.file_path)) + tree_join(diff_commit_id, diff_file_path)), + view: diff_view } end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 337b0aacbb5..2b1f3825adc 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,5 +1,5 @@ module ExploreHelper - def filter_projects_path(options={}) + def filter_projects_path(options = {}) exist_opts = { sort: params[:sort], scope: params[:scope], diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 2b0defd1dda..2e82b44437b 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -13,38 +13,6 @@ module IssuesHelper OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned') end - def url_for_project_issues(project = @project, options = {}) - return '' if project.nil? - - url = - if options[:only_path] - project.issues_tracker.project_path - else - project.issues_tracker.project_url - end - - # Ensure we return a valid URL to prevent possible XSS. - URI.parse(url).to_s - rescue URI::InvalidURIError - '' - end - - def url_for_new_issue(project = @project, options = {}) - return '' if project.nil? - - url = - if options[:only_path] - project.issues_tracker.new_issue_path - else - project.issues_tracker.new_issue_url - end - - # Ensure we return a valid URL to prevent possible XSS. - URI.parse(url).to_s - rescue URI::InvalidURIError - '' - end - def url_for_issue(issue_iid, project = @project, options = {}) return '' if project.nil? diff --git a/app/helpers/lfs_helper.rb b/app/helpers/lfs_helper.rb new file mode 100644 index 00000000000..eb651e3687e --- /dev/null +++ b/app/helpers/lfs_helper.rb @@ -0,0 +1,67 @@ +module LfsHelper + def require_lfs_enabled! + return if Gitlab.config.lfs.enabled + + render( + json: { + message: 'Git LFS is not enabled on this GitLab server, contact your admin.', + documentation_url: "#{Gitlab.config.gitlab.url}/help", + }, + status: 501 + ) + end + + def lfs_check_access! + return if download_request? && lfs_download_access? + return if upload_request? && lfs_upload_access? + + if project.public? || (user && user.can?(:read_project, project)) + render_lfs_forbidden + else + render_lfs_not_found + end + end + + def lfs_download_access? + project.public? || ci? || (user && user.can?(:download_code, project)) + end + + def lfs_upload_access? + user && user.can?(:push_code, project) + end + + def render_lfs_forbidden + render( + json: { + message: 'Access forbidden. Check your access level.', + documentation_url: "#{Gitlab.config.gitlab.url}/help", + }, + content_type: "application/vnd.git-lfs+json", + status: 403 + ) + end + + def render_lfs_not_found + render( + json: { + message: 'Not found.', + documentation_url: "#{Gitlab.config.gitlab.url}/help", + }, + content_type: "application/vnd.git-lfs+json", + status: 404 + ) + end + + def storage_project + @storage_project ||= begin + result = project + + loop do + break unless result.forked? + result = result.forked_from_project + end + + result + end + end +end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 183d3b77a3f..e9f6172f802 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -6,12 +6,6 @@ module MembersHelper "#{action}_#{member.type.underscore}".to_sym end - def default_show_roles(member) - can?(current_user, action_member_permission(:update, member), member) || - can?(current_user, action_member_permission(:destroy, member), member) || - can?(current_user, action_member_permission(:admin, member), member.source) - end - def remove_member_message(member, user: nil) user = current_user if defined?(current_user) 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/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 0c47abe0fba..26bde2230a9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -92,6 +92,10 @@ module NotesHelper project.team.max_member_access_for_user_ids(user_ids) end + def preload_noteable_for_regular_notes(notes) + ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) + end + def note_max_access_for_user(note) note.project.team.human_max_access(note.author_id) end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index a733dff1579..505545fbabb 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -263,6 +263,10 @@ module ProjectsHelper filename_path(project, :version) end + def ci_configuration_path(project) + filename_path(project, :gitlab_ci_yml) + end + def project_wiki_path_with_version(proj, page, version, is_newest) url_params = is_newest ? {} : { version_id: version } namespace_project_wiki_path(proj.namespace, proj, page, url_params) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a2bba139c17..c0195713f4a 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -107,7 +107,7 @@ module SearchHelper Sanitize.clean(str) end - def search_filter_path(options={}) + def search_filter_path(options = {}) exist_opts = { search: params[:search], project_id: params[:project_id], diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index d86f1999f5c..8b138a8e69f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -20,13 +20,19 @@ module SortingHelper end def projects_sort_options_hash - { + options = { sort_value_name => sort_title_name, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, } + + if current_controller?('admin/projects') + options.merge!(sort_value_largest_repo => sort_title_largest_repo) + end + + options end def sort_title_priority @@ -102,11 +108,11 @@ module SortingHelper end def sort_value_oldest_created - 'id_asc' + 'created_asc' end def sort_value_recently_created - 'id_desc' + 'created_desc' end def sort_value_milestone_soon diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index e3a208f826a..0465327060e 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -1,10 +1,10 @@ module TodosHelper def todos_pending_count - @todos_pending_count ||= TodosFinder.new(current_user, state: :pending).execute.count + @todos_pending_count ||= current_user.todos_pending_count end def todos_done_count - @todos_done_count ||= TodosFinder.new(current_user, state: :done).execute.count + @todos_done_count ||= current_user.todos_done_count end def todo_action_name(todo) diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index dbedf417fa5..4a76c679bad 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -4,23 +4,11 @@ module TreeHelper # # contents - A Grit::Tree object for the current tree def render_tree(tree) - # Render Folders before Files/Submodules + # Sort submodules and folders together by name ahead of files folders, files, submodules = tree.trees, tree.blobs, tree.submodules - tree = "" - - # Render folders if we have any - tree << render(partial: 'projects/tree/tree_item', collection: folders, - locals: { type: 'folder' }) if folders.present? - - # Render files if we have any - tree << render(partial: 'projects/tree/blob_item', collection: files, - locals: { type: 'file' }) if files.present? - - # Render submodules if we have any - tree << render(partial: 'projects/tree/submodule_item', - collection: submodules) if submodules.present? - + items = (folders + submodules).sort_by(&:name) + files + tree << render(partial: "projects/tree/tree_row", collection: items) if items.present? tree.html_safe end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 6f54c42146c..d64e48f774b 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id)) end + def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 9dd11d20ea6..95810b0ac6e 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end + def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) + end + def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/models/ability.rb b/app/models/ability.rb index d95a2507199..55265c3cfcb 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -6,6 +6,10 @@ class Ability return [] unless user.is_a?(User) return [] if user.blocked? + abilities_by_subject_class(user: user, subject: subject) + end + + def abilities_by_subject_class(user:, subject:) case subject when CommitStatus then commit_status_abilities(user, subject) when Project then project_abilities(user, subject) @@ -86,6 +90,8 @@ class Ability if project && project.public? rules = [ :read_project, + :read_board, + :read_list, :read_wiki, :read_label, :read_milestone, @@ -224,6 +230,8 @@ class Ability :read_project, :read_wiki, :read_issue, + :read_board, + :read_list, :read_label, :read_milestone, :read_project_snippet, @@ -245,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/blob.rb b/app/models/blob.rb index 0df2805e448..12cc5aaafba 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,6 +3,9 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour + # The maximum size of an SVG that can be displayed. + MAXIMUM_SVG_SIZE = 2.megabytes + # Wrap a Gitlab::Git::Blob object, or return nil when given nil # # This method prevents the decorated object from evaluating to "truthy" when @@ -31,6 +34,10 @@ class Blob < SimpleDelegator text? && language && language.name == 'SVG' end + def size_within_svg_limits? + size <= MAXIMUM_SVG_SIZE + end + def video? UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end 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/ci/build.rb b/app/models/ci/build.rb index 08f396210c9..ed056a07a49 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -16,7 +16,7 @@ module Ci scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } - scope :manual_actions, ->() { where(when: :manual) } + scope :manual_actions, ->() { where(when: :manual).relevant } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader @@ -42,40 +42,35 @@ module Ci end def retry(build, user = nil) - new_build = Ci::Build.new(status: 'pending') - new_build.ref = build.ref - new_build.tag = build.tag - new_build.options = build.options - new_build.commands = build.commands - new_build.tag_list = build.tag_list - new_build.project = build.project - new_build.pipeline = build.pipeline - new_build.name = build.name - new_build.allow_failure = build.allow_failure - new_build.stage = build.stage - new_build.stage_idx = build.stage_idx - new_build.trigger_request = build.trigger_request - new_build.yaml_variables = build.yaml_variables - new_build.when = build.when - new_build.user = user - new_build.environment = build.environment - new_build.save + new_build = Ci::Build.create( + ref: build.ref, + tag: build.tag, + options: build.options, + commands: build.commands, + tag_list: build.tag_list, + project: build.project, + pipeline: build.pipeline, + name: build.name, + allow_failure: build.allow_failure, + stage: build.stage, + stage_idx: build.stage_idx, + trigger_request: build.trigger_request, + yaml_variables: build.yaml_variables, + when: build.when, + user: user, + environment: build.environment, + status_event: 'enqueue' + ) MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) new_build end end - state_machine :status, initial: :pending do + state_machine :status do after_transition pending: :running do |build| build.execute_hooks end - # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed - around_transition any => [:success, :failed, :canceled] do |build, block| - block.call - build.pipeline.create_next_builds(build) if build.pipeline - end - after_transition any => [:success, :failed, :canceled] do |build| build.update_coverage build.execute_hooks @@ -102,12 +97,12 @@ module Ci end def playable? - project.builds_enabled? && commands.present? && manual? + project.builds_enabled? && commands.present? && manual? && skipped? end def play(current_user = nil) # Try to queue a current build - if self.queue + if self.enqueue self.update(user: current_user) self else @@ -349,7 +344,7 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::BuildDataBuilder.build(self) + build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) project.running_or_pending_build_count(force: true) @@ -461,7 +456,7 @@ module Ci def build_attributes_from_config return {} unless pipeline.config_processor - + pipeline.config_processor.build_attributes(name) end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bce6a992af6..c360a6ff729 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -13,13 +13,57 @@ module Ci has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest', foreign_key: :commit_id validates_presence_of :sha + validates_presence_of :ref validates_presence_of :status validate :valid_commit_sha - # Invalidate object and save if when touched - after_touch :update_state after_save :keep_around_commits + delegate :stages, to: :statuses + + state_machine :status, initial: :created do + event :enqueue do + transition created: :pending + transition [:success, :failed, :canceled, :skipped] => :running + end + + event :run do + transition any => :running + end + + event :skip do + transition any => :skipped + end + + event :drop do + transition any => :failed + end + + event :succeed do + transition any => :success + end + + event :cancel do + transition any => :canceled + end + + before_transition [:created, :pending] => :running do |pipeline| + pipeline.started_at = Time.now + end + + before_transition any => [:success, :failed, :canceled] do |pipeline| + pipeline.finished_at = Time.now + end + + before_transition do |pipeline| + pipeline.update_duration + end + + after_transition do |pipeline, transition| + pipeline.execute_hooks unless transition.loopback? + end + end + # ref can't be HEAD or SHA, can only be branch/tag name scope :latest_successful_for, ->(ref = default_branch) do where(ref: ref).success.order(id: :desc).limit(1) @@ -34,6 +78,10 @@ module Ci CommitStatus.where(pipeline: pluck(:id)).stages end + def stages_with_latest_statuses + statuses.latest.order(:stage_idx).group_by(&:stage) + end + def project_id project.id end @@ -109,37 +157,6 @@ module Ci trigger_requests.any? end - def create_builds(user, trigger_request = nil) - ## - # We persist pipeline only if there are builds available - # - return unless config_processor - - build_builds_for_stages(config_processor.stages, user, - 'success', trigger_request) && save - end - - def create_next_builds(build) - return unless config_processor - - # don't create other builds if this one is retried - latest_builds = builds.latest - return unless latest_builds.exists?(build.id) - - # get list of stages after this build - next_stages = config_processor.stages.drop_while { |stage| stage != build.stage } - next_stages.delete(build.stage) - - # get status for all prior builds - prior_builds = latest_builds.where.not(stage: next_stages) - prior_status = prior_builds.status - - # build builds for next stage that has builds available - # and save pipeline if we have builds - build_builds_for_stages(next_stages, build.user, prior_status, - build.trigger_request) && save - end - def retried @retried ||= (statuses.order(id: :desc) - statuses.latest) end @@ -151,6 +168,14 @@ module Ci end end + def config_builds_attributes + return [] unless config_processor + + config_processor. + builds_for_ref(ref, tag?, trigger_requests.first). + sort_by { |build| build[:stage_idx] } + end + def has_warnings? builds.latest.ignored.any? end @@ -182,10 +207,6 @@ module Ci end end - def skip_ci? - git_commit_message =~ /\[(ci skip|skip ci)\]/i if git_commit_message - end - def environments builds.where.not(environment: nil).success.pluck(:environment).uniq end @@ -207,37 +228,47 @@ module Ci Note.for_commit_id(sha) end + def process! + Ci::ProcessPipelineService.new(project, user).execute(self) + end + + def build_updated + case latest_builds_status + when 'pending' then enqueue + when 'running' then run + when 'success' then succeed + when 'failed' then drop + when 'canceled' then cancel + when 'skipped' then skip + end + end + def predefined_variables [ { key: 'CI_PIPELINE_ID', value: id.to_s, public: true } ] end + def update_duration + self.duration = statuses.latest.duration + end + + def execute_hooks + data = pipeline_data + project.execute_hooks(data, :pipeline_hooks) + project.execute_services(data, :pipeline_hooks) + end + private - def build_builds_for_stages(stages, user, status, trigger_request) - ## - # Note that `Array#any?` implements a short circuit evaluation, so we - # build builds only for the first stage that has builds available. - # - stages.any? do |stage| - CreateBuildsService.new(self). - execute(stage, user, status, trigger_request). - any?(&:active?) - end - end - - def update_state - statuses.reload - self.status = if yaml_errors.blank? - statuses.latest.status || 'skipped' - else - 'failed' - end - self.started_at = statuses.started_at - self.finished_at = statuses.finished_at - self.duration = statuses.latest.duration - save + def pipeline_data + Gitlab::DataBuilder::Pipeline.build(self) + end + + def latest_builds_status + return 'failed' unless yaml_errors.blank? + + statuses.latest.status || 'skipped' end def keep_around_commits diff --git a/app/models/commit.rb b/app/models/commit.rb index 486ad6714d9..cc413448ce8 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -104,7 +104,7 @@ class Commit end def diff_line_count - @diff_line_count ||= Commit::diff_line_count(self.diffs) + @diff_line_count ||= Commit::diff_line_count(raw_diffs) @diff_line_count end @@ -123,15 +123,17 @@ class Commit # In case this first line is longer than 100 characters, it is cut off # after 80 characters and ellipses (`&hellp;`) are appended. def title - title = safe_message + full_title.length > 100 ? full_title[0..79] << "…" : full_title + end - return no_commit_message if title.blank? + # Returns the full commits title + def full_title + return @full_title if @full_title - title_end = title.index("\n") - if (!title_end && title.length > 100) || (title_end && title_end > 100) - title[0..79] << "…" + if safe_message.blank? + @full_title = no_commit_message else - title.split("\n", 2).first + @full_title = safe_message.split("\n", 2).first end end @@ -315,6 +317,14 @@ class Commit nil end + def raw_diffs(*args) + raw.diffs(*args) + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) + end + private def find_author_by_any_email @@ -324,7 +334,7 @@ class Commit def repo_changes changes = { added: [], modified: [], removed: [] } - diffs.each do |diff| + raw_diffs(deltas_only: true).each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 2d185c28809..703ca90edb6 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -5,7 +5,7 @@ class CommitStatus < ActiveRecord::Base self.table_name = 'ci_builds' belongs_to :project, class_name: '::Project', foreign_key: :gl_project_id - belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, touch: true + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :user delegate :commit, to: :pipeline @@ -25,28 +25,36 @@ class CommitStatus < ActiveRecord::Base scope :ordered, -> { order(:name) } scope :ignored, -> { where(allow_failure: true, status: [:failed, :canceled]) } - state_machine :status, initial: :pending do - event :queue do - transition skipped: :pending + state_machine :status do + event :enqueue do + transition [:created, :skipped] => :pending end event :run do transition pending: :running end + event :skip do + transition [:created, :pending] => :skipped + end + event :drop do - transition [:pending, :running] => :failed + transition [:created, :pending, :running] => :failed end event :success do - transition [:pending, :running] => :success + transition [:created, :pending, :running] => :success end event :cancel do - transition [:pending, :running] => :canceled + transition [:created, :pending, :running] => :canceled end - after_transition pending: :running do |commit_status| + after_transition created: [:pending, :running] do |commit_status| + commit_status.update_attributes queued_at: Time.now + end + + after_transition [:created, :pending] => :running do |commit_status| commit_status.update_attributes started_at: Time.now end @@ -54,7 +62,18 @@ class CommitStatus < ActiveRecord::Base commit_status.update_attributes finished_at: Time.now end - after_transition [:pending, :running] => :success do |commit_status| + # We use around_transition to process pipeline on next stages as soon as possible, before the `after_*` is executed + around_transition any => [:success, :failed, :canceled] do |commit_status, block| + block.call + + commit_status.pipeline.try(:process!) + end + + after_transition do |commit_status, transition| + commit_status.pipeline.try(:build_updated) unless transition.loopback? + end + + after_transition [:created, :pending, :running] => :success do |commit_status| MergeRequests::MergeWhenBuildSucceedsService.new(commit_status.pipeline.project, nil).trigger(commit_status) end diff --git a/app/models/compare.rb b/app/models/compare.rb new file mode 100644 index 00000000000..4856510f526 --- /dev/null +++ b/app/models/compare.rb @@ -0,0 +1,66 @@ +class Compare + delegate :same, :head, :base, to: :@compare + + attr_reader :project + + def self.decorate(compare, project) + if compare.is_a?(Compare) + compare + else + self.new(compare, project) + end + end + + def initialize(compare, project) + @compare = compare + @project = project + end + + def commits + @commits ||= Commit.decorate(@compare.commits, project) + end + + def start_commit + return @start_commit if defined?(@start_commit) + + commit = @compare.base + @start_commit = commit ? ::Commit.new(commit, project) : nil + end + + def head_commit + return @head_commit if defined?(@head_commit) + + commit = @compare.head + @head_commit = commit ? ::Commit.new(commit, project) : nil + end + alias_method :commit, :head_commit + + def base_commit + return @base_commit if defined?(@base_commit) + + @base_commit = if start_commit && head_commit + project.merge_base_commit(start_commit.id, head_commit.id) + else + nil + end + end + + def raw_diffs(*args) + @compare.diffs(*args) + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Compare.new(self, + project: project, + diff_options: diff_options, + diff_refs: diff_refs) + end + + def diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: base_commit.try(:sha), + start_sha: start_commit.try(:sha), + head_sha: commit.try(:sha) + ) + end +end diff --git a/app/models/concerns/faster_cache_keys.rb b/app/models/concerns/faster_cache_keys.rb new file mode 100644 index 00000000000..5b14723fa2d --- /dev/null +++ b/app/models/concerns/faster_cache_keys.rb @@ -0,0 +1,16 @@ +module FasterCacheKeys + # A faster version of Rails' "cache_key" method. + # + # Rails' default "cache_key" method uses all kind of complex logic to figure + # out the cache key. In many cases this complexity and overhead may not be + # needed. + # + # This method does not do any timestamp parsing as this process is quite + # expensive and not needed when generating cache keys. This method also relies + # on the table name instead of the cache namespace name as the latter uses + # complex logic to generate the exact same value (as when using the table + # name) in 99% of the cases. + def cache_key + "#{self.class.table_name}/#{id}-#{read_attribute_before_type_cast(:updated_at)}" + end +end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb new file mode 100644 index 00000000000..5a7b36070e7 --- /dev/null +++ b/app/models/concerns/protected_branch_access.rb @@ -0,0 +1,7 @@ +module ProtectedBranchAccess + extend ActiveSupport::Concern + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 3b8e6df2da9..ce54fe5d3bf 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,9 +1,32 @@ module Spammable extend ActiveSupport::Concern + module ClassMethods + def attr_spammable(attr, options = {}) + spammable_attrs << [attr.to_s, options] + end + end + included do + has_one :user_agent_detail, as: :subject, dependent: :destroy + attr_accessor :spam + after_validation :check_for_spam, on: :create + + cattr_accessor :spammable_attrs, instance_accessor: false do + [] + end + + delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true + end + + def submittable_as_spam? + if user_agent_detail + user_agent_detail.submittable? + else + false + end end def spam? @@ -13,4 +36,33 @@ module Spammable def check_for_spam self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end + + def spam_title + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_title, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spam_description + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_description, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spammable_text + result = self.class.spammable_attrs.map do |attr| + public_send(attr.first) + end + + result.reject(&:blank?).join("\n") + end + + # Override in Spammable if further checks are necessary + def check_for_spam? + true + end end diff --git a/app/models/concerns/statuseable.rb b/app/models/concerns/statuseable.rb index 44c6b30f278..5d4b0a86899 100644 --- a/app/models/concerns/statuseable.rb +++ b/app/models/concerns/statuseable.rb @@ -1,18 +1,22 @@ module Statuseable extend ActiveSupport::Concern - AVAILABLE_STATUSES = %w(pending running success failed canceled skipped) + AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped] + STARTED_STATUSES = %w[running success failed skipped] + ACTIVE_STATUSES = %w[pending running] + COMPLETED_STATUSES = %w[success failed canceled] class_methods do def status_sql - builds = all.select('count(*)').to_sql - success = all.success.select('count(*)').to_sql - ignored = all.ignored.select('count(*)').to_sql if all.respond_to?(:ignored) + scope = all.relevant + builds = scope.select('count(*)').to_sql + success = scope.success.select('count(*)').to_sql + ignored = scope.ignored.select('count(*)').to_sql if scope.respond_to?(:ignored) ignored ||= '0' - pending = all.pending.select('count(*)').to_sql - running = all.running.select('count(*)').to_sql - canceled = all.canceled.select('count(*)').to_sql - skipped = all.skipped.select('count(*)').to_sql + pending = scope.pending.select('count(*)').to_sql + running = scope.running.select('count(*)').to_sql + canceled = scope.canceled.select('count(*)').to_sql + skipped = scope.skipped.select('count(*)').to_sql deduce_status = "(CASE WHEN (#{builds})=0 THEN NULL @@ -48,7 +52,8 @@ module Statuseable included do validates :status, inclusion: { in: AVAILABLE_STATUSES } - state_machine :status, initial: :pending do + state_machine :status, initial: :created do + state :created, value: 'created' state :pending, value: 'pending' state :running, value: 'running' state :failed, value: 'failed' @@ -57,6 +62,8 @@ module Statuseable state :skipped, value: 'skipped' end + scope :created, -> { where(status: 'created') } + scope :relevant, -> { where.not(status: 'created') } scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } scope :success, -> { where(status: 'success') } @@ -68,14 +75,14 @@ module Statuseable end def started? - !pending? && !canceled? && started_at + STARTED_STATUSES.include?(status) && started_at end def active? - running? || pending? + ACTIVE_STATUSES.include?(status) end def complete? - canceled? || success? || failed? + COMPLETED_STATUSES.include?(status) end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1a7cd60817e..1e338889714 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -36,4 +36,10 @@ class Deployment < ActiveRecord::Base def manual_actions deployable.try(:other_actions) end + + def includes_commit?(commit) + return false unless commit + + project.repository.is_ancestor?(commit.id, sha) + end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 9671955db36..e02a3d54c36 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -67,7 +67,7 @@ class DiffNote < Note return false unless supported? return true if for_commit? - diff_refs ||= self.noteable.diff_refs + diff_refs ||= noteable_diff_refs self.position.diff_refs == diff_refs end @@ -75,7 +75,15 @@ class DiffNote < Note private def supported? - !self.for_merge_request? || self.noteable.support_new_diff_notes? + !self.for_merge_request? || self.noteable.has_complete_diff_refs? + end + + def noteable_diff_refs + if noteable.respond_to?(:diff_sha_refs) + noteable.diff_sha_refs + else + noteable.diff_refs + end end def set_original_position @@ -96,7 +104,7 @@ class DiffNote < Note self.project, nil, old_diff_refs: self.position.diff_refs, - new_diff_refs: self.noteable.diff_refs, + new_diff_refs: noteable_diff_refs, paths: self.position.paths ).execute(self) end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 74facfd1c9c..e2218a5f02b 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -49,6 +49,12 @@ class Discussion self.noteable == target && !diff_discussion? end + def active? + return @active if defined?(@active) + + @active = first_note.active? + end + def expanded? !diff_discussion? || active? end diff --git a/app/models/environment.rb b/app/models/environment.rb index ac3a571a1f3..75e6f869786 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -3,6 +3,8 @@ class Environment < ActiveRecord::Base has_many :deployments + before_validation :nullify_external_url + validates :name, presence: true, uniqueness: { scope: :project_id }, @@ -10,7 +12,23 @@ class Environment < ActiveRecord::Base format: { with: Gitlab::Regex.environment_name_regex, message: Gitlab::Regex.environment_name_regex_message } + validates :external_url, + uniqueness: { scope: :project_id }, + length: { maximum: 255 }, + allow_nil: true, + addressable_url: true + def last_deployment deployments.last end + + def nullify_external_url + self.external_url = nil if self.external_url.blank? + end + + def includes_commit?(commit) + return false unless last_deployment + + last_deployment.includes_commit?(commit) + end end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index ba42a8eeb70..836a75b0608 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,5 +5,6 @@ class ProjectHook < WebHook scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } scope :build_hooks, -> { where(build_events: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 8b87b6c3d64..f365dee3141 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -8,6 +8,7 @@ class WebHook < ActiveRecord::Base default_value_for :merge_requests_events, false default_value_for :tag_push_events, false default_value_for :build_events, false + default_value_for :pipeline_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } diff --git a/app/models/issue.rb b/app/models/issue.rb index 11f734cfc6d..788611305fe 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base include Sortable include Taskable include Spammable + include FasterCacheKeys DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -35,6 +36,9 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -261,4 +265,9 @@ class Issue < ActiveRecord::Base def overdue? due_date.try(:past?) || false end + + # Only issues on public projects should be checked for spam + def check_for_spam? + project.public? + end end diff --git a/app/models/key.rb b/app/models/key.rb index b9bc38a0436..568a60b8af3 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -26,8 +26,9 @@ class Key < ActiveRecord::Base end def publishable_key - # Removes anything beyond the keytype and key itself - self.key.split[0..1].join(' ') + # Strip out the keys comment so we don't leak email addresses + # Replace with simple ident of user_name (hostname) + self.key.split[0..1].push("#{self.user_name} (#{Gitlab.config.gitlab.host})").join(' ') end # projects that has this key 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/label_link.rb b/app/models/label_link.rb index 47bd6eaf35f..51b5c2b1f4c 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -1,7 +1,9 @@ class LabelLink < ActiveRecord::Base + include Importable + belongs_to :target, polymorphic: true belongs_to :label - validates :target, presence: true - validates :label, presence: true + validates :target, presence: true, unless: :importing? + validates :label, presence: true, unless: :importing? end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 865712268a0..6ed66001513 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -85,7 +85,7 @@ class LegacyDiffNote < Note return nil unless noteable return @diff if defined?(@diff) - @diff = noteable.diffs(Commit.max_diff_options).find do |d| + @diff = noteable.raw_diffs(Commit.max_diff_options).find do |d| d.new_path && Digest::SHA1.hexdigest(d.new_path) == diff_file_hash end end @@ -116,7 +116,7 @@ class LegacyDiffNote < Note # Find the diff on noteable that matches our own def find_noteable_diff - diffs = noteable.diffs(Commit.max_diff_options) + diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end end 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/members/project_member.rb b/app/models/members/project_member.rb index d5666de79cc..ec2d40eb11c 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -8,6 +8,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE validates_format_of :source_type, with: /\AProject\z/ + validates :access_level, inclusion: { in: Gitlab::Access.values } default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } @@ -21,19 +22,19 @@ class ProjectMember < Member # or symbol like :master representing role # # Ex. - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # ProjectMember::MASTER # ) # - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # :master # ) # - def add_users_into_projects(project_ids, user_ids, access, current_user: nil, expires_at: nil) + def add_users_to_projects(project_ids, user_ids, access, current_user: nil, expires_at: nil) access_level = if roles_hash.has_key?(access) roles_hash[access] elsif roles_hash.values.include?(access.to_i) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 471e32f3b60..4304ef04767 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -104,6 +104,7 @@ class MergeRequest < ActiveRecord::Base scope :from_project, ->(project) { where(source_project_id: project.id) } scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } + scope :from_source_branches, ->(branches) { where(source_branch: branches) } scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } @@ -164,8 +165,16 @@ class MergeRequest < ActiveRecord::Base merge_request_diff ? merge_request_diff.first_commit : compare_commits.first end - def diffs(*args) - merge_request_diff ? merge_request_diff.diffs(*args) : compare.diffs(*args) + def raw_diffs(*args) + merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args) + end + + def diffs(diff_options = nil) + if self.compare + self.compare.diffs(diff_options) + else + Gitlab::Diff::FileCollection::MergeRequest.new(self, diff_options: diff_options) + end end def diff_size @@ -238,11 +247,11 @@ class MergeRequest < ActiveRecord::Base end def target_branch_sha - target_branch_head.try(:sha) + @target_branch_sha || target_branch_head.try(:sha) end def source_branch_sha - source_branch_head.try(:sha) + @source_branch_sha || source_branch_head.try(:sha) end def diff_refs @@ -255,6 +264,19 @@ class MergeRequest < ActiveRecord::Base ) end + # Return diff_refs instance trying to not touch the git repository + def diff_sha_refs + if merge_request_diff && merge_request_diff.diff_refs_by_sha? + return Gitlab::Diff::DiffRefs.new( + base_sha: merge_request_diff.base_commit_sha, + start_sha: merge_request_diff.start_commit_sha, + head_sha: merge_request_diff.head_commit_sha + ) + else + diff_refs + end + end + def validate_branches if target_project == source_project && target_branch == source_branch errors.add :branch_conflict, "You can not use same project/branch for source and target" @@ -300,6 +322,8 @@ class MergeRequest < ActiveRecord::Base merge_request_diff.reload_content + MergeRequests::MergeRequestDiffCacheService.new.execute(self) + new_diff_refs = self.diff_refs update_diff_notes_positions( @@ -567,6 +591,14 @@ class MergeRequest < ActiveRecord::Base !pipeline || pipeline.success? end + def environments + return unless diff_head_commit + + target_project.environments.select do |environment| + environment.includes_commit?(diff_head_commit) + end + end + def state_human_name if merged? "Merged" @@ -642,10 +674,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 @@ -658,12 +701,12 @@ class MergeRequest < ActiveRecord::Base merge_commit end - def support_new_diff_notes? - diff_refs && diff_refs.complete? + 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| @@ -691,4 +734,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/merge_request_diff.rb b/app/models/merge_request_diff.rb index 3f520c8f3ff..32cc6a3bfea 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -33,12 +33,12 @@ class MergeRequestDiff < ActiveRecord::Base end def size - real_size.presence || diffs.size + real_size.presence || raw_diffs.size end - def diffs(options={}) + def raw_diffs(options = {}) if options[:ignore_whitespace_change] - @diffs_no_whitespace ||= begin + @raw_diffs_no_whitespace ||= begin compare = Gitlab::Git::Compare.new( repository.raw_repository, self.start_commit_sha || self.target_branch_sha, @@ -47,8 +47,8 @@ class MergeRequestDiff < ActiveRecord::Base compare.diffs(options) end else - @diffs ||= {} - @diffs[options] ||= load_diffs(st_diffs, options) + @raw_diffs ||= {} + @raw_diffs[options] ||= load_diffs(st_diffs, options) end end @@ -82,6 +82,10 @@ class MergeRequestDiff < ActiveRecord::Base project.commit(self.head_commit_sha) end + def diff_refs_by_sha? + base_commit_sha? && head_commit_sha? && start_commit_sha? + end + def compare @compare ||= begin diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8b52cc824cd..7c29d27ce97 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -1,4 +1,6 @@ class Namespace < ActiveRecord::Base + acts_as_paranoid + include Sortable include Gitlab::ShellAdapter diff --git a/app/models/note.rb b/app/models/note.rb index b6b2ac6aa42..ddcd7f9d034 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -5,6 +5,7 @@ class Note < ActiveRecord::Base include Mentionable include Awardable include Importable + include FasterCacheKeys # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. diff --git a/app/models/project.rb b/app/models/project.rb index 7aecd7860c5..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 @@ -197,6 +199,8 @@ class Project < ActiveRecord::Base scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } + scope :excluding_project, ->(project) { where.not(id: project) } + state_machine :import_status, initial: :none do event :import_start do transition [:none, :finished] => :started @@ -379,9 +383,10 @@ class Project < ActiveRecord::Base joins(join_body).reorder('join_note_counts.amount DESC') end - # Deletes gitlab project export files older than 24 hours - def remove_gitlab_exports! - Gitlab::Popen.popen(%W(find #{Gitlab::ImportExport.storage_path} -not -path #{Gitlab::ImportExport.storage_path} -mmin +1440 -delete)) + def cached_count + Rails.cache.fetch('total_project_count', expires_in: 5.minutes) do + Project.count + end end end @@ -870,10 +875,16 @@ class Project < ActiveRecord::Base # Check if current branch name is marked as protected in the system def protected_branch?(branch_name) + return true if empty_repo? && default_branch_protected? + @protected_branches ||= self.protected_branches.to_a ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present? end + def user_can_push_to_empty_repo?(user) + !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER + end + def forked? !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?) end @@ -992,6 +1003,10 @@ class Project < ActiveRecord::Base project_members.find_by(user_id: user) end + def add_user(user, access_level, current_user = nil) + team.add_user(user, access_level, current_user) + end + def default_branch @default_branch ||= repository.root_ref if repository.exists? end @@ -1154,16 +1169,6 @@ class Project < ActiveRecord::Base @wiki ||= ProjectWiki.new(self, self.owner) end - def schedule_delete!(user_id, params) - # Queue this task for after the commit, so once we mark pending_delete it will run - run_after_commit do - job_id = ProjectDestroyWorker.perform_async(id, user_id, params) - Rails.logger.info("User #{user_id} scheduled destruction of project #{path_with_namespace} with job ID #{job_id}") - end - - update_attribute(:pending_delete, true) - end - def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1253,8 +1258,23 @@ class Project < ActiveRecord::Base authorized_for_user_by_shared_projects?(user, min_access_level) end + def append_or_update_attribute(name, value) + old_values = public_send(name.to_s) + + if Project.reflect_on_association(name).try(:macro) == :has_many && old_values.any? + update_attribute(name, old_values + value) + else + update_attribute(name, value) + end + end + private + def default_branch_protected? + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE + end + def authorized_for_user_by_group?(user, min_access_level) member = user.group_members.find_by(source_id: group) diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index 5e166471077..fa66e5864b8 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -51,8 +51,7 @@ class BuildsEmailService < Service end def test_data(project = nil, user = nil) - build = project.builds.last - Gitlab::BuildDataBuilder.build(build) + Gitlab::DataBuilder::Build.build(project.builds.last) end def fields diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 511b2eac792..5af93860d09 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -1,4 +1,6 @@ class CampfireService < Service + include HTTParty + prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? @@ -29,18 +31,53 @@ class CampfireService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) - room = gate.find_room_by_name(self.room) - return true unless room - + self.class.base_uri base_uri message = build_message(data) - - room.speak(message) + speak(self.room, message, auth) end private - def gate - @gate ||= Tinder::Campfire.new(subdomain, token: token) + def base_uri + @base_uri ||= "https://#{subdomain}.campfirenow.com" + end + + def auth + # use a dummy password, as explained in the Campfire API doc: + # https://github.com/basecamp/campfire-api#authentication + @auth ||= { + basic_auth: { + username: token, + password: 'X' + } + } + end + + # Post a message into a room, returns the message Hash in case of success. + # Returns nil otherwise. + # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message + def speak(room_name, message, auth) + room = rooms(auth).find { |r| r["name"] == room_name } + return nil unless room + + path = "/room/#{room["id"]}/speak.json" + body = { + body: { + message: { + type: 'TextMessage', + body: message + } + } + } + res = self.class.post(path, auth.merge(body)) + res.code == 201 ? res : nil + end + + # Returns a list of rooms, or []. + # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms + def rooms(auth) + res = self.class.get("/rooms.json", auth) + res.code == 200 ? res["rooms"] : [] end def build_message(push) diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index ad19b7795da..5301f9fa0ff 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,7 +1,9 @@ class PivotaltrackerService < Service include HTTParty - prop_accessor :token + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + + prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? def title @@ -18,7 +20,17 @@ class PivotaltrackerService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' } + { + type: 'text', + name: 'token', + placeholder: 'Pivotal Tracker API token.' + }, + { + type: 'text', + name: 'restrict_to_branch', + placeholder: 'Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.' + } ] end @@ -28,8 +40,8 @@ class PivotaltrackerService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) + return unless allowed_branch?(data[:ref]) - url = 'https://www.pivotaltracker.com/services/v5/source_commits' data[:commits].each do |commit| message = { 'source_commit' => { @@ -40,7 +52,7 @@ class PivotaltrackerService < Service } } PivotaltrackerService.post( - url, + API_ENDPOINT, body: message.to_json, headers: { 'Content-Type' => 'application/json', @@ -49,4 +61,15 @@ class PivotaltrackerService < Service ) end end + + private + + def allowed_branch?(ref) + return true unless ref.present? && restrict_to_branch.present? + + branch = Gitlab::Git.ref_name(ref) + allowed_branches = restrict_to_branch.split(',').map(&:strip) + + branch.present? && allowed_branches.include?(branch) + end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 1865c5253e2..a65d271d262 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -34,7 +34,7 @@ class ProjectTeam end def add_users(users, access, current_user: nil, expires_at: nil) - ProjectMember.add_users_into_projects( + ProjectMember.add_users_to_projects( [project.id], users, access, @@ -139,8 +139,13 @@ class ProjectTeam def max_member_access_for_user_ids(user_ids) user_ids = user_ids.uniq key = "max_member_access:#{project.id}" - RequestStore.store[key] ||= {} - access = RequestStore.store[key] + + access = {} + + if RequestStore.active? + RequestStore.store[key] ||= {} + access = RequestStore.store[key] + end # Lookup only the IDs we need user_ids = user_ids - access.keys diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index a255710f577..46f70da2452 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -56,6 +56,10 @@ class ProjectWiki end end + def repository_exists? + !!repository.exists? + end + def empty? pages.empty? end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 226b3f54342..6240912a6e1 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base validates :name, presence: true validates :project, presence: true - has_one :merge_access_level, dependent: :destroy - has_one :push_access_level, dependent: :destroy + has_many :merge_access_levels, dependent: :destroy + has_many :push_access_levels, dependent: :destroy - accepts_nested_attributes_for :push_access_level - accepts_nested_attributes_for :merge_access_level + validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + + accepts_nested_attributes_for :push_access_levels + accepts_nested_attributes_for :merge_access_levels def commit project.commit(self.name) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index b1112ee737d..806b3ccd275 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 6a5e49cf453..92e9c51d883 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/repository.rb b/app/models/repository.rb index bac37483c47..2494c266cd2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -372,7 +372,7 @@ class Repository # We don't want to flush the cache if the commit didn't actually make any # changes to any of the possible avatar files. if revision && commit = self.commit(revision) - return unless commit.diffs. + return unless commit.raw_diffs(deltas_only: true). any? { |diff| AVATAR_FILES.include?(diff.new_path) } end @@ -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. @@ -601,7 +617,7 @@ class Repository commit(sha) end - def next_branch(name, opts={}) + def next_branch(name, opts = {}) branch_ids = self.branch_names.map do |n| next 1 if n == name result = n.match(/\A#{name}-([0-9]+)\z/) @@ -636,9 +652,7 @@ class Repository def tags_sorted_by(value) case value when 'name' - # Would be better to use `sort_by` but `version_sorter` only exposes - # `sort` and `rsort` - VersionSorter.rsort(tag_names).map { |tag_name| find_tag(tag_name) } + VersionSorter.rsort(tags) { |tag| tag.name } when 'updated_desc' tags_sorted_by_committed_date.reverse when 'updated_asc' @@ -871,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] @@ -1061,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/models/service.rb b/app/models/service.rb index 40cd9b861f0..09b4717a523 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -36,6 +36,7 @@ class Service < ActiveRecord::Base scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } scope :build_hooks, -> { where(build_events: true, active: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } @@ -79,13 +80,17 @@ class Service < ActiveRecord::Base end def test_data(project, user) - Gitlab::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end def event_channel_names [] end + def event_names + supported_events.map { |event| "#{event}_events" } + end + def event_field(event) nil end diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 12df68ef83b..3b8b9833565 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base user.block user.destroy end + + def text + [title, description].join("\n") + end end diff --git a/app/models/spam_report.rb b/app/models/spam_report.rb deleted file mode 100644 index cdc7321b08e..00000000000 --- a/app/models/spam_report.rb +++ /dev/null @@ -1,5 +0,0 @@ -class SpamReport < ActiveRecord::Base - belongs_to :user - - validates :user, presence: true -end diff --git a/app/models/user.rb b/app/models/user.rb index db747434959..48e83ab7e56 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,13 +23,13 @@ class User < ActiveRecord::Base default_value_for :theme_id, gitlab_config.default_theme attr_encrypted :otp_secret, - key: Gitlab::Application.config.secret_key_base, + key: Gitlab::Application.secrets.otp_key_base, mode: :per_attribute_iv_and_salt, insecure_mode: true, algorithm: 'aes-256-cbc' devise :two_factor_authenticatable, - otp_secret_encryption_key: Gitlab::Application.config.secret_key_base + otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base devise :two_factor_backupable, otp_number_of_backup_codes: 10 serialize :otp_backup_codes, JSON @@ -429,6 +429,13 @@ class User < ActiveRecord::Base owned_groups.select(:id), namespace.id).joins(:namespace) end + # Returns projects which user can admin issues on (for example to move an issue to that project). + # + # This logic is duplicated from `Ability#project_abilities` into a SQL form. + def projects_where_can_admin_issues + authorized_projects(Gitlab::Access::REPORTER).non_archived.where.not(issues_enabled: false) + end + def is_admin? admin end @@ -809,13 +816,13 @@ class User < ActiveRecord::Base def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do - todos.done.count + TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do - todos.pending.count + TodosFinder.new(self, state: :pending).execute.count end end diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb new file mode 100644 index 00000000000..0949c6ef083 --- /dev/null +++ b/app/models/user_agent_detail.rb @@ -0,0 +1,9 @@ +class UserAgentDetail < ActiveRecord::Base + belongs_to :subject, polymorphic: true + + validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true + + def submittable? + !submitted? + end +end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb new file mode 100644 index 00000000000..5c60addbe7c --- /dev/null +++ b/app/services/akismet_service.rb @@ -0,0 +1,79 @@ +class AkismetService + attr_accessor :owner, :text, :options + + def initialize(owner, text, options = {}) + @owner = owner + @text = text + @options = options + end + + def is_spam? + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + created_at: DateTime.now, + author: owner.name, + author_email: owner.email, + referrer: options[:referrer], + } + + begin + is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) + is_spam || is_blatant + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") + false + end + end + + def submit_ham + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner.name, + author_email: owner.email + } + + begin + akismet_client.submit_ham(options[:ip_address], options[:user_agent], params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + def submit_spam + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner.name, + author_email: owner.email + } + + begin + akismet_client.submit_spam(options[:ip_address], options[:user_agent], params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + private + + def akismet_client + @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, + Gitlab.config.gitlab.url) + end + + def akismet_enabled? + current_application_settings.akismet_enabled + end +end diff --git a/app/services/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/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb deleted file mode 100644 index 4946f7076fd..00000000000 --- a/app/services/ci/create_builds_service.rb +++ /dev/null @@ -1,62 +0,0 @@ -module Ci - class CreateBuildsService - def initialize(pipeline) - @pipeline = pipeline - @config = pipeline.config_processor - end - - def execute(stage, user, status, trigger_request = nil) - builds_attrs = @config.builds_for_stage_and_ref(stage, @pipeline.ref, @pipeline.tag, trigger_request) - - # check when to create next build - builds_attrs = builds_attrs.select do |build_attrs| - case build_attrs[:when] - when 'on_success' - status == 'success' - when 'on_failure' - status == 'failed' - when 'always', 'manual' - %w(success failed).include?(status) - end - end - - # don't create the same build twice - builds_attrs.reject! do |build_attrs| - @pipeline.builds.find_by(ref: @pipeline.ref, - tag: @pipeline.tag, - trigger_request: trigger_request, - name: build_attrs[:name]) - end - - builds_attrs.map do |build_attrs| - build_attrs.slice!(:name, - :commands, - :tag_list, - :options, - :allow_failure, - :stage, - :stage_idx, - :environment, - :when, - :yaml_variables) - - build_attrs.merge!(pipeline: @pipeline, - ref: @pipeline.ref, - tag: @pipeline.tag, - trigger_request: trigger_request, - user: user, - project: @pipeline.project) - - # TODO: The proper implementation for this is in - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5295 - build_attrs[:status] = 'skipped' if build_attrs[:when] == 'manual' - - ## - # We do not persist new builds here. - # Those will be persisted when @pipeline is saved. - # - @pipeline.builds.new(build_attrs) - end - end - end -end diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb new file mode 100644 index 00000000000..005014fa1de --- /dev/null +++ b/app/services/ci/create_pipeline_builds_service.rb @@ -0,0 +1,42 @@ +module Ci + class CreatePipelineBuildsService < BaseService + attr_reader :pipeline + + def execute(pipeline) + @pipeline = pipeline + + new_builds.map do |build_attributes| + create_build(build_attributes) + end + end + + private + + def create_build(build_attributes) + build_attributes = build_attributes.merge( + pipeline: pipeline, + project: pipeline.project, + ref: pipeline.ref, + tag: pipeline.tag, + user: current_user, + trigger_request: trigger_request + ) + pipeline.builds.create(build_attributes) + end + + def new_builds + @new_builds ||= pipeline.config_builds_attributes. + reject { |build| existing_build_names.include?(build[:name]) } + end + + def existing_build_names + @existing_build_names ||= pipeline.builds.pluck(:name) + end + + def trigger_request + return @trigger_request if defined?(@trigger_request) + + @trigger_request ||= pipeline.trigger_requests.first + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index be91bf0db85..cde856b0186 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -1,49 +1,101 @@ module Ci class CreatePipelineService < BaseService - def execute - pipeline = project.pipelines.new(params) - pipeline.user = current_user + attr_reader :pipeline - unless ref_names.include?(params[:ref]) - pipeline.errors.add(:base, 'Reference not found') - return pipeline + def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil) + @pipeline = Ci::Pipeline.new( + project: project, + ref: ref, + sha: sha, + before_sha: before_sha, + tag: tag?, + trigger_requests: Array(trigger_request), + user: current_user + ) + + unless project.builds_enabled? + return error('Pipeline is disabled') end - if commit - pipeline.sha = commit.id - else - pipeline.errors.add(:base, 'Commit not found') - return pipeline + unless trigger_request || can?(current_user, :create_pipeline, project) + return error('Insufficient permissions to create a new pipeline') end - unless can?(current_user, :create_pipeline, project) - pipeline.errors.add(:base, 'Insufficient permissions to create a new pipeline') - return pipeline + unless branch? || tag? + return error('Reference not found') + end + + unless commit + return error('Commit not found') end unless pipeline.config_processor - pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') - return pipeline + unless pipeline.ci_yaml_file + return error('Missing .gitlab-ci.yml file') + end + return error(pipeline.yaml_errors, save: save_on_errors) end - pipeline.save! + if !ignore_skip_ci && skip_ci? + pipeline.skip if save_on_errors + return pipeline + end - unless pipeline.create_builds(current_user) - pipeline.errors.add(:base, 'No builds for this pipeline.') + unless pipeline.config_builds_attributes.present? + return error('No builds for this pipeline.') end pipeline.save + pipeline.process! pipeline end private - def ref_names - @ref_names ||= project.repository.ref_names + def skip_ci? + pipeline.git_commit_message =~ /\[(ci skip|skip ci)\]/i if pipeline.git_commit_message end def commit - @commit ||= project.commit(params[:ref]) + @commit ||= project.commit(origin_sha || origin_ref) + end + + def sha + commit.try(:id) + end + + def before_sha + params[:checkout_sha] || params[:before] || Gitlab::Git::BLANK_SHA + end + + def origin_sha + params[:checkout_sha] || params[:after] + end + + def origin_ref + params[:ref] + end + + def branch? + project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref) + end + + def tag? + project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref) + end + + def ref + Gitlab::Git.ref_name(origin_ref) + end + + def valid_sha? + origin_sha && origin_sha != Gitlab::Git::BLANK_SHA + end + + def error(message, save: false) + pipeline.errors.add(:base, message) + pipeline.drop if save + pipeline end end end diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 1e629cf119a..6af3c1ca5b1 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -1,20 +1,11 @@ module Ci class CreateTriggerRequestService def execute(project, trigger, ref, variables = nil) - commit = project.commit(ref) - return unless commit + trigger_request = trigger.trigger_requests.create(variables: variables) - # check if ref is tag - tag = project.repository.find_tag(ref).present? - - pipeline = project.pipelines.create(sha: commit.sha, ref: ref, tag: tag) - - trigger_request = trigger.trigger_requests.create!( - variables: variables, - pipeline: pipeline, - ) - - if pipeline.create_builds(nil, trigger_request) + pipeline = Ci::CreatePipelineService.new(project, nil, ref: ref). + execute(ignore_skip_ci: true, trigger_request: trigger_request) + if pipeline.persisted? trigger_request end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb new file mode 100644 index 00000000000..6f7610d42ba --- /dev/null +++ b/app/services/ci/process_pipeline_service.rb @@ -0,0 +1,77 @@ +module Ci + class ProcessPipelineService < BaseService + attr_reader :pipeline + + def execute(pipeline) + @pipeline = pipeline + + # This method will ensure that our pipeline does have all builds for all stages created + if created_builds.empty? + create_builds! + end + + new_builds = + stage_indexes_of_created_builds.map do |index| + process_stage(index) + end + + # Return a flag if a when builds got enqueued + new_builds.flatten.any? + end + + private + + def create_builds! + Ci::CreatePipelineBuildsService.new(project, current_user).execute(pipeline) + end + + def process_stage(index) + current_status = status_for_prior_stages(index) + + created_builds_in_stage(index).select do |build| + process_build(build, current_status) + end + end + + def process_build(build, current_status) + return false unless Statuseable::COMPLETED_STATUSES.include?(current_status) + + if valid_statuses_for_when(build.when).include?(current_status) + build.enqueue + true + else + build.skip + false + end + end + + def valid_statuses_for_when(value) + case value + when 'on_success' + %w[success] + when 'on_failure' + %w[failed] + when 'always' + %w[success failed] + else + [] + end + end + + def status_for_prior_stages(index) + pipeline.builds.where('stage_idx < ?', index).latest.status || 'success' + end + + def stage_indexes_of_created_builds + created_builds.order(:stage_idx).pluck('distinct stage_idx') + end + + def created_builds_in_stage(index) + created_builds.where(stage_idx: index) + end + + def created_builds + pipeline.builds.created + end + end +end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 149822aa647..6d6075628af 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -20,10 +20,12 @@ class CompareService ) end - Gitlab::Git::Compare.new( + raw_compare = Gitlab::Git::Compare.new( target_project.repository.raw_repository, target_branch, - source_sha, + source_sha ) + + Compare.new(raw_compare, target_project) end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb deleted file mode 100644 index 0b66b854dea..00000000000 --- a/app/services/create_commit_builds_service.rb +++ /dev/null @@ -1,69 +0,0 @@ -class CreateCommitBuildsService - def execute(project, user, params) - return unless project.builds_enabled? - - before_sha = params[:checkout_sha] || params[:before] - sha = params[:checkout_sha] || params[:after] - origin_ref = params[:ref] - - ref = Gitlab::Git.ref_name(origin_ref) - tag = Gitlab::Git.tag_ref?(origin_ref) - - # Skip branch removal - if sha == Gitlab::Git::BLANK_SHA - return false - end - - @pipeline = Ci::Pipeline.new( - project: project, - sha: sha, - ref: ref, - before_sha: before_sha, - tag: tag, - user: user) - - ## - # Skip creating pipeline if no gitlab-ci.yml is found - # - unless @pipeline.ci_yaml_file - return false - end - - ## - # Skip creating builds for commits that have [ci skip] - # but save pipeline object - # - if @pipeline.skip_ci? - return save_pipeline! - end - - ## - # Skip creating builds when CI config is invalid - # but save pipeline object - # - unless @pipeline.config_processor - return save_pipeline! - end - - ## - # Skip creating pipeline object if there are no builds for it. - # - unless @pipeline.create_builds(user) - @pipeline.errors.add(:base, 'No builds created') - return false - end - - save_pipeline! - end - - private - - ## - # Create a new pipeline and touch object to calculate status - # - def save_pipeline! - @pipeline.save! - @pipeline.touch - @pipeline - end -end diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb deleted file mode 100644 index 59a66fde47a..00000000000 --- a/app/services/create_spam_log_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateSpamLogService < BaseService - def initialize(project, user, params) - super(project, user, params) - end - - def execute - spam_params = params.merge({ user_id: @current_user.id, - project_id: @project.id } ) - spam_log = SpamLog.new(spam_params) - spam_log.save - spam_log - end -end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 87f066edb6f..918eddaa53a 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -39,7 +39,12 @@ class DeleteBranchService < BaseService end def build_push_data(branch) - Gitlab::PushDataBuilder - .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + branch.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", + []) end end diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 32e0eed6b63..d0cb151a010 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -33,7 +33,12 @@ class DeleteTagService < BaseService end def build_push_data(tag) - Gitlab::PushDataBuilder - .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + tag.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", + []) end end diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index ce79287e35a..eaff88d6463 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -18,9 +18,14 @@ class DeleteUserService user.personal_projects.each do |project| # Skip repository removal because we remove directory with namespace # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute end - user.destroy + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + namespace = user.namespace + user_data = user.destroy + namespace.really_destroy! + + user_data end end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 3c42ac61be4..0081364b8aa 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -5,13 +5,23 @@ class DestroyGroupService @group, @current_user = group, user end + def async_execute + group.transaction do + # Soft delete via paranoia gem + group.destroy + job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) + Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") + end + end + def execute group.projects.each do |project| + # Execute the destruction of the models immediately to ensure atomic cleanup. # Skip repository removal because we remove directory with namespace - # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + # that contain all these repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute end - group.destroy + group.really_destroy! end end diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c4a206f785e..ea94818713b 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -15,6 +15,7 @@ module Files else params[:file_content] end + @last_commit_sha = params[:last_commit_sha] # Validate parameters validate diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 8d2b5083179..4fc3b640799 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,11 +2,34 @@ require_relative "base_service" module Files class UpdateService < Files::BaseService + class FileChangedError < StandardError; end + def commit repository.update_file(current_user, @file_path, @file_content, branch: @target_branch, previous_path: @previous_path, message: @commit_message) end + + private + + def validate + super + + if file_has_changed? + raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") + end + end + + def file_has_changed? + return false unless @last_commit_sha && last_commit + + @last_commit_sha != last_commit.sha + end + + def last_commit + @last_commit ||= Gitlab::Git::Commit. + last_for_path(@source_project.repository, @source_branch, @file_path) + end end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 3f6a177bf3a..78feb37aa2a 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -69,7 +69,7 @@ class GitPushService < BaseService SystemHooksService.new.execute_hooks(build_push_data_system_hook.dup, :push_hooks) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) - CreateCommitBuildsService.new.execute(@project, current_user, build_push_data) + Ci::CreatePipelineService.new(project, current_user, build_push_data).execute ProjectCacheWorker.perform_async(@project.id) end @@ -91,12 +91,12 @@ class GitPushService < BaseService params = { name: @project.default_branch, - push_access_level_attributes: { + push_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - }, - merge_access_level_attributes: { + }], + merge_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } + }] } ProtectedBranches::CreateService.new(@project, current_user, params).execute @@ -138,13 +138,23 @@ class GitPushService < BaseService end def build_push_data - @push_data ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits) + @push_data ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + push_commits) end def build_push_data_system_hook - @push_data_system ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], []) + @push_data_system ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + []) end def push_to_existing_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 969530c4fdc..e6002b03b93 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -11,7 +11,7 @@ class GitTagPushService < BaseService SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks) project.execute_hooks(@push_data.dup, :tag_push_hooks) project.execute_services(@push_data.dup, :tag_push_hooks) - CreateCommitBuildsService.new.execute(project, current_user, @push_data) + Ci::CreatePipelineService.new(project, current_user, @push_data).execute ProjectCacheWorker.perform_async(project.id) true @@ -34,12 +34,24 @@ class GitTagPushService < BaseService end end - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message) + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + commits, + message) end def build_system_push_data - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '') + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + [], + '') end end diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb new file mode 100644 index 00000000000..b0e1799b489 --- /dev/null +++ b/app/services/ham_service.rb @@ -0,0 +1,26 @@ +class HamService + attr_accessor :spam_log + + def initialize(spam_log) + @spam_log = spam_log + end + + def mark_as_ham! + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spam_log.user, + spam_log.text, + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + ) + end +end diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb new file mode 100644 index 00000000000..6442406d77e --- /dev/null +++ b/app/services/import_export_clean_up_service.rb @@ -0,0 +1,24 @@ +class ImportExportCleanUpService + LAST_MODIFIED_TIME_IN_MINUTES = 1440 + + attr_reader :mmin, :path + + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) + @mmin = mmin + @path = Gitlab::ImportExport.storage_path + end + + def execute + Gitlab::Metrics.measure(:import_export_clean_up) do + return unless File.directory?(path) + + clean_up_export_files + end + end + + private + + def clean_up_export_files + Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2d96efe1042..b0ea7c905f8 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -104,11 +104,12 @@ class IssuableBaseService < BaseService change_subscription(issuable) filter_params old_labels = issuable.labels.to_a + old_mentioned_users = issuable.mentioned_users.to_a if params.present? && update_issuable(issuable, params) issuable.reset_events_cache handle_common_system_notes(issuable, old_labels: old_labels) - handle_changes(issuable, old_labels: old_labels) + handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 5e2de2ccf64..65550ab8ec6 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -3,29 +3,34 @@ module Issues def execute filter_params label_params = params.delete(:label_ids) - request = params.delete(:request) - api = params.delete(:api) - issue = project.issues.new(params) - issue.author = params[:author] || current_user + @request = params.delete(:request) + @api = params.delete(:api) + @issue = project.issues.new(params) + @issue.author = params[:author] || current_user - issue.spam = spam_check_service.execute(request, api) + @issue.spam = spam_service.check(@api) - if issue.save - issue.update_attributes(label_ids: label_params) - notification_service.new_issue(issue, current_user) - todo_service.new_issue(issue, current_user) - event_service.open_issue(issue, current_user) - issue.create_cross_references!(current_user) - execute_hooks(issue, 'open') + if @issue.save + @issue.update_attributes(label_ids: label_params) + notification_service.new_issue(@issue, current_user) + todo_service.new_issue(@issue, current_user) + event_service.open_issue(@issue, current_user) + user_agent_detail_service.create + @issue.create_cross_references!(current_user) + execute_hooks(@issue, 'open') end - issue + @issue end private - def spam_check_service - SpamCheckService.new(project, current_user, params) + def spam_service + SpamService.new(@issue, @request) + end + + def user_agent_detail_service + UserAgentDetailService.new(@issue, @request) end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index c7d406cc331..a2111b3806b 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,7 +4,7 @@ module Issues update(issue) end - def handle_changes(issue, old_labels: []) + def handle_changes(issue, old_labels: [], old_mentioned_users: []) if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -32,6 +32,11 @@ module Issues if added_labels.present? notification_service.relabeled_issue(issue, added_labels, current_user) end + + added_mentions = issue.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_issue(issue, added_mentions, current_user) + end end def reopen_service diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 5228da4d457..9a2bf82ef51 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,8 +2,9 @@ module Members class DestroyService < BaseService attr_accessor :member, :current_user - def initialize(member, user) - @member, @current_user = member, user + def initialize(member, current_user) + @member = member + @current_user = current_user end def execute diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index bc3606a14c2..ba424b09463 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -17,16 +17,19 @@ module MergeRequests end end - def hook_data(merge_request, action) + def hook_data(merge_request, action, oldrev = nil) hook_data = merge_request.to_hook_data(current_user) hook_data[:object_attributes][:url] = Gitlab::UrlBuilder.build(merge_request) hook_data[:object_attributes][:action] = action + if oldrev && !Gitlab::Git.blank_ref?(oldrev) + hook_data[:object_attributes][:oldrev] = oldrev + end hook_data end - def execute_hooks(merge_request, action = 'open') + def execute_hooks(merge_request, action = 'open', oldrev = nil) if merge_request.project - merge_data = hook_data(merge_request, action) + merge_data = hook_data(merge_request, action, oldrev) merge_request.project.execute_hooks(merge_data, :merge_request_hooks) merge_request.project.execute_services(merge_data, :merge_request_hooks) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 7fe57747265..290742f1506 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -34,7 +34,7 @@ module MergeRequests # At this point we decide if merge request can be created # If we have at least one commit to merge -> creation allowed if commits.present? - merge_request.compare_commits = Commit.decorate(commits, merge_request.source_project) + merge_request.compare_commits = commits merge_request.can_be_created = true merge_request.compare = compare else diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb new file mode 100644 index 00000000000..08c1f72d65a --- /dev/null +++ b/app/services/merge_requests/get_urls_service.rb @@ -0,0 +1,63 @@ +module MergeRequests + class GetUrlsService < BaseService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute(changes) + branches = get_branches(changes) + merge_requests_map = opened_merge_requests_from_source_branches(branches) + branches.map do |branch| + existing_merge_request = merge_requests_map[branch] + if existing_merge_request + url_for_existing_merge_request(existing_merge_request) + else + url_for_new_merge_request(branch) + end + end + end + + private + + def opened_merge_requests_from_source_branches(branches) + merge_requests = MergeRequest.from_project(project).opened.from_source_branches(branches) + merge_requests.inject({}) do |hash, mr| + hash[mr.source_branch] = mr + hash + end + end + + def get_branches(changes) + return [] if project.empty_repo? + return [] unless project.merge_requests_enabled + + changes_list = Gitlab::ChangesList.new(changes) + changes_list.map do |change| + next unless Gitlab::Git.branch_ref?(change[:ref]) + + # Deleted branch + next if Gitlab::Git.blank_ref?(change[:newrev]) + + # Default branch + branch_name = Gitlab::Git.branch_name(change[:ref]) + next if branch_name == project.default_branch + + branch_name + end.compact + end + + def url_for_new_merge_request(branch_name) + merge_request_params = { source_branch: branch_name } + url = Gitlab::Routing.url_helpers.new_namespace_project_merge_request_url(project.namespace, project, merge_request: merge_request_params) + { branch_name: branch_name, url: url, new_merge_request: true } + end + + def url_for_existing_merge_request(merge_request) + target_project = merge_request.target_project + url = Gitlab::Routing.url_helpers.namespace_project_merge_request_url(target_project.namespace, target_project, merge_request) + { branch_name: merge_request.source_branch, url: url, new_merge_request: false } + end + end +end diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb new file mode 100644 index 00000000000..2945a7fd4e4 --- /dev/null +++ b/app/services/merge_requests/merge_request_diff_cache_service.rb @@ -0,0 +1,8 @@ +module MergeRequests + class MergeRequestDiffCacheService + def execute(merge_request) + # Executing the iteration we cache all the highlighted diff information + merge_request.diffs.diff_files.to_a + end + end +end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 1daf6bbf553..5cedd6f11d9 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -137,7 +137,7 @@ module MergeRequests # Call merge request webhook with update branches def execute_mr_web_hooks merge_requests_for_source_branch.each do |merge_request| - execute_hooks(merge_request, 'update') + execute_hooks(merge_request, 'update', @oldrev) 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/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 026a37997d4..30c5f24988c 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -16,7 +16,7 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, old_labels: []) + def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -55,6 +55,15 @@ module MergeRequests current_user ) end + + added_mentions = merge_request.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_merge_request( + merge_request, + added_mentions, + current_user + ) + end end def reopen_service diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 534c48aefff..e4cd3fc7833 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,7 +16,7 @@ module Notes end def hook_data - Gitlab::NoteDataBuilder.build(@note, @note.author) + Gitlab::DataBuilder::Note.build(@note, @note.author) end def execute_note_hooks diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ab6e51209ee..2291bc0f127 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -35,6 +35,20 @@ class NotificationService new_resource_email(issue, issue.project, :new_issue_email) end + # When issue text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_issue(issue, new_mentioned_users, current_user) + new_mentions_in_resource_email( + issue, + issue.project, + new_mentioned_users, + current_user, + :new_mention_in_issue_email + ) + end + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled @@ -75,6 +89,20 @@ class NotificationService new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email) end + # When merge request text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user) + new_mentions_in_resource_email( + merge_request, + merge_request.target_project, + new_mentioned_users, + current_user, + :new_mention_in_merge_request_email + ) + end + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled @@ -177,7 +205,7 @@ class NotificationService # build notify method like 'note_commit_email' notify_method = "note_#{note.noteable_type.underscore}_email".to_sym - + recipients.each do |recipient| mailer.send(notify_method, recipient.id, note.id).deliver_later end @@ -471,6 +499,15 @@ class NotificationService end end + def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method) + recipients = build_recipients(target, project, current_user, action: "new") + recipients = recipients & new_mentioned_users + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, current_user.id).deliver_later + end + end + def close_resource_email(target, project, current_user, method) action = method == :merged_merge_request_email ? "merge" : "close" recipients = build_recipients(target, project, current_user, action: action) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 882606e38d0..8a53f65aec1 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -6,8 +6,12 @@ module Projects DELETED_FLAG = '+deleted' - def pending_delete! - project.schedule_delete!(current_user.id, params) + def async_execute + project.transaction do + project.update_attribute(:pending_delete, true) + job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) + Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}") + end end def execute diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb new file mode 100644 index 00000000000..3cf4264ce9b --- /dev/null +++ b/app/services/projects/enable_deploy_key_service.rb @@ -0,0 +1,17 @@ +module Projects + class EnableDeployKeyService < BaseService + def execute + key = accessible_keys.find_by(id: params[:key_id] || params[:id]) + return unless key + + project.deploy_keys << key + key + end + + private + + def accessible_keys + current_user.accessible_deploy_keys + end + end +end diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 6150a2a83c9..a84e335340d 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -5,23 +5,7 @@ module ProtectedBranches def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - protected_branch = project.protected_branches.new(params) - - ProtectedBranch.transaction do - protected_branch.save! - - if protected_branch.push_access_level.blank? - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - end - - if protected_branch.merge_access_level.blank? - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) - end - end - - protected_branch - rescue ActiveRecord::RecordInvalid - protected_branch + project.protected_branches.create(params) end end end diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb index 0b56b09738d..aa84d36a206 100644 --- a/app/services/repository_archive_clean_up_service.rb +++ b/app/services/repository_archive_clean_up_service.rb @@ -1,6 +1,8 @@ class RepositoryArchiveCleanUpService LAST_MODIFIED_TIME_IN_MINUTES = 120 + attr_reader :mmin, :path + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) @mmin = mmin @path = Gitlab.config.gitlab.repository_downloads_path @@ -17,8 +19,6 @@ class RepositoryArchiveCleanUpService private - attr_reader :mmin, :path - def clean_up_old_archives run(%W(find #{path} -not -path #{path} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -maxdepth 2 -mmin +#{mmin} -delete)) end diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb deleted file mode 100644 index 7c3e692bde9..00000000000 --- a/app/services/spam_check_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -class SpamCheckService < BaseService - include Gitlab::AkismetHelper - - attr_accessor :request, :api - - def execute(request, api) - @request, @api = request, api - return false unless request || check_for_spam?(project) - return false unless is_spam?(request.env, current_user, text) - - create_spam_log - - true - end - - private - - def text - [params[:title], params[:description]].reject(&:blank?).join("\n") - end - - def spam_log_attrs - { - user_id: current_user.id, - project_id: project.id, - title: params[:title], - description: params[:description], - source_ip: client_ip(request.env), - user_agent: user_agent(request.env), - noteable_type: 'Issue', - via_api: api - } - end - - def create_spam_log - CreateSpamLogService.new(project, current_user, spam_log_attrs).execute - end -end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb new file mode 100644 index 00000000000..48903291799 --- /dev/null +++ b/app/services/spam_service.rb @@ -0,0 +1,78 @@ +class SpamService + attr_accessor :spammable, :request, :options + + def initialize(spammable, request = nil) + @spammable = spammable + @request = request + @options = {} + + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] + else + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + end + + def check(api = false) + return false unless request && check_for_spam? + + return false unless akismet.is_spam? + + create_spam_log(api) + true + end + + def mark_as_spam! + return false unless spammable.submittable_as_spam? + + if akismet.submit_spam + spammable.user_agent_detail.update_attribute(:submitted, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spammable_owner, + spammable.spammable_text, + options + ) + end + + def spammable_owner + @user ||= User.find(spammable_owner_id) + end + + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def check_for_spam? + spammable.check_for_spam? + end + + def create_spam_log(api) + SpamLog.create( + { + user_id: spammable_owner_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: options[:ip_address], + user_agent: options[:user_agent], + noteable_type: spammable.class.to_s, + via_api: api + } + ) + end +end diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb index e85e58751e7..280c81f7d2d 100644 --- a/app/services/test_hook_service.rb +++ b/app/services/test_hook_service.rb @@ -1,6 +1,6 @@ class TestHookService def execute(hook, current_user) - data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user) + data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user) hook.execute(data, 'push_hooks') end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 6b48d68cccb..eb833dd82ac 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -144,8 +144,9 @@ class TodoService def mark_todos_as_done(todos, current_user) todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all) - todos.update_all(state: :done) + marked_todos = todos.update_all(state: :done) current_user.update_todos_count_cache + marked_todos end # When user marks an issue as todo diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb new file mode 100644 index 00000000000..a1ee3df5fe1 --- /dev/null +++ b/app/services/user_agent_detail_service.rb @@ -0,0 +1,13 @@ +class UserAgentDetailService + attr_accessor :spammable, :request + + def initialize(spammable, request) + @spammable, @request = spammable, request + end + + def create + return unless request + + spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 23b52d08df7..c7fd344eea2 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -228,6 +228,9 @@ = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2' .col-sm-10 = f.number_field :max_artifacts_size, class: 'form-control' + .help-block + Set the maximum file size each build's artifacts can have + = link_to "(?)", help_page_path("user/admin_area/settings/continuous_integration", anchor: "maximum-artifacts-size") - if Gitlab.config.registry.enabled %fieldset @@ -363,7 +366,9 @@ .col-sm-10 = f.select :repository_storage, repository_storage_options_for_select, {}, class: 'form-control' .help-block - You can manage the repository storage paths in your gitlab.yml configuration file + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storages") %fieldset %legend Repository Checks @@ -385,4 +390,4 @@ .form-actions - = f.submit 'Save', class: 'btn btn-save'
\ No newline at end of file + = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index ce818c30c30..352adbedee4 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -11,16 +11,18 @@ - else %span.build-link ##{build.id} - - if build.stuck? - %i.fa.fa-warning.text-warning - - if build.ref + .icon-container + = build.tag? ? icon('tag') : icon('code-fork') = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" - else .light none - = custom_icon("icon_commit") + .icon-container + = custom_icon("icon_commit") = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace commit-id" + - if build.stuck? + %i.fa.fa-warning.text-warning .label-container - if build.tags.any? diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 452fc25ab07..e6687f43816 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -112,7 +112,7 @@ %h4 Projects .data = link_to admin_namespaces_projects_path do - %h1= number_with_delimiter(Project.count) + %h1= number_with_delimiter(Project.cached_count) %hr = link_to('New Project', new_project_path, class: "btn btn-new") .col-sm-4 diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 448aa953548..602cfa9b6fc 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -28,6 +28,3 @@ .form-actions = f.submit 'Save', class: 'btn btn-save js-save-button' = link_to "Cancel", admin_labels_path, class: 'btn btn-cancel' - -:javascript - new Labels(); diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 8aea67f4497..4ce4eab8753 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -24,6 +24,11 @@ = link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true), data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove" %td + - if spam_log.submitted_as_ham? + .btn.btn-xs.disabled + Submitted as ham + - else + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning' - if user && !user.blocked? = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" - else diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index d37489bebea..76c9ed0ee8b 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -140,12 +140,10 @@ .panel-heading This user is blocked .panel-body - %p Blocking user has the following effects: + %p A blocked user cannot: %ul - %li User will not be able to login - %li User will not be able to access git repositories - %li Personal projects will be left - %li Owned groups will be left + %li Log in + %li Access Git repositories %br = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } - else diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 98f302d2f93..b40395c74de 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,6 +1,7 @@ %li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} } + = author_avatar(todo, size: 40) + .todo-item.todo-block - = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' .todo-title.title - unless todo.build_failed? = todo_target_state_pill(todo) @@ -19,13 +20,13 @@ · #{time_ago_with_tooltip(todo.created_at)} - - if todo.pending? - .todo-actions.pull-right - = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do - Done - = icon('spinner spin') - .todo-body .todo-note .md = event_note(todo.body, project: todo.project) + + - if todo.pending? + .todo-actions + = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do + Done + = icon('spinner spin') diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index 8e81671b7e7..b7d3acac2b1 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -1,4 +1,4 @@ -= form_tag(user_omniauth_authorize_path("crowd"), id: 'new_crowd_user' ) do += form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user' ) do = text_field_tag :username, nil, {class: "form-control top", placeholder: "Username", autofocus: "autofocus"} = password_field_tag :password, nil, {class: "form-control bottom", placeholder: "Password"} - if devise_mapping.rememberable? diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index de18bc2d844..2e7da2747d0 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,4 +5,4 @@ - providers.each do |provider| %span.light - has_icon = provider_has_icon?(provider) - = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" + = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 431d312b4ca..85e188d6f8b 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -189,7 +189,7 @@ %li %a Sort by date - = link_to 'New issue', '#', class: 'btn btn-new' + = link_to 'New issue', '#', class: 'btn btn-new btn-inverted' .lead Only nav links without button and search diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index deaaf9af875..54ff1d27c67 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -4,10 +4,6 @@ %i.fa.fa-github Import projects from GitHub -%p - %i.fa.fa-warning - To import GitHub pull requests, any pull request source branches that had been deleted are temporarily restored on GitHub. To prevent any connected CI services from being overloaded with dozens of irrelevant branches being created and deleted again, GitHub webhooks are temporarily disabled during the import process, but only if you have admin access to the GitHub repository. - %p.light Select projects you want to import. %hr 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/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 9e65d94186b..1d3b8fc3683 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -66,7 +66,7 @@ - if project_nav_tab? :issues = nav_link(controller: [:issues, :labels, :milestones]) do - = link_to url_for_project_issues(@project, only_path: true), title: 'Issues', class: 'shortcuts-issues' do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do %span Issues - if @project.default_issues_tracker? diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index ee9c0366f2b..9fe94291db7 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -6,13 +6,13 @@ - content_for :scripts_body_top do - project = @target_project || @project - if @project_wiki && @page - - markdown_preview_path = namespace_project_wiki_markdown_preview_path(project.namespace, project, @page.slug) + - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug) - else - - markdown_preview_path = markdown_preview_namespace_project_path(project.namespace, project) + - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - window.markdown_preview_path = "#{markdown_preview_path}"; + window.preview_markdown_path = "#{preview_markdown_path}"; - content_for :scripts_body do = render "layouts/init_auto_complete" if current_user diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index fc64c98038b..ca5c2f2688c 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -3,3 +3,5 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> Assignee: <%= @issue.assignee_name %> + +<%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml new file mode 100644 index 00000000000..4f3d36bd9ca --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -0,0 +1,12 @@ +%p + You have been mentioned in an issue. + +- if current_application_settings.email_author_in_body + %div + #{link_to @issue.author_name, user_url(@issue.author)} wrote: +-if @issue.description + = markdown(@issue.description, pipeline: :email, author: @issue.author) + +- if @issue.assignee_id.present? + %p + Assignee: #{@issue.assignee_name} diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb new file mode 100644 index 00000000000..457e94b4800 --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -0,0 +1,7 @@ +You have been mentioned in an issue. + +Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> +Author: <%= @issue.author_name %> +Assignee: <%= @issue.assignee_name %> + +<%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml new file mode 100644 index 00000000000..32aedb9e6b9 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -0,0 +1,15 @@ +%p + You have been mentioned in Merge Request #{@merge_request.to_reference} + +- if current_application_settings.email_author_in_body + %div + #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: +%p.details + != merge_path_description(@merge_request, '→') + +- if @merge_request.assignee_id.present? + %p + Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + +-if @merge_request.description + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb new file mode 100644 index 00000000000..5bf0282e097 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -0,0 +1,9 @@ +You have been mentioned in Merge Request <%= @merge_request.to_reference %> + +<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %> + +<%= merge_path_description(@merge_request, 'to') %> +Author: <%= @merge_request.author_name %> +Assignee: <%= @merge_request.assignee_name %> + +<%= @merge_request.description %> diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index d4aad8d1862..3c8f178ac77 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -6,3 +6,5 @@ New Merge Request <%= @merge_request.to_reference %> Author: <%= @merge_request.author_name %> Assignee: <%= @merge_request.assignee_name %> +<%= @merge_request.description %> + diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 57d16d29158..c80f22457b4 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -70,7 +70,7 @@ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do Disconnect - else - = link_to user_omniauth_authorize_path(provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do Connect %hr - if current_user.can_change_username? diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 51f74f3b7ce..8ef31ca3bda 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -24,6 +24,3 @@ .project-clone-holder = render "shared/clone_panel" - -:javascript - new Star(); diff --git a/app/views/projects/badges/badge.svg.erb b/app/views/projects/badges/badge.svg.erb new file mode 100644 index 00000000000..a5fef4fc56f --- /dev/null +++ b/app/views/projects/badges/badge.svg.erb @@ -0,0 +1,36 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="<%= badge.width %>" height="20"> + <linearGradient id="b" x2="0" y2="100%"> + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> + <stop offset="1" stop-opacity=".1"/> + </linearGradient> + + <mask id="a"> + <rect width="<%= badge.width %>" height="20" rx="3" fill="#fff"/> + </mask> + + <g mask="url(#a)"> + <path fill="<%= badge.key_color %>" + d="M0 0 h<%= badge.key_width %> v20 H0 z"/> + <path fill="<%= badge.value_color %>" + d="M<%= badge.key_width %> 0 h<%= badge.value_width %> v20 H<%= badge.key_width %> z"/> + <path fill="url(#b)" + d="M0 0 h<%= badge.width %> v20 H0 z"/> + </g> + + <g fill="#fff" text-anchor="middle"> + <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="<%= badge.key_text_anchor %>" y="15" fill="#010101" fill-opacity=".3"> + <%= badge.key_text %> + </text> + <text x="<%= badge.key_text_anchor %>" y="14"> + <%= badge.key_text %> + </text> + <text x="<%= badge.value_text_anchor %>" y="15" fill="#010101" fill-opacity=".3"> + <%= badge.value_text %> + </text> + <text x="<%= badge.value_text_anchor %>" y="14"> + <%= badge.value_text %> + </text> + </g> + </g> +</svg> diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index ff379bafb26..0237e152b54 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -24,7 +24,7 @@ .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' - .file-content.code + .file-editor.code %pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]} - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index 18caddabd39..4c356d1f07f 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -1,9 +1,15 @@ .file-content.image_file - if blob.svg? - - # We need to scrub SVG but we cannot do so in the RawController: it would - - # be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - if blob.size_within_svg_limits? + - # We need to scrub SVG but we cannot do so in the RawController: it would + - # be wrong/strange if RawController modified the data. + - blob.load_all_data!(@repository) + - blob = sanitize_svg(blob) + %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - else + .nothing-here-block + The SVG could not be displayed as it is too large, you can + #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')} + instead. - else %img{src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path))} diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml index 5926d181ba3..a79ae53c780 100644 --- a/app/views/projects/blob/diff.html.haml +++ b/app/views/projects/blob/diff.html.haml @@ -1,20 +1,30 @@ - if @lines.present? + - line_class = diff_view == :inline ? '' : diff_view - if @form.unfold? && @form.since != 1 && !@form.bottom? - %tr.line_holder - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.since, line_new: @form.since, bottom: false, new_file: false } + %tr.line_holder{ class: line_class } + = diff_match_line @form.since, @form.since, text: @match_line, view: diff_view - @lines.each_with_index do |line, index| - line_new = index + @form.since - line_old = line_new - @form.offset - %tr.line_holder{ id: line_old } - %td.old_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_old), "##{line_old}" - %td.new_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_new) , "##{line_old}" - %td.line_content.noteable_line==#{' ' * @form.indent}#{line} + - line_content = capture do + %td.line_content.noteable_line{ class: line_class }==#{' ' * @form.indent}#{line} + %tr.line_holder{ id: line_old, class: line_class } + - case diff_view + - when :inline + %td.old_line.diff-line-num{ data: { linenumber: line_old } } + %a{href: "##{line_old}", data: { linenumber: line_old }} + %td.new_line.diff-line-num{ data: { linenumber: line_new } } + %a{href: "##{line_new}", data: { linenumber: line_new }} + = line_content + - when :parallel + %td.old_line.diff-line-num{data: { linenumber: line_old }} + = link_to raw(line_old), "##{line_old}" + = line_content + %td.new_line.diff-line-num{data: { linenumber: line_new }} + = link_to raw(line_new), "##{line_new}" + = line_content - if @form.unfold? && @form.bottom? && @form.to < @blob.loc - %tr.line_holder{ id: @form.to } - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.to, line_new: @form.to, bottom: true, new_file: false } + %tr.line_holder{ id: @form.to, class: line_class } + = diff_match_line @form.to, @form.to, text: @match_line, view: diff_view, bottom: true diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index b1c9895f43e..7b0621f9401 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,5 +1,11 @@ - page_title "Edit", @blob.path, @ref +- if @conflict + .alert.alert-danger + Someone edited the file the same time you did. Please check out + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank" + and make sure your changes will not unintentionally remove theirs. + .file-editor %ul.nav-links.no-bottom.js-edit-mode %li.active @@ -13,8 +19,7 @@ = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form') do = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data = render 'shared/new_commit_form', placeholder: "Update #{@blob.name}" - - = hidden_field_tag 'last_commit', @last_commit + = hidden_field_tag 'last_commit_sha', @last_commit_sha = hidden_field_tag 'content', '', id: "file-content" = hidden_field_tag 'from_merge_request_id', params[:from_merge_request_id] = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id) 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/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index a8bc53c2849..5b0b58e087b 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -11,98 +11,133 @@ %p.build-detail-row #{@build.coverage}% - - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) - .block{ class: ("block-first" if !@build.coverage) } - .title - Build artifacts - - if @build.artifacts_expired? - %p.build-detail-row - The artifacts were removed - #{time_ago_with_tooltip(@build.artifacts_expire_at)} - - elsif @build.artifacts_expire_at - %p.build-detail-row - The artifacts will be removed in - %span.js-artifacts-remove= @build.artifacts_expire_at + - builds = @build.pipeline.builds.latest.to_a + - statuses = ["failed", "pending", "running", "canceled", "success", "skipped"] + - if builds.size > 1 + .dropdown.build-dropdown + .build-light-text Stage + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} + %span.stage-selection More + = icon('caret-down') + %ul.dropdown-menu + - builds.map(&:stage).uniq.each do |stage| + %li + %a.stage-item= stage - - if @build.artifacts? - .btn-group.btn-group-justified{ role: :group } - - if @build.artifacts_expire_at - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do - Keep + .builds-container + - statuses.each do |build_status| + - builds.select{|build| build.status == build_status}.each do |build| + .build-job{class: ('active' if build == @build), data: {stage: build.stage}} + = link_to namespace_project_build_path(@project.namespace, @project, build) do + = icon('check') + = ci_icon_for_status(build.status) + %span + - if build.name + = build.name + - else + = build.id - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Download + - if @build.retried? + %li.active + %a + Build ##{@build.id} + · + %i.fa.fa-warning + This build was retried. - - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Browse + .blocks-container + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) + .block{ class: ("block-first" if !@build.coverage) } + .title + Build artifacts + - if @build.artifacts_expired? + %p.build-detail-row + The artifacts were removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + %p.build-detail-row + The artifacts will be removed in + %span.js-artifacts-remove= @build.artifacts_expire_at - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } - .title - Build details - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post - - if @build.merge_request - %p.build-detail-row - %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) - - if @build.duration - %p.build-detail-row - %span.build-light-text Duration: - = time_interval_in_words(@build.duration) - - if @build.finished_at - %p.build-detail-row - %span.build-light-text Finished: - #{time_ago_with_tooltip(@build.finished_at)} - - if @build.erased_at - %p.build-detail-row - %span.build-light-text Erased: - #{time_ago_with_tooltip(@build.erased_at)} - %p.build-detail-row - %span.build-light-text Runner: - - if @build.runner && current_user && current_user.admin - = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) - - elsif @build.runner - \##{@build.runner.id} - .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace? - = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - - if @build.active? - = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), - class: "btn btn-sm btn-default", method: :post, - data: { confirm: "Are you sure you want to erase this build?" } do - Erase + - if @build.artifacts? + .btn-group.btn-group-justified{ role: :group } + - if @build.artifacts_expire_at + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + Keep + + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Download - - if @build.trigger_request - .build-widget - %h4.title - Trigger + - if @build.artifacts_metadata? + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Browse - %p - %span.build-light-text Token: - #{@build.trigger_request.trigger.short_token} + .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } + .title + Build details + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post + - if @build.merge_request + %p.build-detail-row + %span.build-light-text Merge Request: + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + - if @build.duration + %p.build-detail-row + %span.build-light-text Duration: + = time_interval_in_words(@build.duration) + - if @build.finished_at + %p.build-detail-row + %span.build-light-text Finished: + #{time_ago_with_tooltip(@build.finished_at)} + - if @build.erased_at + %p.build-detail-row + %span.build-light-text Erased: + #{time_ago_with_tooltip(@build.erased_at)} + %p.build-detail-row + %span.build-light-text Runner: + - if @build.runner && current_user && current_user.admin + = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) + - elsif @build.runner + \##{@build.runner.id} + .btn-group.btn-group-justified{ role: :group } + - if @build.has_trace? + = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), + class: "btn btn-sm btn-default", method: :post, + data: { confirm: "Are you sure you want to erase this build?" } do + Erase + + - if @build.trigger_request + .build-widget + %h4.title + Trigger - - if @build.trigger_request.variables %p - %span.build-light-text Variables: + %span.build-light-text Token: + #{@build.trigger_request.trigger.short_token} + - if @build.trigger_request.variables + %p + %span.build-light-text Variables: - - @build.trigger_request.variables.each do |key, value| - %code - #{key}=#{value} - .block - .title - Commit title - %p.build-light-text.append-bottom-0 - #{@build.pipeline.git_commit_title} + - @build.trigger_request.variables.each do |key, value| + %code + #{key}=#{value} - - if @build.tags.any? .block .title - Tags - - @build.tag_list.each do |tag| - %span.label.label-primary - = tag + Commit title + %p.build-light-text.append-bottom-0 + #{@build.pipeline.git_commit_title} + + - if @build.tags.any? + .block + .title + Tags + - @build.tag_list.each do |tag| + %span.label.label-primary + = tag diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index 4421f3b9562..e4d41288aa6 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -5,26 +5,6 @@ .build-page = render "header" - - builds = @build.pipeline.builds.latest.to_a - - if builds.size > 1 - %ul.nav-links.no-top.no-bottom - - builds.each do |build| - %li{class: ('active' if build == @build) } - = link_to namespace_project_build_path(@project.namespace, @project, build) do - = ci_icon_for_status(build.status) - %span - - if build.name - = build.name - - else - = build.id - - - if @build.retried? - %li.active - %a - Build ##{@build.id} - · - %i.fa.fa-warning - This build was retried. - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning @@ -67,4 +47,10 @@ = render "sidebar" :javascript - new Build("#{namespace_project_build_url(@project.namespace, @project, @build)}", "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", "#{@build.status}", "#{trace_with_state[:state]}") + new Build({ + page_url: "#{namespace_project_build_url(@project.namespace, @project, @build)}", + build_url: "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", + build_status: "#{@build.status}", + build_stage: "#{@build.stage}", + state1: "#{trace_with_state[:state]}" + }) diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 16b8e1cca91..ca907077c2b 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -9,7 +9,7 @@ - if can_create_issue %li - = link_to url_for_new_issue(@project, only_path: true) do + = link_to new_namespace_project_issue_path(@project.namespace, @project) do = icon('exclamation-circle fw') New issue diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 71cf5582a4c..311583037e5 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,10 +1,10 @@ - if current_user = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) - = icon('star fw') + = icon('star') %span.starred Unstar - else - = icon('star-o fw') + = icon('star-o') %span Star %div.count-with-arrow %span.arrow @@ -13,7 +13,7 @@ - else = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do - = icon('star fw') + = icon('star') Star %div.count-with-arrow %span.arrow diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index a3114771a42..91081435220 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -13,13 +13,6 @@ - else %span ##{build.id} - - if build.stuck? - .icon-container - = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') - - if defined?(retried) && retried - .icon-container - = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') - - if defined?(ref) && ref - if build.ref .icon-container @@ -33,6 +26,11 @@ - if defined?(commit_sha) && commit_sha = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" + - if build.stuck? + = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') + - if defined?(retried) && retried + = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') + .label-container - if build.tags.any? - build.tags.each do |tag| @@ -47,7 +45,6 @@ - if build.manual? %span.label.label-info manual - - if defined?(runner) && runner %td - if build.try(:runner) diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml new file mode 100644 index 00000000000..04cbd0c3591 --- /dev/null +++ b/app/views/projects/ci/builds/_build_pipeline.html.haml @@ -0,0 +1,14 @@ +- is_playable = subject.playable? && can?(current_user, :update_build, @project) +%li.build{class: ("playable" if is_playable)} + .build-content + - if is_playable + = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, title: 'Play' do + = render_status_with_link('build', 'play') + = subject.name + - elsif can?(current_user, :read_build, @project) + = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject) do + = render_status_with_link('build', subject.status) + = subject.name + - else + = render_status_with_link('build', subject.status) + = ci_icon_for_status(subject.status) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index 558c35553da..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 @@ -33,7 +35,7 @@ Cant find HEAD commit for this branch - - stages_status = pipeline.statuses.latest.stages_status + - stages_status = pipeline.statuses.relevant.latest.stages_status - stages.each do |stage| %td.stage-cell - status = stages_status[stage] @@ -53,7 +55,7 @@ - if pipeline.finished_at %p.finished-at = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at)} + #{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/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index ea33aa472a6..935433306ea 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -2,7 +2,7 @@ = nav_link(path: 'commit#show') do = link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do Changes - %span.badge= @diffs.count + %span.badge= @diffs.size = nav_link(path: 'commit#builds') do = link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id) do Builds diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index 540689f4a61..20a85148ab5 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -1,5 +1,9 @@ -.row-content-block.build-content.middle-block +.row-content-block.build-content.middle-block.pipeline-actions .pull-right + .btn.btn-grouped.btn-white.toggle-pipeline-btn + %span.toggle-btn-text Hide + %span pipeline graph + %span.caret - if can?(current_user, :update_pipeline, pipeline.project) - if pipeline.builds.latest.failed.any?(&:retryable?) = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post @@ -23,6 +27,22 @@ in = time_interval_in_words pipeline.duration +.row-content-block.build-content.middle-block.pipeline-graph + .pipeline-visualization + %ul.stage-column-list + - stages = pipeline.stages_with_latest_statuses + - stages.each do |stage, statuses| + %li.stage-column + .stage-name + %a{name: stage} + - if stage + = stage.titleize + .builds-container + %ul + - statuses.each do |status| + = render "projects/#{status.to_partial_path}_pipeline", subject: status + + - if pipeline.yaml_errors.present? .bs-callout.bs-callout-danger %h4 Found errors in your .gitlab-ci.yml: @@ -46,5 +66,5 @@ - if pipeline.project.build_coverage_enabled? %th Coverage %th - - pipeline.statuses.stages.each do |stage| - = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.where(stage: stage) + - pipeline.statuses.relevant.stages.each do |stage| + = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage) 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/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d0da2606587..ed44d86a687 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -7,7 +7,7 @@ = render "ci_menu" - else %div.block-connector -= render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @commit.diff_refs += render "projects/diffs/diffs", diffs: @diffs = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index af09b3418ea..d79336f5a60 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,7 +1,7 @@ = form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do .clearfix - if params[:to] && params[:from] - = link_to 'switch', {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} + = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} .form-group.dropdown.compare-form-group.js-compare-from-dropdown .input-group.inline-input-group %span.input-group-addon from diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 28a50e7031a..819e9bc15ae 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @diff_refs + = render "projects/diffs/diffs", diffs: @diffs - else .light-well .center diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index f70dba224fa..f7bf3b834ef 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -2,9 +2,9 @@ .pull-right - actions = deployment.manual_actions - if actions.present? - .btn-group.inline - .btn-group - %a.dropdown-toggle.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'} + .inline + .dropdown + %a.dropdown-new.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'} = icon("play") %b.caret %ul.dropdown-menu.dropdown-menu-align-right diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 0f9d9512d88..28813babd7b 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -1,12 +1,16 @@ %div.branch-commit - if deployment.ref - = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" - · + .icon-container + = deployment.tag? ? icon('tag') : icon('code-fork') + = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name" + .icon-container + = custom_icon("icon_commit") = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" %p.commit-title %span - if commit_title = deployment.commit_title + = author_avatar(deployment.commit, size: 20) = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" - else Cant find HEAD commit for this branch diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index baf02f1e6a0..cd95841ca5a 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -8,6 +8,7 @@ %td - if deployment.deployable = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable] do + = user_avatar(user: deployment.user, size: 20) = "#{deployment.deployable.name} (##{deployment.deployable.id})" %td diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index a1b071f130c..d37961c4e40 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -13,7 +13,7 @@ .nothing-here-block.diff-collapsed{data: { diff_for_path: url } } This diff is collapsed. Click to expand it. - elsif diff_file.diff_lines.length > 0 - - if diff_view == 'parallel' + - if diff_view == :parallel = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob - else = render "projects/diffs/text_file", diff_file: diff_file diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 4bf3ccace20..62aff36aadd 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -1,20 +1,19 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) -- if diff_view == 'parallel' +- diff_files = diffs.diff_files +- if diff_view == :parallel - fluid_layout true -- diff_files = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository) - .content-block.oneline-block.files-changed .inline-parallel-buttons - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? } = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default' - if show_whitespace_toggle - if current_controller?(:commit) - = commit_diff_whitespace_link(@project, @commit, class: 'hidden-xs') + = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs') - elsif current_controller?(:merge_requests) - = diff_merge_request_whitespace_link(@project, @merge_request, class: 'hidden-xs') + = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs') - elsif current_controller?(:compare) - = diff_compare_whitespace_link(@project, params[:from], params[:to], class: 'hidden-xs') + = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs') .btn-group = inline_diff_btn = parallel_diff_btn @@ -23,12 +22,12 @@ - if diff_files.overflow? = render 'projects/diffs/warning', diff_files: diff_files -.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, @project))}} +.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, diffs.project))}} - diff_files.each_with_index do |diff_file, index| - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - next unless blob - - blob.load_all_data!(project.repository) unless blob.only_display_raw? + - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw? - = render 'projects/diffs/file', i: index, project: project, - diff_file: diff_file, diff_commit: diff_commit, blob: blob, diff_refs: diff_refs + = render 'projects/diffs/file', index: index, project: diffs.project, + diff_file: diff_file, diff_commit: diff_commit, blob: blob diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 1854c64cbd7..8fbd89100ca 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,6 +1,6 @@ -.diff-file.file-holder{id: "diff-#{i}", data: diff_file_html_data(project, diff_file)} +.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file.file_path, diff_commit.id)} .file-title{id: "file-path-#{hexdigest(diff_file.file_path)}"} - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{i}" + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{index}" - unless diff_file.submodule? .file-actions.hidden-xs @@ -15,6 +15,6 @@ from_merge_request_id: @merge_request.id, skip_visible_check: true) - = view_file_btn(diff_commit.id, diff_file, project) + = view_file_btn(diff_commit.id, diff_file.new_path, project) - = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, diff_refs: diff_refs, blob: blob, project: project + = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 5a8a131d10c..2d6a370b848 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -1,12 +1,10 @@ - plain = local_assigns.fetch(:plain, false) -- line_code = diff_file.line_code(line) -- position = diff_file.position(line) - type = line.type -%tr.line_holder{ id: line_code, class: type } +- line_code = diff_file.line_code(line) unless plain +%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } } - case type - when 'match' - = render "projects/diffs/match_line", { line: line.text, - line_old: line.old_pos, line_new: line.new_pos, bottom: false, new_file: diff_file.new_file } + = diff_match_line line.old_pos, line.new_pos, text: line.text - when 'nonewline' %td.old_line.diff-line-num %td.new_line.diff-line-num @@ -24,4 +22,4 @@ = link_text - else %a{href: "##{line_code}", data: { linenumber: link_text }} - %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, position, type) unless plain) }= diff_line_content(line.text, type) + %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type) diff --git a/app/views/projects/diffs/_match_line.html.haml b/app/views/projects/diffs/_match_line.html.haml deleted file mode 100644 index d6dddd97879..00000000000 --- a/app/views/projects/diffs/_match_line.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%td.old_line.diff-line-num{data: {linenumber: line_old}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.new_line.diff-line-num{data: {linenumber: line_new}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.line_content.match= line diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 7f30faa20d8..28aad3f4725 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -1,14 +1,15 @@ / Side-by-side diff view %div.text-file.diff-wrap-lines.code.file-content.js-syntax-highlight{ data: diff_view_data } %table + - last_line = 0 - diff_file.parallel_diff_lines.each do |line| - left = line[:left] - right = line[:right] + - last_line = right.new_pos if right %tr.line_holder.parallel - if left - if left.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line left.old_pos, nil, text: left.text, view: :parallel - else - left_line_code = diff_file.line_code(left) - left_position = diff_file.position(left) @@ -21,8 +22,7 @@ - if right - if right.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line nil, right.new_pos, text: left.text, view: :parallel - else - right_line_code = diff_file.line_code(right) - right_position = diff_file.position(right) @@ -37,3 +37,5 @@ - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) - if discussion_left || discussion_right = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true, view: :parallel diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5970b9abf2b..ab5463ba89d 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -15,6 +15,5 @@ - if discussion = render "discussions/diff_discussion", discussion: discussion - - if last_line > 0 - = render "projects/diffs/match_line", { line: "", - line_old: last_line, line_new: last_line, bottom: true, new_file: diff_file.new_file } + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index e2453395602..36a6162a5a8 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -2,8 +2,12 @@ %tr.environment %td - %strong - = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) + + %td + - if last_deployment + = user_avatar(user: last_deployment.user, size: 20) + %strong ##{last_deployment.id} %td - if last_deployment diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml index c07f4bd510c..6d040f5cfe6 100644 --- a/app/views/projects/environments/_form.html.haml +++ b/app/views/projects/environments/_form.html.haml @@ -1,7 +1,22 @@ -= form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: 'col-lg-9' } do |f| - = form_errors(@environment) - .form-group - = f.label :name, 'Name', class: 'label-light' - = f.text_field :name, required: true, class: 'form-control' - = f.submit 'Create environment', class: 'btn btn-create' - = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Environments + %p + Environments allow you to track deployments of your application + = succeed "." do + = link_to "Read more about environments", help_page_path("ci/environments") + + = form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f| + = form_errors(@environment) + + .form-group + = f.label :name, 'Name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + .form-group + = f.label :external_url, 'External URL', class: 'label-light' + = f.url_field :external_url, class: 'form-control' + + .form-actions + = f.submit 'Save', class: 'btn btn-save' + = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml new file mode 100644 index 00000000000..6d1bdb9320f --- /dev/null +++ b/app/views/projects/environments/edit.html.haml @@ -0,0 +1,6 @@ +- page_title "Edit", @environment.name, "Environments" + +%h3.page-title + Edit environment +%hr += render 'form' diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index a6dd34653ab..b3eb5b0011a 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -23,10 +23,11 @@ New environment - else .table-holder - %table.table.environments + %table.table.builds.environments %tbody %th Environment - %th Last deployment - %th Date + %th Last Deployment + %th Commit + %th %th = render @environments diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 89e06567196..e51667ade2d 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -1,12 +1,6 @@ - page_title 'New Environment' -.row.prepend-top-default.append-bottom-default - .col-lg-3 - %h4.prepend-top-0 - New Environment - %p - Environments allow you to track deployments of your application - = succeed "." do - = link_to "Read more about environments", help_page_path("ci/environments") - - = render 'form' +%h3.page-title + New environment +%hr += render 'form' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index b8b1ce52a91..8f8c1c4ce22 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -6,10 +6,10 @@ .top-area .col-md-9 %h3.page-title= @environment.name.capitalize - .col-md-3 .nav-controls - if can?(current_user, :update_environment, @environment) + = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to delete this environment?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? @@ -23,13 +23,13 @@ = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success" - else .table-holder - %table.table.environments + %table.table.builds.environments %thead %tr %th ID %th Commit %th Build - %th Date + %th %th = render @deployments diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml new file mode 100644 index 00000000000..584c0fa18ae --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml @@ -0,0 +1,9 @@ +%li.build + .build-content + - if subject.target_url + - link_to subject.target_url do + = render_status_with_link('commit status', subject.status) + = subject.name + - else + = render_status_with_link('commit status', subject.status) + = subject.name diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index a985b442b2d..ac5f792d140 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -32,7 +32,7 @@ :javascript $.ajax({ type: "GET", - url: location.href, + url: "#{namespace_project_graph_path(@project.namespace, @project, current_ref, format: :json)}", dataType: "json", success: function (data) { var graph = new ContributorsStatGraph(); diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml index 8151187d499..3fcf1692e09 100644 --- a/app/views/projects/hooks/_project_hook.html.haml +++ b/app/views/projects/hooks/_project_hook.html.haml @@ -3,7 +3,7 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 403adb7426b..b6cb559afcb 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -2,10 +2,15 @@ %ul{ class: (container_class) } - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) = nav_link(controller: :issues) do - = link_to url_for_project_issues(@project, only_path: true), title: 'Issues' do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do %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/issues/index.html.haml b/app/views/projects/issues/index.html.haml index d0edd2f22ec..1a87045aa60 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -19,7 +19,13 @@ Subscribe = render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project) - if can? current_user, :create_issue, @project - = link_to new_namespace_project_issue_path(@project.namespace, @project, issue: { assignee_id: @issuable_finder.assignee.try(:id), milestone_id: @issuable_finder.milestones.try(:first).try(:id) }), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do + = link_to new_namespace_project_issue_path(@project.namespace, + @project, + issue: { assignee_id: issues_finder.assignee.try(:id), + milestone_id: issues_finder.milestones.first.try(:id) }), + class: "btn btn-new", + title: "New Issue", + id: "new_issue_link" do New Issue = render 'shared/issuable/filter', type: :issues diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9b6a97c0959..9f1a046ea74 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -37,14 +37,19 @@ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + - if @issue.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if can?(current_user, :create_issue, @project) - = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-success', title: 'New issue', id: 'new_issue_link' do + = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do - Edit + - if @issue.submittable_as_spam? && current_user.admin? + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index a5e67b95727..598bd743676 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -42,7 +42,7 @@ %h4 This comparison includes more than #{MergeRequestDiff::COMMITS_SAFE_SIZE} commits. %p To preserve performance the line changes are not shown. - else - = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @merge_request.diff_refs, show_whitespace_toggle: false + = render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false - if @pipeline #builds.builds.tab-pane = render "projects/merge_requests/show/builds" diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 873ed9b59ee..a1313064725 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -2,7 +2,7 @@ - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes -- if diff_view == 'parallel' +- if diff_view == :parallel - fluid_layout true .merge-request{'data-url' => merge_request_path(@merge_request)} @@ -45,20 +45,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 @@ -76,6 +80,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/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml index 1b0bae86ad4..013b05628fa 100644 --- a/app/views/projects/merge_requests/show/_diffs.html.haml +++ b/app/views/projects/merge_requests/show/_diffs.html.haml @@ -1,6 +1,5 @@ - if @merge_request_diff.collected? - = render "projects/diffs/diffs", diffs: @merge_request.diffs(diff_options), - project: @merge_request.project, diff_refs: @merge_request.diff_refs + = render "projects/diffs/diffs", diffs: @diffs - elsif @merge_request_diff.empty? .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} - else 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/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 6ef640bb654..494695a03a5 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -42,3 +42,16 @@ .ci_widget.ci-error{style: "display:none"} = icon("times-circle") Could not connect to the CI server. Please check your settings and try again. + +- @merge_request.environments.each do |environment| + .mr-widget-heading + .ci_widget.ci-success + = ci_icon_for_status("success") + %span.hidden-sm + Deployed to + = succeed '.' do + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: 'environment' + - external_url = environment.external_url + - if external_url + = link_to external_url, target: '_blank' do + = icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}", right: 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/projects/new.html.haml b/app/views/projects/new.html.haml index facdfcc9447..ea4898f2107 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -46,28 +46,18 @@ %div - if github_import_enabled? = link_to new_import_github_path, class: 'btn import_github' do - = icon 'github', text: 'GitHub' + = icon('github', text: 'GitHub') %div - if bitbucket_import_enabled? - - if bitbucket_import_configured? - = link_to status_import_bitbucket_path, class: 'btn import_bitbucket', "data-no-turbolink" => "true" do - %i.fa.fa-bitbucket - Bitbucket - - else - = link_to status_import_bitbucket_path, class: 'how_to_import_link btn import_bitbucket', "data-no-turbolink" => "true" do - %i.fa.fa-bitbucket - Bitbucket + = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", "data-no-turbolink" => "true" do + = icon('bitbucket', text: 'Bitbucket') + - unless bitbucket_import_configured? = render 'bitbucket_import_modal' %div - if gitlab_import_enabled? - - if gitlab_import_configured? - = link_to status_import_gitlab_path, class: 'btn import_gitlab' do - %i.fa.fa-heart - GitLab.com - - else - = link_to status_import_gitlab_path, class: 'how_to_import_link btn import_gitlab' do - %i.fa.fa-heart - GitLab.com + = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless bitbucket_import_configured?}" do + = icon('gitlab', text: 'GitLab.com') + - unless gitlab_import_configured? = render 'gitlab_import_modal' %div - if gitorious_import_enabled? @@ -77,23 +67,19 @@ %div - if google_code_import_enabled? = link_to new_import_google_code_path, class: 'btn import_google_code' do - %i.fa.fa-google - Google Code + = icon('google', text: 'Google Code') %div - if fogbugz_import_enabled? = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do - %i.fa.fa-bug - Fogbugz + = icon('bug', text: 'Fogbugz') %div - if git_import_enabled? = link_to "#", class: 'btn js-toggle-button import_git' do - %i.fa.fa-git - %span Repo by URL + = icon('git', text: 'Repo by URL') %div{ class: 'import_gitlab_project' } - - if gitlab_project_import_enabled? + - if gitlab_project_import_enabled? && current_user.is_admin? = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do - %i.fa.fa-gitlab - %span GitLab export + = icon('gitlab', text: 'GitLab export') .js-toggle-content.hide = render "shared/import_form", f: f @@ -159,4 +145,4 @@ $('.import_git').click(function( event ) { $projectImportUrl = $('#project_import_url') $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')) - });
\ No newline at end of file + }); diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 5f4ec2e40c8..55202725b9e 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :ref, 'Create for', class: 'control-label' .col-sm-10 - = f.text_field :ref, required: true, tabindex: 2, class: 'form-control' + = f.text_field :ref, required: true, tabindex: 2, class: 'form-control js-branch-name ui-autocomplete-input', autocomplete: :false, id: :ref .help-block Existing branch name, tag .form-actions = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml new file mode 100644 index 00000000000..7b7fa56d993 --- /dev/null +++ b/app/views/projects/pipelines_settings/_badge.html.haml @@ -0,0 +1,27 @@ +.row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 228bad36ebd..8c7222bfe3d 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,27 +77,4 @@ %hr .row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - Builds Badge - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b Builds badge · - = @build_badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', @build_badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', @build_badge.to_html) + = render partial: 'badge', collection: @badges diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml index 0603a014008..04b19a8c5a7 100644 --- a/app/views/projects/protected_branches/_branches_list.html.haml +++ b/app/views/projects/protected_branches/_branches_list.html.haml @@ -1,26 +1,28 @@ -%h5.prepend-top-0 - Already Protected (#{@protected_branches.size}) -- if @protected_branches.empty? - %p.settings-message.text-center - No branches are protected, protect a branch with the form above. -- else - - can_admin_project = can?(current_user, :admin_project, @project) +.panel.panel-default.protected-branches-list + - if @protected_branches.empty? + .panel-heading + %h3.panel-title + Protected branch (#{@protected_branches.size}) + %p.settings-message.text-center + There are currently no protected branches, protect a branch with the form above. + - else + - can_admin_project = can?(current_user, :admin_project, @project) - %table.table.protected-branches-list - %colgroup - %col{ width: "20%" } - %col{ width: "30%" } - %col{ width: "25%" } - %col{ width: "25%" } - %thead - %tr - %th Branch - %th Last commit - %th Allowed to merge - %th Allowed to push - - if can_admin_project - %th - %tbody - = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } + %table.table.table-bordered + %colgroup + %col{ width: "25%" } + %col{ width: "30%" } + %col{ width: "25%" } + %col{ width: "20%" } + %thead + %tr + %th Protected branch (#{@protected_branches.size}) + %th Last commit + %th Allowed to merge + %th Allowed to push + - if can_admin_project + %th + %tbody + = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } - = paginate @protected_branches, theme: 'gitlab' + = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml new file mode 100644 index 00000000000..d4c6fa24768 --- /dev/null +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -0,0 +1,37 @@ += form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| + .panel.panel-default + .panel-heading + %h3.panel-title + Protect a branch + .panel-body + .form-horizontal + = form_errors(@protected_branch) + .form-group + = f.label :name, class: 'col-md-2 text-right' do + Branch: + .col-md-10 + = render partial: "dropdown", locals: { f: f } + .help-block + = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches') + such as + %code *-stable + or + %code production/* + are supported + .form-group + %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' } + Allowed to merge: + .col-md-10 + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-merge wide', + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) + .form-group + %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } + Allowed to push: + .col-md-10 + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-push wide', + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) + + .panel-footer + = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index b803d932e67..a9e27df5a87 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,17 +1,15 @@ = f.hidden_field(:name) -= dropdown_tag("Protected Branch", - options: { title: "Pick protected branch", toggle_class: 'js-protected-branch-select js-filter-submit', += dropdown_tag('Select branch or create wildcard', + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], project_id: @project.try(:id) } }) do - %ul.dropdown-footer-list.hidden.protected-branch-select-footer-list + %ul.dropdown-footer-list %li = link_to '#', title: "New Protected Branch", class: "create-new-protected-branch" do - Create new - -:javascript - new ProtectedBranchSelect(); + Create wildcard + %code diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index 498e412235e..0628134b1bb 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,5 +1,4 @@ -- url = namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) -%tr +%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), branch_id: protected_branch.id } } %td = protected_branch.name - if @project.root_ref?(protected_branch.name) @@ -14,16 +13,9 @@ = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) - %td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level - = dropdown_tag(protected_branch.merge_access_level.humanize, - options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge', dropdown_class: 'dropdown-menu-selectable merge', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", url: url, id: protected_branch.id, type: "merge_access_level" }}) - %td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level - = dropdown_tag(protected_branch.push_access_level.humanize, - options: { title: "Allowed to push", toggle_class: 'allowed-to-push', dropdown_class: 'dropdown-menu-selectable push', - data: { field_name: "allowed_to_push_#{protected_branch.id}", url: url, id: protected_branch.id, type: "push_access_level" }}) + + = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch } + - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm pull-right" + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..d6044aacaec --- /dev/null +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1,10 @@ +%td + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level + = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) +%td + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level + = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/index.html.haml index 4efe44c7233..49dcc9a6ba4 100644 --- a/app/views/projects/protected_branches/index.html.haml +++ b/app/views/projects/protected_branches/index.html.haml @@ -14,41 +14,7 @@ %li prevent <strong>anyone</strong> from deleting the branch %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}. .col-lg-9 - %h5.prepend-top-0 - Protect a branch - if can? current_user, :admin_project, @project - = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| - = form_errors(@protected_branch) + = render 'create_protected_branch' - .form-group - = f.label :name, "Branch", class: "label-light" - = render partial: "dropdown", locals: { f: f } - %p.help-block - = link_to "Wildcards", help_page_path('user/project/protected_branches', anchor: "wildcard-protected-branches") - such as - %code *-stable - or - %code production/* - are supported. - - .form-group - = hidden_field_tag 'protected_branch[merge_access_level_attributes][access_level]' - = label_tag "Allowed to merge: ", nil, class: "label-light append-bottom-0" - = dropdown_tag("<Make a selection>", - options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge', - dropdown_class: 'dropdown-menu-selectable', - data: { field_name: "protected_branch[merge_access_level_attributes][access_level]" }}) - - .form-group - = hidden_field_tag 'protected_branch[push_access_level_attributes][access_level]' - = label_tag "Allowed to push: ", nil, class: "label-light append-bottom-0" - = dropdown_tag("<Make a selection>", - options: { title: "Allowed to push", toggle_class: 'allowed-to-push', - dropdown_class: 'dropdown-menu-selectable', - data: { field_name: "protected_branch[push_access_level_attributes][access_level]" }}) - - - = f.submit "Protect", class: "btn-create btn protect-branch-btn", disabled: true - - %hr = render "branches_list" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index dd1cf680cfa..a666d07e9eb 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -43,6 +43,10 @@ %li = link_to 'Contribution guide', contribution_guide_path(@project) + - if @repository.gitlab_ci_yml + %li + = link_to 'CI configuration', ci_configuration_path(@project) + - if current_user && can_push_branch?(@project, @project.default_branch) - unless @repository.changelog %li.missing diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml index a3a4bd4f752..84da16b6bb1 100644 --- a/app/views/projects/tree/_tree_commit_column.html.haml +++ b/app/views/projects/tree/_tree_commit_column.html.haml @@ -1,2 +1,2 @@ %span.str-truncated - = link_to_gfm commit.title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link" + = link_to_gfm commit.full_title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link" diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml new file mode 100644 index 00000000000..0a5c6f048f7 --- /dev/null +++ b/app/views/projects/tree/_tree_row.html.haml @@ -0,0 +1,6 @@ +- if tree_row.type == :tree + = render partial: 'projects/tree/tree_item', object: tree_row, as: 'tree_item', locals: { type: 'folder' } +- elsif tree_row.type == :blob + = render partial: 'projects/tree/blob_item', object: tree_row, as: 'blob_item', locals: { type: 'file' } +- elsif tree_row.type == :commit + = render partial: 'projects/tree/submodule_item', object: tree_row, as: 'submodule_item' diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 797a1a59e9f..643f7c589e6 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -18,9 +18,14 @@ .error-alert .help-block - To link to a (new) page, simply type - %code [Link Title](page-slug) - \. + = succeed '.' do + To link to a (new) page, simply type + %code [Link Title](page-slug) + + = succeed '.' do + More examples are in the + = link_to 'documentation', help_page_path("user/project/markdown", anchor: "wiki-specific-markdown") + .form-group = f.label :commit_message, class: 'control-label' .col-sm-10= f.text_field :message, class: 'form-control', rows: 18 diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index 8163aff43b6..e0400083870 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -1,6 +1,7 @@ - project = note.project - note_url = Gitlab::UrlBuilder.build(note) -- noteable_identifier = note.noteable.try(:iid) || note.noteable.id +- noteable_identifier = note.noteable.try(:iid) || note.noteable.try(:id) + .search-result-row %h5.note-search-caption.str-truncated %i.fa.fa-comment @@ -10,7 +11,10 @@ · - if note.for_commit? - = link_to "Commit #{truncate_sha(note.commit_id)}", note_url + = link_to_if(noteable_identifier, "Commit #{truncate_sha(note.commit_id)}", note_url) do + = truncate_sha(note.commit_id) + %span.light Commit deleted + - else %span #{note.noteable_type.titleize} ##{noteable_identifier} · diff --git a/app/views/shared/_labels_row.html.haml b/app/views/shared/_labels_row.html.haml index dce492352ac..e324d0e5203 100644 --- a/app/views/shared/_labels_row.html.haml +++ b/app/views/shared/_labels_row.html.haml @@ -1,9 +1,5 @@ - labels.each do |label| %span.label-row.btn-group{ role: "group", aria: { label: label.name }, style: "color: #{text_color_for_bg(label.color)}" } - = link_to label.name, label_filter_path(@project, label, type: controller.controller_name), - class: "btn btn-transparent has-tooltip", - style: "background-color: #{label.color};", - title: escape_once(label.description), - data: { container: "body" } + = link_to_label(label, css_class: 'btn btn-transparent') %button.btn.btn-transparent.label-remove.js-label-filter-remove{ type: "button", style: "background-color: #{label.color};", data: { label: label.title } } = icon("times") diff --git a/app/views/shared/icons/_icon_fork.svg b/app/views/shared/icons/_icon_fork.svg index a21f8f3a951..fc970e4ce50 100644 --- a/app/views/shared/icons/_icon_fork.svg +++ b/app/views/shared/icons/_icon_fork.svg @@ -1,3 +1,3 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> +<svg xmlns="http://www.w3.org/2000/svg" width="30" height="40" viewBox="5 0 30 40"> <path fill="#7E7E7E" fill-rule="evenodd" d="M22,29.5351288 L22,22.7193602 C26.1888699,21.5098039 29.3985457,16.802989 29.3985457,16.802989 C29.740988,16.3567547 30,15.5559546 30,15.0081969 L30,10.4648712 C31.1956027,9.77325238 32,8.48056471 32,7 C32,4.790861 30.209139,3 28,3 C25.790861,3 24,4.790861 24,7 C24,8.48056471 24.8043973,9.77325238 26,10.4648712 L26,14.7083871 C26,14.8784435 25.9055559,15.0987329 25.7890533,15.2104147 C25.7890533,15.2104147 24.5373893,16.4126202 23.9488702,16.9515733 C22.5015398,18.2770075 21.1191354,19 20.090554,19 C19.0477772,19 17.6172728,18.2608988 16.1128852,16.9142923 C15.5030182,16.3683886 14.3672121,15.3403307 14.3672121,15.3403307 C14.1659605,15.1583364 14.0000086,14.7846305 14.0000192,14.5088473 C14.0000192,14.5088473 14.0000932,12.7539451 14.0001308,10.4647956 C15.1956614,9.77315812 16,8.48051074 16,7 C16,4.790861 14.209139,3 12,3 C9.790861,3 8,4.790861 8,7 C8,8.48056471 8.80439726,9.77325238 10,10.4648712 L10,15.0081969 C10,15.5446944 10.2736352,16.3534183 10.6111812,16.7893819 C10.6111812,16.7893819 13.8599776,21.3779363 18,22.6668724 L18,29.5351288 C16.8043973,30.2267476 16,31.5194353 16,33 C16,35.209139 17.790861,37 20,37 C22.209139,37 24,35.209139 24,33 C24,31.5194353 23.1956027,30.2267476 22,29.5351288 Z M14,7 C14,5.8954305 13.1045695,5 12,5 C10.8954305,5 10,5.8954305 10,7 C10,8.1045695 10.8954305,9 12,9 C13.1045695,9 14,8.1045695 14,7 Z M30,7 C30,5.8954305 29.1045695,5 28,5 C26.8954305,5 26,5.8954305 26,7 C26,8.1045695 26.8954305,9 28,9 C29.1045695,9 30,8.1045695 30,7 Z M22,33 C22,31.8954305 21.1045695,31 20,31 C18.8954305,31 18,31.8954305 18,33 C18,34.1045695 18.8954305,35 20,35 C21.1045695,35 22,34.1045695 22,33 Z"/> </svg> diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 0b7fa8c7d06..4f8ea7e7cef 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' && 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 + - else + = render 'shared/sort_dropdown' - if controller.controller_name == 'issues' .issues_bulk_update.hide @@ -45,7 +55,7 @@ .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do %ul diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c30bdb0ae91..9e2e096d5f9 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -2,7 +2,22 @@ .form-group = f.label :title, class: 'control-label' - .col-sm-10 + + - issuable_template_names = issuable_templates(issuable) + + - if issuable_template_names.any? + .col-sm-3.col-lg-2 + .js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } } + - title = selected_template(issuable) || "Choose a template" + + = dropdown_tag(title, options: { toggle_class: 'js-issuable-selector', + title: title, filter: true, placeholder: 'Filter', footer_content: true, + data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do + %ul.dropdown-footer-list + %li + %a.reset-template + Reset template + %div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' } = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', class: 'form-control pad', required: true @@ -23,6 +38,13 @@ to prevent a %strong Work In Progress merge request from being merged before it's ready. + + - if can_add_template?(issuable) + %p.help-block + Add + = link_to "description templates", help_page_path('user/project/description_templates') + to help your contributors communicate effectively! + .form-group.detail-page-description = f.label :description, 'Description', class: 'control-label' .col-sm-10 diff --git a/app/views/shared/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/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 1c081bcdc17..2adbf9277fa 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,4 +1,4 @@ -- show_roles = local_assigns.fetch(:show_roles, default_show_roles(member)) +- show_roles = local_assigns.fetch(:show_roles, true) - show_controls = local_assigns.fetch(:show_controls, true) - user = member.user diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index b8b66d08db8..66c309644a7 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,19 +12,21 @@ %li.project-row{ class: css_class } = cache(cache_key) do .controls + - if project.archived + %span.label.label-warning archived - if project.commit.try(:status) %span = render_commit_status(project.commit) - if forks %span = icon('code-fork') - = project.forks_count + = number_with_delimiter(project.forks_count) - if stars %span = icon('star') - = project.star_count + = number_with_delimiter(project.star_count) %span.visibility-icon.has-tooltip{data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project)} - = visibility_level_icon(project.visibility_level, fw: false) + = visibility_level_icon(project.visibility_level, fw: true) .title = link_to project_path(project), class: dom_class(project) do diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 2585ed9360b..d2ec6c3ddef 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -19,7 +19,7 @@ = f.label :token, "Secret Token", class: 'label-light' = f.text_field :token, class: "form-control", placeholder: '' %p.help-block - Use this token to validate received payloads + Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. .form-group = f.label :url, "Trigger", class: 'label-light' %ul.list-unstyled @@ -29,49 +29,56 @@ = f.label :push_events, class: 'list-label' do %strong Push events %p.light - This url will be triggered by a push to the repository + This URL will be triggered by a push to the repository %li = f.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = f.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light - This url will be triggered when a new tag is pushed to the repository + This URL will be triggered when a new tag is pushed to the repository %li = f.check_box :note_events, class: 'pull-left' .prepend-left-20 = f.label :note_events, class: 'list-label' do %strong Comments %p.light - This url will be triggered when someone adds a comment + This URL will be triggered when someone adds a comment %li = f.check_box :issues_events, class: 'pull-left' .prepend-left-20 = f.label :issues_events, class: 'list-label' do %strong Issues events %p.light - This url will be triggered when an issue is created/updated/merged + This URL will be triggered when an issue is created/updated/merged %li = f.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = f.label :merge_requests_events, class: 'list-label' do %strong Merge Request events %p.light - This url will be triggered when a merge request is created/updated/merged + This URL will be triggered when a merge request is created/updated/merged %li = f.check_box :build_events, class: 'pull-left' .prepend-left-20 = f.label :build_events, class: 'list-label' do %strong Build events %p.light - This url will be triggered when the build status changes + This URL will be triggered when the build status changes + %li + = f.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = f.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This URL will be triggered when the pipeline status changes %li = f.check_box :wiki_page_events, class: 'pull-left' .prepend-left-20 = f.label :wiki_page_events, class: 'list-label' do %strong Wiki Page events %p.light - This url will be triggered when a wiki page is created/updated + This URL will be triggered when a wiki page is created/updated .form-group = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' .checkbox diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 0b6a01a3200..c6a5af2809a 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -33,25 +33,14 @@ class EmailsOnPushWorker reverse_compare = false if action == :push - merge_base_sha = project.merge_base_commit(before_sha, after_sha).try(:sha) - compare = Gitlab::Git::Compare.new(project.repository.raw_repository, before_sha, after_sha) - - diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: merge_base_sha, - start_sha: before_sha, - head_sha: after_sha - ) + compare = CompareService.new.execute(project, before_sha, project, after_sha) + diff_refs = compare.diff_refs return false if compare.same if compare.commits.empty? - compare = Gitlab::Git::Compare.new(project.repository.raw_repository, after_sha, before_sha) - - diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: merge_base_sha, - start_sha: after_sha, - head_sha: before_sha - ) + compare = CompareService.new.execute(project, after_sha, project, before_sha) + diff_refs = compare.diff_refs reverse_compare = true diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb new file mode 100644 index 00000000000..5048746f09b --- /dev/null +++ b/app/workers/group_destroy_worker.rb @@ -0,0 +1,17 @@ +class GroupDestroyWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform(group_id, user_id) + begin + group = Group.with_deleted.find(group_id) + rescue ActiveRecord::RecordNotFound + return + end + + user = User.find(user_id) + + DestroyGroupService.new(group, user).execute + end +end diff --git a/app/workers/gitlab_remove_project_export_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index 1d91897d520..72e3a9ae734 100644 --- a/app/workers/gitlab_remove_project_export_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -1,9 +1,9 @@ -class GitlabRemoveProjectExportWorker +class ImportExportProjectCleanupWorker include Sidekiq::Worker sidekiq_options queue: :default def perform - Project.remove_gitlab_exports! + ImportExportCleanUpService.new.execute end end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index 605ec4f04e5..19f38358eb5 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -141,8 +141,10 @@ class IrkerWorker end def files_count(commit) - files = "#{commit.diffs.real_size} file" - files += 's' if commit.diffs.count > 1 + diffs = commit.raw_diffs(deltas_only: true) + + files = "#{diffs.real_size} file" + files += 's' if diffs.size > 1 files end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 09035a7cf2d..a9a2b716005 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -10,6 +10,10 @@ class PostReceive log("Check gitlab.yml config for correct repositories.storages values. No repository storage path matches \"#{repo_path}\"") end + changes = Base64.decode64(changes) unless changes.include?(' ') + # Use Sidekiq.logger so arguments can be correlated with execution + # time and thread ID's. + Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] post_received = Gitlab::GitPostReceive.new(repo_path, identifier, changes) if post_received.project.nil? diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index b51c6a266c9..3062301a9b1 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -12,6 +12,6 @@ class ProjectDestroyWorker user = User.find(user_id) - ::Projects::DestroyService.new(project, user, params).execute + ::Projects::DestroyService.new(project, user, params.symbolize_keys).execute end end 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 |