diff options
410 files changed, 10640 insertions, 2429 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 588f255eff8..88d536fa9b3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -141,6 +141,7 @@ stages: # Trigger a package build on omnibus-gitlab repository build-package: + before_script: [] services: [] variables: SETUP_DB: "false" @@ -148,17 +149,7 @@ build-package: stage: build when: manual script: - # If no branch in omnibus is specified, trigger pipeline against master - - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi - - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details - - echo "ref=${OMNIBUS_BRANCH}" >> version_details - - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details - - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details - # Collect version details of all components - - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done - # Trigger the API and pass values collected above as parameters to it - - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- - - rm version_details + - scripts/trigger-build # Prepare and merge knapsack tests knapsack: diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 239eeacf2d7..0d23bdeeb99 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -35,7 +35,10 @@ gl.issueBoards.Board = Vue.extend({ filter: { handler() { this.list.page = 1; - this.list.getIssues(true); + this.list.getIssues(true) + .catch(() => { + // TODO: handle request error + }); }, deep: true, }, diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js index 3fc68457961..870e115bd1a 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.js @@ -70,7 +70,10 @@ export default { list.id = listObj.id; list.label.id = listObj.label.id; - list.getIssues(); + list.getIssues() + .catch(() => { + // TODO: handle request error + }); }); }) .catch(() => { diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index b13386536bf..49a775002c3 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -90,7 +90,10 @@ export default { if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { this.list.page += 1; - this.list.getIssues(false); + this.list.getIssues(false) + .catch(() => { + // TODO: handle request error + }); } if (this.scrollHeight() > Math.ceil(this.listHeight())) { diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index fdab317dc23..a61cc7954a1 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -108,6 +108,8 @@ gl.issueBoards.IssuesModal = Vue.extend({ if (!this.issuesCount) { this.issuesCount = data.size; } + }).catch(() => { + // TODO: handle request error }); }, }, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index bd2f62bcc1a..90561d0f7a8 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -25,7 +25,9 @@ class List { } if (this.type !== 'blank' && this.id) { - this.getIssues(); + this.getIssues().catch(() => { + // TODO: handle request error + }); } } @@ -52,11 +54,17 @@ class List { gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); - gl.boardService.destroyList(this.id); + gl.boardService.destroyList(this.id) + .catch(() => { + // TODO: handle request error + }); } update () { - gl.boardService.updateList(this.id, this.position); + gl.boardService.updateList(this.id, this.position) + .catch(() => { + // TODO: handle request error + }); } nextPage () { @@ -146,11 +154,17 @@ class List { this.issues.splice(oldIndex, 1); this.issues.splice(newIndex, 0, issue); - gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid); + gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid) + .catch(() => { + // TODO: handle request error + }); } updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) { - gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid); + gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid) + .catch(() => { + // TODO: handle request error + }); } findIssue (id) { diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 92f6fd654b3..9d51fb53eb2 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); } else { new Flash(errorFlashMsg); } diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 4ea6ba8a73d..ba4f6d36fcb 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -49,6 +49,7 @@ class ResolveServiceClass { discussion.resolveAllNotes(resolved_by); } + gl.mrWidget.checkStatus(); discussion.updateHeadline(data); } else { throw new Error('An error occurred when trying to resolve discussion.'); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index bf802056d36..abb871c3af0 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -10,7 +10,6 @@ /* global IssuableForm */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global MergedButtons */ /* global Commit */ /* global NotificationsForm */ /* global TreeView */ @@ -216,15 +215,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new gl.Diff(); shortcut_handler = new ShortcutsIssuable(true); new ZenMode(); - new MergedButtons(); - break; - case 'projects:merge_requests:commits': - new MergedButtons(); break; case "projects:merge_requests:diffs": new gl.Diff(); new ZenMode(); - new MergedButtons(); break; case 'dashboard:activity': new gl.Activities(); diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index e0088d496eb..ed8df0f3a54 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,17 +1,17 @@ <script> /* global Flash */ import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from './environments_table.vue'; +import environmentTable from './environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import eventHub from '../event_hub'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, }, data() { diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index f4a0c390c91..1fc0ce818e9 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,16 +1,16 @@ <script> /* global Flash */ import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from '../components/environments_table.vue'; +import environmentTable from '../components/environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, }, data() { diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 59d6508fc02..534e651b030 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -3,7 +3,6 @@ /* global notes */ let $commentButtonTemplate; -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; window.FilesCommentButton = (function() { var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; @@ -27,8 +26,8 @@ window.FilesCommentButton = (function() { TEXT_FILE_SELECTOR = '.text-file'; function FilesCommentButton(filesContainerElement) { - this.render = bind(this.render, this); - this.hideButton = bind(this.hideButton, this); + this.render = this.render.bind(this); + this.hideButton = this.hideButton.bind(this); this.isParallelView = notes.isParallelView(); filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 0c9eb84f0eb..2eee989bffe 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,9 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, - bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -213,10 +211,10 @@ GitLabDropdown = (function() { var searchFields, selector, self; this.el = el1; this.options = options; - this.updateLabel = bind(this.updateLabel, this); - this.hidden = bind(this.hidden, this); - this.opened = bind(this.opened, this); - this.shouldPropagate = bind(this.shouldPropagate, this); + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); self = this; selector = $(this.el).data("target"); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -627,8 +625,8 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - var occurrences; - occurrences = fuzzaldrinPlus.match(text, term); + const occurrences = fuzzaldrinPlus.match(text, term); + const indexOf = [].indexOf; return text.split('').map(function(character, i) { if (indexOf.call(occurrences, i) !== -1) { return "<b>" + character + "</b>"; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index 521bc77db66..0deb27e522b 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -2,7 +2,6 @@ import d3 from 'd3'; -const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; const hasProp = {}.hasOwnProperty; @@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) { function ContributorsMasterGraph(data1) { this.data = data1; - this.update_content = bind(this.update_content, this); + this.update_content = this.update_content.bind(this); this.width = $('.content').width() - 70; this.height = 200; this.x = null; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 687c2bb6110..544fc91876a 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -7,8 +7,6 @@ /* global Pikaday */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; @@ -17,10 +15,10 @@ function IssuableForm(form) { var $issuableDueDate, calendar; this.form = form; - this.toggleWip = bind(this.toggleWip, this); - this.renderWipExplanation = bind(this.renderWipExplanation, this); - this.resetAutosave = bind(this.resetAutosave, this); - this.handleSubmit = bind(this.handleSubmit, this); + this.toggleWip = this.toggleWip.bind(this); + this.renderWipExplanation = this.renderWipExplanation.bind(this); + this.resetAutosave = this.resetAutosave.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); gl.GfmAutoComplete.setup(); new UsersSelect(); new ZenMode(); diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js index 17a3fc1b1e4..03dd61b4263 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Labels = (function() { function Labels() { - this.setSuggestedColor = bind(this.setSuggestedColor, this); - this.updateColorPreview = bind(this.updateColorPreview, this); + this.setSuggestedColor = this.setSuggestedColor.bind(this); + this.updateColorPreview = this.updateColorPreview.bind(this); var form; form = $('.label-form'); this.cleanBinding(); diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js new file mode 100644 index 00000000000..25ca98afbe7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/simple_poll.js @@ -0,0 +1,15 @@ +export default (fn, interval = 2000, timeout = 60000) => { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + const next = () => { + if (Date.now() - startTime < timeout) { + setTimeout(fn.bind(null, next, stop), interval); + } else { + reject(new Error('SIMPLE_POLL_TIMEOUT')); + } + }; + fn(next, stop); + }); +}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 3ac6dedf131..517f03d5aba 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -31,8 +31,6 @@ require('vendor/jquery.scrollTo'); // </div> // (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.LineHighlighter = (function() { // CSS class applied to highlighted lines LineHighlighter.prototype.highlightClass = 'hll'; @@ -47,9 +45,9 @@ require('vendor/jquery.scrollTo'); // hash - String URL hash for dependency injection in tests hash = location.hash; } - this.setHash = bind(this.setHash, this); - this.highlightLine = bind(this.highlightLine, this); - this.clickHandler = bind(this.clickHandler, this); + this.setHash = this.setHash.bind(this); + this.highlightLine = this.highlightLine.bind(this); + this.clickHandler = this.clickHandler.bind(this); this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index e96090da80e..9411f078ecf 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b0d5fc92e3..a07aa047293 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -123,8 +123,6 @@ import './member_expiration_date'; import './members'; import './merge_request'; import './merge_request_tabs'; -import './merge_request_widget'; -import './merged_buttons'; import './milestone'; import './milestone_select'; import './mini_pipeline_graph_dropdown'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 5e01aacf2ba..d1cdcadf87d 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -6,8 +6,6 @@ require('./task_list'); require('./merge_request_tabs'); (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MergeRequest = (function() { function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -16,7 +14,7 @@ require('./merge_request_tabs'); // action - String, current controller action // this.opts = opts != null ? opts : {}; - this.submitNoteForm = bind(this.submitNoteForm, this); + this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); this.$('.show-all-commits').on('click', (function(_this) { return function() { @@ -106,6 +104,21 @@ require('./merge_request_tabs'); }); }; + MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { + $('.detail-page-header .status-box') + .removeClass(classToRemove) + .addClass(classToAdd) + .find('span') + .text(newStatusText); + }; + + MergeRequest.prototype.decreaseCounter = function(by = 1) { + const $el = $('.nav-links .js-merge-counter'); + const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); + + $el.text(gl.text.addDelimiter(count)); + }; + return MergeRequest; })(); }).call(window); diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 6f6ae9bde92..3f976680b9d 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -7,8 +7,6 @@ import './smart_interval'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; ((global) => { - var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>"> <div class="ci_widget ci-success"> <%= ci_success_icon %> @@ -258,7 +256,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; let stateClass = 'btn-danger'; if (!hasCi) { stateClass = 'btn-create'; - } else if (indexOf.call(allowed_states, state) !== -1) { + } else if (allowed_states.indexOf(state) !== -1) { switch (state) { case "failed": case "canceled": diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js deleted file mode 100644 index 21d7c3e168e..00000000000 --- a/app/assets/javascripts/merge_request_widget/ci_bundle.js +++ /dev/null @@ -1,53 +0,0 @@ -/* global merge_request_widget */ - -(() => { - $(() => { - /* TODO: This needs a better home, or should be refactored. It was previously contained - * in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml, - * but Vue chokes on script tags and prevents their execution. So it was moved here - * temporarily. - * */ - - $(document) - .off('ajax:send', '.accept-mr-form') - .on('ajax:send', '.accept-mr-form', () => { - $('.accept-mr-form :input').disable(); - }); - - $(document) - .off('click', '.accept-merge-request') - .on('click', '.accept-merge-request', () => { - $('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress'); - }); - - $(document) - .off('click', '.merge-when-pipeline-succeeds') - .on('click', '.merge-when-pipeline-succeeds', () => { - $('#merge_when_pipeline_succeeds').val('1'); - }); - - $(document) - .off('click', '.js-merge-dropdown a') - .on('click', '.js-merge-dropdown a', (e) => { - e.preventDefault(); - $(e.target).closest('form').submit(); - }); - if ($('.rebase-in-progress').length) { - merge_request_widget.rebaseInProgress(); - } else if ($('.rebase-mr-form').length) { - $(document) - .off('ajax:send', '.rebase-mr-form') - .on('ajax:send', '.rebase-mr-form', () => { - $('.rebase-mr-form :input').disable(); - }); - - $(document) - .off('click', '.js-rebase-button') - .on('click', '.js-rebase-button', () => { - $('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress"); - }); - } else { - setTimeout(() => merge_request_widget.getMergeStatus(), 200); - } - }); -})(); diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js deleted file mode 100644 index 7b0997c6520..00000000000 --- a/app/assets/javascripts/merged_buttons.js +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */ - -import '~/lib/utils/url_utility'; - -(function() { - this.MergedButtons = (function() { - function MergedButtons() { - this.removeSourceBranch = this.removeSourceBranch.bind(this); - this.removeBranchSuccess = this.removeBranchSuccess.bind(this); - this.removeBranchError = this.removeBranchError.bind(this); - this.$removeBranchWidget = $('.remove_source_branch_widget'); - this.$removeBranchProgress = $('.remove_source_branch_in_progress'); - this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); - this.cleanEventListeners(); - this.initEventListeners(); - } - - MergedButtons.prototype.cleanEventListeners = function() { - $(document).off('click', '.remove_source_branch'); - $(document).off('ajax:success', '.remove_source_branch'); - return $(document).off('ajax:error', '.remove_source_branch'); - }; - - MergedButtons.prototype.initEventListeners = function() { - $(document).on('click', '.remove_source_branch', this.removeSourceBranch); - $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); - $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); - }; - - MergedButtons.prototype.removeSourceBranch = function() { - this.$removeBranchWidget.hide(); - return this.$removeBranchProgress.show(); - }; - - MergedButtons.prototype.removeBranchSuccess = function() { - gl.utils.refreshCurrentPage(); - }; - - MergedButtons.prototype.removeBranchError = function() { - this.$removeBranchWidget.hide(); - this.$removeBranchProgress.hide(); - return this.$removeBranchFailed.show(); - }; - - return MergedButtons; - })(); -}).call(window); diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 36bc1257cef..426d7f3288e 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -2,11 +2,9 @@ /* global Api */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.NamespaceSelect = (function() { function NamespaceSelect(opts) { - this.onSelectItem = bind(this.onSelectItem, this); + this.onSelectItem = this.onSelectItem.bind(this); var fieldName, showAny; this.dropdown = opts.dropdown; showAny = true; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 67046d52a65..9d614cdee3a 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,11 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - this.NewBranchForm = (function() { function NewBranchForm(form, availableRefs) { - this.validate = bind(this.validate, this); + this.validate = this.validate.bind(this); this.branchNameError = form.find('.js-branch-name-error'); this.name = form.find('.js-branch-name'); this.ref = form.find('#ref'); @@ -95,6 +92,8 @@ NewBranchForm.prototype.validate = function() { var errorMessage, errors, formatter, unique, validator; + const indexOf = [].indexOf; + this.branchNameError.empty(); unique = function(values, value) { if (indexOf.call(values, value) === -1) { diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index ad36f08840d..658879607e2 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NewCommitForm = (function() { function NewCommitForm(form, targetBranchName = 'target_branch') { this.form = form; this.targetBranchName = targetBranchName; - this.renderDestination = bind(this.renderDestination, this); + this.renderDestination = this.renderDestination.bind(this); this.targetBranchDropdown = form.find('button.js-target-branch'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 55391ebc089..f6fe6d9f0fd 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -22,8 +22,6 @@ const normalizeNewlines = function(str) { }; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_SLASH_COMMANDS = /\/\w+/g; @@ -31,24 +29,24 @@ const normalizeNewlines = function(str) { Notes.interval = null; function Notes(notes_url, note_ids, last_fetched_at, view) { - this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateComment = bind(this.updateComment, this); - this.visibilityChange = bind(this.visibilityChange, this); - this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); - this.addDiffNote = bind(this.addDiffNote, this); - this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this); - this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this); - this.removeNote = bind(this.removeNote, this); - this.cancelEdit = bind(this.cancelEdit, this); - this.updateNote = bind(this.updateNote, this); - this.addDiscussionNote = bind(this.addDiscussionNote, this); - this.addNoteError = bind(this.addNoteError, this); - this.addNote = bind(this.addNote, this); - this.resetMainTargetForm = bind(this.resetMainTargetForm, this); - this.refresh = bind(this.refresh, this); - this.keydownNoteText = bind(this.keydownNoteText, this); - this.toggleCommitList = bind(this.toggleCommitList, this); - this.postComment = bind(this.postComment, this); + this.updateTargetButtons = this.updateTargetButtons.bind(this); + this.updateComment = this.updateComment.bind(this); + this.visibilityChange = this.visibilityChange.bind(this); + this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); + this.addDiffNote = this.addDiffNote.bind(this); + this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); + this.replyToDiscussionNote = this.replyToDiscussionNote.bind(this); + this.removeNote = this.removeNote.bind(this); + this.cancelEdit = this.cancelEdit.bind(this); + this.updateNote = this.updateNote.bind(this); + this.addDiscussionNote = this.addDiscussionNote.bind(this); + this.addNoteError = this.addNoteError.bind(this); + this.addNote = this.addNote.bind(this); + this.resetMainTargetForm = this.resetMainTargetForm.bind(this); + this.refresh = this.refresh.bind(this); + this.keydownNoteText = this.keydownNoteText.bind(this); + this.toggleCommitList = this.toggleCommitList.bind(this); + this.postComment = this.postComment.bind(this); this.notes_url = notes_url; this.note_ids = note_ids; @@ -175,7 +173,7 @@ const normalizeNewlines = function(str) { if ($textarea.val() !== '') { return; } - myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes')); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -276,7 +274,7 @@ const normalizeNewlines = function(str) { var votesBlock; if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { - $.get(mrRefreshWidgetUrl); + Notes.checkMergeRequestStatus(); } if ('emoji_award' in noteEntity.commands_changes) { @@ -424,6 +422,7 @@ const normalizeNewlines = function(str) { } gl.utils.localTimeAgo($('.js-timeago'), false); + Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); }; @@ -769,7 +768,8 @@ const normalizeNewlines = function(str) { } }; })(this)); - // Decrement the "Discussions" counter only once + + Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); }; @@ -1115,6 +1115,12 @@ const normalizeNewlines = function(str) { return $form; }; + Notes.checkMergeRequestStatus = function() { + if (gl.utils.getPagePath(1) === 'merge_requests') { + gl.mrWidget.checkStatus(); + } + }; + Notes.animateAppendNote = function(noteHtml, $notesList) { const $note = $(noteHtml); diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 5005af90d48..2ab9c4fed2c 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,10 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NotificationsForm = (function() { function NotificationsForm() { - this.toggleCheckbox = bind(this.toggleCheckbox, this); + this.toggleCheckbox = this.toggleCheckbox.bind(this); this.removeEventListeners(); this.initEventListeners(); } diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index a84161ef5e7..1f1b99ff401 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -64,6 +64,24 @@ capitalizeStageName(name) { return name.charAt(0).toUpperCase() + name.slice(1); }, + + isFirstColumn(index) { + return index === 0; + }, + + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, }, }; </script> @@ -82,10 +100,12 @@ v-if="!isLoading" class="stage-column-list"> <stage-column-component - v-for="stage in state.graph" + v-for="(stage, index) in state.graph" :title="capitalizeStageName(stage.name)" :jobs="stage.groups" - :key="stage.name"/> + :key="stage.name" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)"/> </ul> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index b7da185e280..9b1bbb0906f 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -13,6 +13,18 @@ export default { type: Array, required: true, }, + + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + + stageConnectorClass: { + type: String, + required: false, + default: '', + }, }, components: { @@ -28,20 +40,27 @@ export default { jobId(job) { return `ci-badge-${job.name}`; }, + + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, }, }; </script> <template> - <li class="stage-column"> + <li + class="stage-column" + :class="stageConnectorClass"> <div class="stage-name"> {{title}} </div> <div class="builds-container"> <ul> <li - v-for="job in jobs" + v-for="(job, index) in jobs" :key="job.id" class="build" + :class="buildConnnectorClass(index)" :id="jobId(job)"> <div class="curve"></div> diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js new file mode 100644 index 00000000000..034e8d3280e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.js @@ -0,0 +1,104 @@ +/* global Flash */ +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; + +export default { + data() { + return { + builds: '', + spinner: '<span class="fa fa-spinner fa-spin"></span>', + }; + }, + + props: { + stage: { + type: Object, + required: true, + }, + }, + + updated() { + if (this.builds) { + this.stopDropdownClickPropagation(); + } + }, + + methods: { + fetchBuilds(e) { + const ariaExpanded = e.currentTarget.attributes['aria-expanded']; + + if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; + + return this.$http.get(this.stage.dropdown_path) + .then((response) => { + this.builds = JSON.parse(response.body).html; + }, () => { + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => { + e.stopPropagation(); + }); + }, + }, + computed: { + buildsOrSpinner() { + return this.builds ? this.builds : this.spinner; + }, + dropdownClass() { + if (this.builds) return 'js-builds-dropdown-container'; + return 'js-builds-dropdown-loading builds-dropdown-loading'; + }, + buildStatus() { + return `Build: ${this.stage.status.label}`; + }, + tooltip() { + return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; + }, + triggerButtonClass() { + return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; + }, + svgHTML() { + return borderlessStatusIconEntityMap[this.stage.status.icon]; + }, + }, + watch: { + 'stage.title': function stageTitle() { + $(this.$refs.button).tooltip('destroy').tooltip(); + }, + }, + template: ` + <div> + <button + @click="fetchBuilds($event)" + :class="triggerButtonClass" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + ref="button" + :aria-label="stage.title"> + <span v-html="svgHTML" aria-hidden="true"></span> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> + <div class="arrow-up" aria-hidden="true"></div> + <div + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu" + v-html="buildsOrSpinner"> + </div> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 934bd7deb31..511f10b66f1 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -2,7 +2,7 @@ import Visibility from 'visibilityjs'; import PipelinesService from './services/pipelines_service'; import eventHub from './event_hub'; import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; -import TablePaginationComponent from '../vue_shared/components/table_pagination'; +import tablePagination from '../vue_shared/components/table_pagination.vue'; import EmptyState from './components/empty_state.vue'; import ErrorState from './components/error_state.vue'; import NavigationTabs from './components/navigation_tabs'; @@ -18,7 +18,7 @@ export default { }, components: { - 'gl-pagination': TablePaginationComponent, + tablePagination, 'pipelines-table-component': PipelinesTableComponent, 'empty-state': EmptyState, 'error-state': ErrorState, @@ -275,12 +275,13 @@ export default { /> </div> - <gl-pagination + <table-pagination v-if="shouldRenderPagination" :pagenum="pagenum" :change="change" :count="state.count.all" - :pageInfo="state.pageInfo"/> + :pageInfo="state.pageInfo" + /> </div> </div> `, diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index e01668eabef..11f9754780d 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,18 +2,16 @@ /* global fuzzaldrinPlus */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectFindFile = (function() { var highlighter; function ProjectFindFile(element1, options) { this.element = element1; this.options = options; - this.goToBlob = bind(this.goToBlob, this); - this.goToTree = bind(this.goToTree, this); - this.selectRowDown = bind(this.selectRowDown, this); - this.selectRowUp = bind(this.selectRowUp, this); + this.goToBlob = this.goToBlob.bind(this); + this.goToTree = this.goToTree.bind(this); + this.selectRowDown = this.selectRowDown.bind(this); + this.selectRowUp = this.selectRowUp.bind(this); this.filePaths = {}; this.inputElement = this.element.find(".file-finder-input"); // init event diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index e9927c1bf51..04b381fe0e0 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectNew = (function() { function ProjectNew() { - this.toggleSettings = bind(this.toggleSettings, this); + this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index a9b3de281e1..b71c3097706 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,11 +3,9 @@ import Cookies from 'js-cookie'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Sidebar = (function() { function Sidebar(currentUser) { - this.toggleTodo = bind(this.toggleTodo, this); + this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); this.removeListeners(); this.addEventListeners(); diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 85659d7fa39..8ac71797c14 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -4,11 +4,9 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Shortcuts = (function() { function Shortcuts(skipResetBindings) { - this.onToggleHelp = bind(this.onToggleHelp, this); + this.onToggleHelp = this.onToggleHelp.bind(this); this.enabledHelp = []; if (!skipResetBindings) { Mousetrap.reset(); diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js index 0948c2e5352..f35506fd5de 100644 --- a/app/assets/javascripts/sidebar/event_hub.js +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -1,3 +1,8 @@ import Vue from 'vue'; -export default new Vue(); +const eventHub = new Vue(); + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = (...args) => eventHub.$emit(...args); + +export default eventHub; diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 294d087554e..bacb26734c9 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,8 +1,6 @@ /* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.SingleFileDiff = (function() { var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; @@ -16,7 +14,7 @@ function SingleFileDiff(file) { this.file = file; - this.toggleDiff = bind(this.toggleDiff, this); + this.toggleDiff = this.toggleDiff.bind(this); this.content = $('.diff-content', this.file); this.$toggleIcon = $('.diff-toggle-caret', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path'); diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 500b78fc5d8..cd5280948fd 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -10,18 +10,16 @@ (function() { const global = window.gl || (window.gl = {}); - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - global.U2FAuthenticate = (function() { function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderAuthenticated = bind(this.renderAuthenticated, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.authenticate = bind(this.authenticate, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderAuthenticated = this.renderAuthenticated.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.authenticate = this.authenticate.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.challenge = u2fParams.challenge; this.form = form; diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js index fd1829efe18..3119b3480c3 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/u2f/error.js @@ -2,12 +2,10 @@ /* global u2f */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FError = (function() { function U2FError(errorCode, u2fFlowType) { this.errorCode = errorCode; - this.message = bind(this.message, this); + this.message = this.message.bind(this); this.httpsDisabled = window.location.protocol !== 'https:'; this.u2fFlowType = u2fFlowType; } diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js index 17631f2908d..1234d17b8fd 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/u2f/register.js @@ -8,19 +8,17 @@ // State Flow #1: setup -> in_progress -> registered -> POST to server // State Flow #2: setup -> in_progress -> error -> setup (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FRegister = (function() { function U2FRegister(container, u2fParams) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderRegistered = bind(this.renderRegistered, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderSetup = bind(this.renderSetup, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.register = bind(this.register, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderRegistered = this.renderRegistered.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderSetup = this.renderSetup.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.register = this.register.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.registerRequests = u2fParams.register_requests; this.signRequests = u2fParams.sign_requests; diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 32ffa2f0ac0..b11f691e424 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -3,12 +3,10 @@ import d3 from 'd3'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Calendar = (function() { function Calendar(timestamps, calendar_activities_path) { this.calendar_activities_path = calendar_activities_path; - this.clickDay = bind(this.clickDay, this); + this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index be29b08c343..452578d5b98 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,17 +1,18 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ +/* global emitSidebarEvent */ -import eventHub from './sidebar/event_hub'; +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = window.emitSidebarEvent || $.noop; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - slice = [].slice; + const slice = [].slice; this.UsersSelect = (function() { function UsersSelect(currentUser, els) { var $els; - this.users = bind(this.users, this); - this.user = bind(this.user, this); + this.users = this.users.bind(this); + this.user = this.user.bind(this); this.usersPath = "/autocomplete/users.json"; this.userPath = "/autocomplete/users/:id.json"; if (currentUser != null) { @@ -110,7 +111,7 @@ import eventHub from './sidebar/event_hub'; .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); firstSelected.remove(); - eventHub.$emit('sidebar.removeAssignee', { + emitSidebarEvent('sidebar.removeAssignee', { id: firstSelectedId, }); } @@ -330,7 +331,7 @@ import eventHub from './sidebar/event_hub'; defaultLabel: defaultLabel, hidden: function(e) { if ($dropdown.hasClass('js-multiselect')) { - eventHub.$emit('sidebar.saveAssignees'); + emitSidebarEvent('sidebar.saveAssignees'); } if (!$dropdown.data('always-show-selectbox')) { @@ -364,10 +365,10 @@ import eventHub from './sidebar/event_hub'; const id = parseInt(element.value, 10); element.remove(); }); - eventHub.$emit('sidebar.removeAllAssignees'); + emitSidebarEvent('sidebar.removeAllAssignees'); } else if (isActive) { // user selected - eventHub.$emit('sidebar.addAssignee', user); + emitSidebarEvent('sidebar.addAssignee', user); // Remove unassigned selection (if it was previously selected) const unassignedSelected = $dropdown.closest('.selectbox') @@ -383,7 +384,7 @@ import eventHub from './sidebar/event_hub'; } // User unselected - eventHub.$emit('sidebar.removeAssignee', user); + emitSidebarEvent('sidebar.removeAssignee', user); } if (getSelected().find(u => u === gon.current_user_id)) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js new file mode 100644 index 00000000000..a01cb8cc202 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetAuthor', + props: { + author: { type: Object, required: true }, + showAuthorName: { type: Boolean, required: false, default: true }, + showAuthorTooltip: { type: Boolean, required: false, default: false }, + }, + template: ` + <a + :href="author.webUrl || author.web_url" + class="author-link" + :class="{ 'has-tooltip': showAuthorTooltip }" + :title="author.name"> + <img + :src="author.avatarUrl || author.avatar_url" + class="avatar avatar-inline s16" /> + <span + v-if="showAuthorName" + class="author">{{author.name}} + </span> + </a> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js new file mode 100644 index 00000000000..6d2ed5fda64 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js @@ -0,0 +1,27 @@ +import MRWidgetAuthor from './mr_widget_author'; + +export default { + name: 'MRWidgetAuthorTime', + props: { + actionText: { type: String, required: true }, + author: { type: Object, required: true }, + dateTitle: { type: String, required: true }, + dateReadable: { type: String, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + template: ` + <h4 class="js-mr-widget-author"> + {{actionText}} + <mr-widget-author :author="author" /> + <time + :title="dateTitle" + data-toggle="tooltip" + data-placement="top" + data-container="body"> + {{dateReadable}} + </time> + </h4> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js new file mode 100644 index 00000000000..3c23b8e472b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import '~/lib/utils/datetime_utility'; +import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; +import MemoryUsage from './mr_widget_memory_usage'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MRWidgetDeployment', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-memory-usage': MemoryUsage, + }, + computed: { + svg() { + return statusClassToSvgMap.icon_status_success; + }, + }, + methods: { + formatDate(date) { + return gl.utils.getTimeago().format(date); + }, + hasExternalUrls(deployment = {}) { + return deployment.external_url && deployment.external_url_formatted; + }, + hasDeploymentTime(deployment = {}) { + return deployment.deployed_at && deployment.deployed_at_formatted; + }, + hasDeploymentMeta(deployment = {}) { + return deployment.url && deployment.name; + }, + stopEnvironment(deployment) { + const msg = 'Are you sure you want to stop this environment?'; + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + MRWidgetService.stopEnvironment(deployment.stop_url) + .then(res => res.json()) + .then((res) => { + if (res.redirect_url) { + gl.utils.visitUrl(res.redirect_url); + } + }) + .catch(() => { + new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line + }); + } + }, + }, + template: ` + <div class="mr-widget-heading"> + <div v-for="deployment in mr.deployments"> + <div class="ci-widget"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <span + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span> + <span + v-if="hasDeploymentMeta(deployment)"> + Deployed to + </span> + <a + v-if="hasDeploymentMeta(deployment)" + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-meta"> + {{deployment.name}} + </a> + <span + v-if="hasExternalUrls(deployment)"> + on + </span> + <a + v-if="hasExternalUrls(deployment)" + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url"> + <i + class="fa fa-external-link" + aria-hidden="true" /> + {{deployment.external_url_formatted}} + </a> + <span + v-if="hasDeploymentTime(deployment)" + :data-title="deployment.deployed_at_formatted" + class="js-deploy-time" + data-toggle="tooltip" + data-placement="top"> + {{formatDate(deployment.deployed_at)}} + </span> + <button + type="button" + v-if="deployment.stop_url" + @click="stopEnvironment(deployment)" + class="btn btn-default btn-xs"> + Stop environment + </button> + </span> + </div> + <mr-widget-memory-usage + v-if="deployment.metrics_url" + :metricsUrl="deployment.metrics_url" + /> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js new file mode 100644 index 00000000000..4a1fd881169 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -0,0 +1,98 @@ +require('../../lib/utils/text_utility'); + +export default { + name: 'MRWidgetHeader', + props: { + mr: { type: Object, required: true }, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; + }, + commitsText() { + return gl.text.pluralize('commit', this.mr.divergedCommitsCount); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, + template: ` + <div class="mr-source-target"> + <div + v-if="mr.isOpen" + class="pull-right"> + <a + href="#modal_merge_info" + data-toggle="modal" + class="btn inline btn-grouped btn-sm"> + Check out branch + </a> + <span class="dropdown inline prepend-left-5"> + <a + class="btn btn-sm dropdown-toggle" + data-toggle="dropdown" + aria-label="Download as" + role="button"> + <i + class="fa fa-download" + aria-hidden="true" /> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li> + <a + :href="mr.emailPatchesPath" + download> + Email patches + </a> + </li> + <li> + <a + :href="mr.plainDiffPath" + download> + Plain diff + </a> + </li> + </ul> + </span> + </div> + <div class="normal"> + <b>Request to merge</b> + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}" + :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''" + data-placement="bottom" + v-html="mr.sourceBranchLink"></span> + <button + class="btn btn-transparent btn-clipboard has-tooltip" + data-title="Copy branch name to clipboard" + :data-clipboard-text="mr.sourceBranch"> + <i + aria-hidden="true" + class="fa fa-clipboard"></i> + </button> + <b>into</b> + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}" + :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''" + data-placement="bottom"> + <a + :href="mr.targetBranchCommitsPath"> + {{mr.targetBranch}} + </a> + </span> + <span + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count"> + ({{mr.divergedCommitsCount}} {{commitsText}} behind) + </span> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js new file mode 100644 index 00000000000..486b13e60af --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -0,0 +1,125 @@ +import statusCodes from '~/lib/utils/http_status'; +import MemoryGraph from '../../vue_shared/components/memory_graph'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MemoryUsage', + props: { + metricsUrl: { type: String, required: true }, + }, + data() { + return { + // memoryFrom: 0, + // memoryTo: 0, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }; + }, + components: { + 'mr-memory-graph': MemoryGraph, + }, + computed: { + shouldShowLoading() { + return this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + shouldShowMemoryGraph() { + return !this.loadingMetrics && this.hasMetrics && !this.loadFailed; + }, + shouldShowLoadFailure() { + return !this.loadingMetrics && !this.hasMetrics && this.loadFailed; + }, + shouldShowMetricsUnavailable() { + return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + }, + methods: { + computeGraphData(metrics, deploymentTime) { + this.loadingMetrics = false; + const { memory_values } = metrics; + // if (memory_previous.length > 0) { + // this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2); + // } + // + // if (memory_current.length > 0) { + // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); + // } + + if (memory_values.length > 0) { + this.hasMetrics = true; + this.memoryMetrics = memory_values[0].values; + this.deploymentTime = deploymentTime; + } + }, + loadMetrics() { + gl.utils.backOff((next, stop) => { + MRWidgetService.fetchMetrics(this.metricsUrl) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + /* eslint-disable no-unused-expressions */ + this.backOffRequestCounter < 3 ? next() : stop(res); + } else { + stop(res); + } + }) + .catch(stop); + }) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + return res; + } + + return res.json(); + }) + .then((res) => { + this.computeGraphData(res.metrics, res.deployment_time); + return res; + }) + .catch(() => { + this.loadFailed = true; + this.loadingMetrics = false; + }); + }, + }, + mounted() { + this.loadingMetrics = true; + this.loadMetrics(); + }, + template: ` + <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage"> + <div class="legend"></div> + <p + v-if="shouldShowLoading" + class="usage-info js-usage-info usage-info-loading"> + <i + class="fa fa-spinner fa-spin usage-info-load-spinner" + aria-hidden="true" />Loading deployment statistics. + </p> + <p + v-if="shouldShowMemoryGraph" + class="usage-info js-usage-info"> + Deployment memory usage: + </p> + <p + v-if="shouldShowLoadFailure" + class="usage-info js-usage-info usage-info-failed"> + Failed to load deployment statistics. + </p> + <p + v-if="shouldShowMetricsUnavailable" + class="usage-info js-usage-info usage-info-unavailable"> + Deployment statistics are not available currently. + </p> + <mr-memory-graph + v-if="shouldShowMemoryGraph" + :metrics="memoryMetrics" + :deploymentTime="deploymentTime" + height="25" + width="100" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js new file mode 100644 index 00000000000..2fecebce7a0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetMergeHelp', + props: { + missingBranch: { type: String, required: false, default: '' }, + }, + template: ` + <section class="mr-widget-help"> + <template + v-if="missingBranch"> + If the {{missingBranch}} branch exists in your local repository, you + </template> + <template v-else> + You + </template> + can merge this merge request manually using the + <a + data-toggle="modal" + href="#modal_merge_info"> + command line. + </a> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js new file mode 100644 index 00000000000..801b9fb1ba1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -0,0 +1,76 @@ +import PipelineStage from '../../pipelines/components/stage'; +import pipelineStatusIcon from '../../vue_shared/components/pipeline_status_icon'; +import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; + +export default { + name: 'MRWidgetPipeline', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'pipeline-stage': PipelineStage, + 'pipeline-status-icon': pipelineStatusIcon, + }, + computed: { + hasCIError() { + const { hasCI, ciStatus } = this.mr; + + return hasCI && !ciStatus; + }, + svg() { + return statusClassToSvgMap.icon_status_failed; + }, + stageText() { + return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; + }, + }, + template: ` + <div class="mr-widget-heading"> + <div class="ci-widget"> + <template v-if="hasCIError"> + <div class="ci-status-icon ci-status-icon-failed js-ci-error"> + <span class="js-icon-link icon-link"> + <span + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span>Could not connect to the CI server. Please check your settings and try again.</span> + </template> + <template v-else> + <pipeline-status-icon :pipelineStatus="mr.pipelineDetailedStatus" /> + <span> + Pipeline + <a + :href="mr.pipeline.path" + class="pipeline-id">#{{mr.pipeline.id}}</a> + {{mr.pipeline.details.status.label}} + with {{stageText}} + </span> + <div class="mr-widget-pipeline-graph"> + <div class="stage-cell"> + <div + v-if="mr.pipeline.details.stages.length > 0" + v-for="stage in mr.pipeline.details.stages" + class="stage-container dropdown js-mini-pipeline-graph"> + <pipeline-stage :stage="stage" /> + </div> + </div> + </div> + <span> + for + <a + :href="mr.pipeline.commit.commit_path" + class="monospace js-commit-link"> + {{mr.pipeline.commit.short_id}}</a>. + </span> + <span + v-if="mr.pipeline.coverage" + class="js-mr-coverage"> + Coverage {{mr.pipeline.coverage}}%. + </span> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js new file mode 100644 index 00000000000..205804670fa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js @@ -0,0 +1,42 @@ +export default { + name: 'MRWidgetRelatedLinks', + props: { + relatedLinks: { type: Object, required: true }, + }, + computed: { + hasLinks() { + const { closing, mentioned, assignToMe } = this.relatedLinks; + return closing || mentioned || assignToMe; + }, + }, + methods: { + hasMultipleIssues(text) { + return !text ? false : text.match(/<\/a> and <a/); + }, + issueLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue'; + }, + verbLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is'; + }, + }, + template: ` + <section + v-if="hasLinks" + class="mr-info-list mr-links"> + <div class="legend"></div> + <p v-if="relatedLinks.closing"> + Closes {{issueLabel('closing')}} + <span v-html="relatedLinks.closing"></span>. + </p> + <p v-if="relatedLinks.mentioned"> + <span class="capitalize">{{issueLabel('mentioned')}}</span> + <span v-html="relatedLinks.mentioned"></span> + {{verbLabel('mentioned')}} mentioned but will not be closed. + </p> + <p v-if="relatedLinks.assignToMe"> + <span v-html="relatedLinks.assignToMe"></span> + </p> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js new file mode 100644 index 00000000000..c7f25a1697c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetArchived', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + This project is archived, write access has been disabled. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js new file mode 100644 index 00000000000..fcccb17f58d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js @@ -0,0 +1,22 @@ +export default { + name: 'MRWidgetAutoMergeFailed', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold danger"> + This merge request failed to be merged automatically. + </span> + <div class="merge-error-text"> + {{mr.mergeError}} + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js new file mode 100644 index 00000000000..8515b54e62d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -0,0 +1,19 @@ +export default { + name: 'MRWidgetChecking', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Checking ability to merge automatically. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js new file mode 100644 index 00000000000..7e66441e5ff --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js @@ -0,0 +1,30 @@ +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; + +export default { + name: 'MRWidgetClosed', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Closed by" + :author="mr.closedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.closedAt" + /> + <section> + <p> + The changes were not merged into + <a + :href="mr.targetBranchCommitsPath" + class="label-branch"> + {{mr.targetBranch}}</a>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js new file mode 100644 index 00000000000..36596c6f37e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -0,0 +1,39 @@ +export default { + name: 'MRWidgetConflicts', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are merge conflicts. + <span v-if="!mr.canMerge"> + Resolve these conflicts or ask someone with write access to this repository to merge it locally. + </span> + </span> + <div + v-if="mr.canMerge" + class="btn-group"> + <a + v-if="mr.conflictResolutionPath" + :href="mr.conflictResolutionPath" + class="btn btn-default btn-xs js-resolve-conflicts-button"> + Resolve conflicts + </a> + <a + v-if="mr.canMerge" + class="btn btn-default btn-xs js-merge-locally-button" + data-toggle="modal" + href="#modal_merge_info"> + Merge locally + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js new file mode 100644 index 00000000000..600b4d42e3d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -0,0 +1,76 @@ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetFailedToMerge', + props: { + mr: { type: Object, required: true }, + }, + data() { + return { + timer: 10, + isRefreshing: false, + }; + }, + mounted() { + setInterval(() => { + this.updateTimer(); + }, 1000); + }, + created() { + eventHub.$emit('DisablePolling'); + }, + computed: { + timerText() { + return this.timer > 1 ? `${this.timer} seconds` : 'a second'; + }, + }, + methods: { + refresh() { + this.isRefreshing = true; + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('EnablePolling'); + }, + updateTimer() { + this.timer = this.timer - 1; + + if (this.timer === 0) { + this.refresh(); + } + }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span + v-if="!isRefreshing" + class="bold danger"> + <span + class="has-error-message" + v-if="mr.mergeError"> + {{mr.mergeError}} + </span> + <span v-else>Merge failed.</span> + <span + :class="{ 'has-custom-error': mr.mergeError }"> + Refreshing in {{timerText}} to show the updated status... + </span> + <button + @click="refresh" + class="btn btn-default btn-xs js-refresh-button" + type="button"> + Refresh now + </button> + </span> + <span + v-if="isRefreshing" + class="bold js-refresh-label"> + Refreshing now... + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js new file mode 100644 index 00000000000..e3c27dfb76d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js @@ -0,0 +1,24 @@ +export default { + name: 'MRWidgetLocked', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body mr-state-locked"> + <span class="state-label">Locked</span> + This merge request is in the process of being merged, during which time it is locked and cannot be closed. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + <section class="mr-info-list mr-links"> + <div class="legend"></div> + <p> + The changes will be merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js new file mode 100644 index 00000000000..bcdbedcd46b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import MRWidgetAuthor from '../../components/mr_widget_author'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMergeWhenPipelineSucceeds', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + data() { + return { + isCancellingAutoMerge: false, + isRemovingSourceBranch: false, + }; + }, + computed: { + canRemoveSourceBranch() { + const { shouldRemoveSourceBranch, canRemoveSourceBranch, + mergeUserId, currentUserId } = this.mr; + + return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId; + }, + }, + methods: { + cancelAutomaticMerge() { + this.isCancellingAutoMerge = true; + this.service.cancelAutomaticMerge() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + }) + .catch(() => { + this.isCancellingAutoMerge = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + removeSourceBranch() { + const options = { + sha: this.mr.sha, + merge_when_pipeline_succeeds: true, + should_remove_source_branch: true, + }; + + this.isRemovingSourceBranch = true; + this.service.mergeResource.save(options) + .then(res => res.json()) + .then((res) => { + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } + }) + .catch(() => { + this.isRemovingSourceBranch = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <h4> + Set by + <mr-widget-author :author="mr.setToMWPSBy" /> + to be merged automatically when the pipeline succeeds. + <a + v-if="mr.canCancelAutomaticMerge" + @click.prevent="cancelAutomaticMerge" + :disabled="isCancellingAutoMerge" + role="button" + href="#" + class="btn btn-xs btn-default js-cancel-auto-merge"> + <i + v-if="isCancellingAutoMerge" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Cancel automatic merge + </a> + </h4> + <section class="mr-info-list"> + <div class="legend"></div> + <p>The changes will be merged into + <a + :href="mr.targetBranchPath" + class="label-branch"> + {{mr.targetBranch}} + </a> + </p> + <p v-if="mr.shouldRemoveSourceBranch"> + The source branch will be removed. + </p> + <p + v-else + class="with-button"> + The source branch will not be removed. + <a + v-if="canRemoveSourceBranch" + :disabled="isRemovingSourceBranch" + @click.prevent="removeSourceBranch" + role="button" + class="btn btn-xs btn-default js-remove-source-branch" + href="#"> + <i + v-if="isRemovingSourceBranch" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Remove source branch + </a> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js new file mode 100644 index 00000000000..c7d32d18141 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,130 @@ +/* global Flash */ + +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMerged', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + data() { + return { + isMakingRequest: false, + }; + }, + computed: { + shouldShowRemoveSourceBranch() { + const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr; + + return !sourceBranchRemoved && canRemoveSourceBranch && + !this.isMakingRequest && !isRemovingSourceBranch; + }, + shouldShowSourceBranchRemoving() { + const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr; + return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest); + }, + shouldShowMergedButtons() { + const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath, + cherryPickInForkPath } = this.mr; + + return canRevertInCurrentMR || canCherryPickInCurrentMR || + revertInForkPath || cherryPickInForkPath; + }, + }, + methods: { + removeSourceBranch() { + this.isMakingRequest = true; + this.service.removeSourceBranch() + .then(res => res.json()) + .then((res) => { + if (res.message === 'Branch was removed') { + eventHub.$emit('MRWidgetUpdateRequested', () => { + this.isMakingRequest = false; + }); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Merged by" + :author="mr.mergedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.mergedAt" /> + <section class="mr-info-list"> + <div class="legend"></div> + <p> + The changes were merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span> + </p> + <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p> + <p v-if="shouldShowRemoveSourceBranch"> + You can remove source branch now. + <button + @click="removeSourceBranch" + :class="{ disabled: isMakingRequest }" + type="button" + class="btn btn-xs btn-default js-remove-branch-button"> + Remove Source Branch + </button> + </p> + <p v-if="shouldShowSourceBranchRemoving"> + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + The source branch is being removed. + </p> + </section> + <div + v-if="shouldShowMergedButtons" + class="merged-buttons clearfix"> + <a + v-if="mr.canRevertInCurrentMR" + class="btn btn-close btn-sm has-tooltip" + href="#modal-revert-commit" + data-toggle="modal" + data-container="body" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-else-if="mr.revertInForkPath" + class="btn btn-close btn-sm has-tooltip" + data-method="post" + :href="mr.revertInForkPath" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-if="mr.canCherryPickInCurrentMR" + class="btn btn-default btn-sm has-tooltip" + href="#modal-cherry-pick-commit" + data-toggle="modal" + data-container="body" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + <a + v-else-if="mr.cherryPickInForkPath" + class="btn btn-default btn-sm has-tooltip" + data-method="post" + :href="mr.cherryPickInForkPath" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js new file mode 100644 index 00000000000..328382485f6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -0,0 +1,34 @@ +import mrWidgetMergeHelp from '../../components/mr_widget_merge_help'; + +export default { + name: 'MRWidgetMissingBranch', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-merge-help': mrWidgetMergeHelp, + }, + computed: { + missingBranchName() { + return this.mr.sourceBranchRemoved ? 'source' : 'target'; + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold js-branch-text"> + <span class="capitalize"> + {{missingBranchName}} + </span> branch does not exist. + Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch. + </span> + <mr-widget-merge-help + :missing-branch="missingBranchName" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js new file mode 100644 index 00000000000..07169b349be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js @@ -0,0 +1,17 @@ +export default { + name: 'MRWidgetNotAllowed', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Ready to be merged automatically. + Ask someone with write access to this repository to merge this request. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js new file mode 100644 index 00000000000..8c4535f1337 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -0,0 +1,17 @@ +export default { + name: 'MRWidgetNothingToMerge', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There is nothing to merge from source branch into target branch. + Please push new commits or use a different branch. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js new file mode 100644 index 00000000000..31c53b679ed --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Pipeline blocked. The pipeline for this merge request requires a manual action to proceed. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js new file mode 100644 index 00000000000..002820123ca --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js new file mode 100644 index 00000000000..ebcc03e531b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,309 @@ +/* global Flash */ + +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; +import simplePoll from '~/lib/utils/simple_poll'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetReadyToMerge', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + removeSourceBranch: true, + mergeWhenBuildSucceeds: false, + useCommitMessageWithDescription: false, + setToMergeWhenPipelineSucceeds: false, + showCommitMessageEditor: false, + isMakingRequest: false, + isMergingImmediately: false, + commitMessage: this.mr.commitMessage, + successSvg, + warningSvg, + }; + }, + computed: { + commitMessageLinkTitle() { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + return this.useCommitMessageWithDescription ? withoutDesc : withDesc; + }, + mergeButtonClass() { + const defaultClass = 'btn btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; + + if (hasCI && !ciStatus) { + return failedClass; + } else if (!pipeline) { + return defaultClass; + } else if (isPipelineActive) { + return inActionClass; + } else if (isPipelineFailed) { + return failedClass; + } + + return defaultClass; + }, + mergeButtonText() { + if (this.isMergingImmediately) { + return 'Merge in progress'; + } else if (this.mr.isPipelineActive) { + return 'Merge when pipeline succeeds'; + } + + return 'Merge'; + }, + shouldShowMergeOptionsDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, + isMergeButtonDisabled() { + const { commitMessage } = this; + return Boolean(!commitMessage.length + || !this.isMergeAllowed() + || this.isMakingRequest + || this.mr.preventMerge); + }, + shouldShowSquashBeforeMerge() { + const { commitsCount, enableSquashBeforeMerge } = this.mr; + return enableSquashBeforeMerge && commitsCount > 1; + }, + }, + methods: { + isMergeAllowed() { + return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed); + }, + updateCommitMessage() { + const cmwd = this.mr.commitMessageWithDescription; + this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; + this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; + }, + toggleCommitMessageEditor() { + this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { + // TODO: Remove no-param-reassign + if (mergeWhenBuildSucceeds === undefined) { + mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign + } else if (mergeImmediately) { + this.isMergingImmediately = true; + } + + this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + + const options = { + sha: this.mr.sha, + commit_message: this.commitMessage, + merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + should_remove_source_branch: this.removeSourceBranch === true, + }; + + // Only truthy in EE extension of this component + if (this.setAdditionalParams) { + this.setAdditionalParams(options); + } + + this.isMakingRequest = true; + this.service.merge(options) + .then(res => res.json()) + .then((res) => { + const hasError = res.status === 'failed' || res.status === 'hook_validation_error'; + + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } else if (res.status === 'success') { + this.initiateMergePolling(); + } else if (hasError) { + eventHub.$emit('FailedToMerge', res.merge_error); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initiateMergePolling() { + simplePoll((continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }); + }, + handleMergePolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + if (res.state === 'merged') { + // If state is merged we should update the widget and stop the polling + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('FetchActionsContent'); + if (window.mergeRequest) { + window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged'); + window.mergeRequest.decreaseCounter(); + } + stopPolling(); + + // If user checked remove source branch and we didn't remove the branch yet + // we should start another polling for source branch remove process + if (this.removeSourceBranch && res.source_branch_exists) { + this.initiateRemoveSourceBranchPolling(); + } + } else if (res.merge_error) { + eventHub.$emit('FailedToMerge', res.merge_error); + stopPolling(); + } else { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + }); + }, + initiateRemoveSourceBranchPolling() { + // We need to show source branch is being removed spinner in another component + eventHub.$emit('SetBranchRemoveFlag', [true]); + + simplePoll((continuePolling, stopPolling) => { + this.handleRemoveBranchPolling(continuePolling, stopPolling); + }); + }, + handleRemoveBranchPolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + // If source branch exists then we should continue polling + // because removing a source branch is a background task and takes time + if (res.source_branch_exists) { + continuePolling(); + } else { + // Branch is removed. Update widget, stop polling and hide the spinner + eventHub.$emit('MRWidgetUpdateRequested', () => { + eventHub.$emit('SetBranchRemoveFlag', [false]); + }); + stopPolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <span class="btn-group"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + {{mergeButtonText}} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-info dropdown-toggle" + data-toggle="dropdown"> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <span class="sr-only"> + Select merge moment + </span> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge when pipeline succeeds</span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge immediately</span> + </a> + </li> + </ul> + </span> + <template v-if="isMergeAllowed()"> + <label class="spacing"> + <input + v-model="removeSourceBranch" + :disabled="isMergeButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <button + @click="toggleCommitMessageEditor" + :disabled="isMergeButtonDisabled" + class="btn btn-default btn-xs" + type="button"> + Modify commit message + </button> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#">{{commitMessageLinkTitle}}</a> + </div> + </div> + </div> + </div> + </template> + <template v-else> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +export default { + template: '', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js new file mode 100644 index 00000000000..f4ab2d9fa58 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -0,0 +1,27 @@ +export default { + name: 'MRWidgetUnresolvedDiscussions', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + <span v-if="mr.canCreateIssue">or</span> + <span v-else>.</span> + </span> + <a + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs js-create-issue"> + Create an issue to resolve them later + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js new file mode 100644 index 00000000000..cb02ffe93bd --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -0,0 +1,59 @@ +/* global Flash */ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetWIP', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + isMakingRequest: false, + }; + }, + methods: { + removeWIP() { + this.isMakingRequest = true; + this.service.removeWIP() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line + $('.merge-request .detail-page-description .title').text(this.mr.title); + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge</button> + <span class="bold"> + This merge request is currently Work In Progress and therefore unable to merge + </span> + <template v-if="mr.removeWIPPath"> + <i + class="fa fa-question-circle has-tooltip" + title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." /> + <button + @click="removeWIP" + :disabled="isMakingRequest" + type="button" + class="btn btn-default btn-xs js-remove-wip"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Resolve WIP status + </button> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js new file mode 100644 index 00000000000..b2eb32ead5f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -0,0 +1,42 @@ +/** + * This file is the centerpiece of an attempt to reduce potential conflicts + * between the CE and EE versions of the MR widget. EE additions to the MR widget should + * be contained in the ./vue_merge_request_widget/ee directory, and should **extend** + * rather than mutate CE MR Widget code. + * + * This file should be the only source of conflicts between EE and CE. EE-only components should + * imported directly where they are needed, and import paths for EE extensions of CE components + * should overwrite import paths **without** changing the order of dependencies listed here. + */ + +export { default as Vue } from 'vue'; +export { default as SmartInterval } from '~/smart_interval'; +export { default as WidgetHeader } from './components/mr_widget_header'; +export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; +export { default as WidgetPipeline } from './components/mr_widget_pipeline'; +export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as WidgetRelatedLinks } from './components/mr_widget_related_links'; +export { default as MergedState } from './components/states/mr_widget_merged'; +export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge'; +export { default as ClosedState } from './components/states/mr_widget_closed'; +export { default as LockedState } from './components/states/mr_widget_locked'; +export { default as WipState } from './components/states/mr_widget_wip'; +export { default as ArchivedState } from './components/states/mr_widget_archived'; +export { default as ConflictsState } from './components/states/mr_widget_conflicts'; +export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge'; +export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; +export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; +export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; +export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; +export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; +export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds'; +export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed'; +export { default as CheckingState } from './components/states/mr_widget_checking'; +export { default as MRWidgetStore } from './stores/mr_widget_store'; +export { default as MRWidgetService } from './services/mr_widget_service'; +export { default as eventHub } from './event_hub'; +export { default as getStateKey } from './stores/get_state_key'; +export { default as mrWidgetOptions } from './mr_widget_options'; +export { default as stateMaps } from './stores/state_maps'; +export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js new file mode 100644 index 00000000000..cd65ac069c5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -0,0 +1,12 @@ +import { + Vue, + mrWidgetOptions, +} from './dependencies'; + +document.addEventListener('DOMContentLoaded', () => { + const vm = new Vue(mrWidgetOptions); + + window.gl.mrWidget = { + checkStatus: vm.checkStatus, + }; +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js new file mode 100644 index 00000000000..7c6c2d21714 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -0,0 +1,234 @@ +/* global Flash */ + +import { + WidgetHeader, + WidgetMergeHelp, + WidgetPipeline, + WidgetDeployment, + WidgetRelatedLinks, + MergedState, + ClosedState, + LockedState, + WipState, + ArchivedState, + ConflictsState, + NothingToMergeState, + MissingBranchState, + NotAllowedState, + ReadyToMergeState, + UnresolvedDiscussionsState, + PipelineBlockedState, + PipelineFailedState, + FailedToMerge, + MergeWhenPipelineSucceedsState, + AutoMergeFailed, + CheckingState, + MRWidgetStore, + MRWidgetService, + eventHub, + stateMaps, + SquashBeforeMerge, +} from './dependencies'; + +export default { + el: '#js-vue-mr-widget', + name: 'MRWidget', + data() { + const store = new MRWidgetStore(gl.mrWidgetData); + const service = this.createService(store); + return { + mr: store, + service, + }; + }, + computed: { + componentName() { + return stateMaps.stateToComponentMap[this.mr.state]; + }, + shouldRenderMergeHelp() { + return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1; + }, + shouldRenderPipelines() { + return Object.keys(this.mr.pipeline).length || this.mr.hasCI; + }, + shouldRenderRelatedLinks() { + return this.mr.relatedLinks; + }, + shouldRenderDeployments() { + return this.mr.deployments.length; + }, + }, + methods: { + createService(store) { + const endpoints = { + mergePath: store.mergePath, + mergeCheckPath: store.mergeCheckPath, + cancelAutoMergePath: store.cancelAutoMergePath, + removeWIPPath: store.removeWIPPath, + sourceBranchPath: store.sourceBranchPath, + ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, + statusPath: store.statusPath, + mergeActionsContentPath: store.mergeActionsContentPath, + }; + return new MRWidgetService(endpoints); + }, + checkStatus(cb) { + this.service.checkStatus() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); + this.setFavicon(); + if (cb) { + cb.call(null, res); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initPolling() { + this.pollingInterval = new gl.SmartInterval({ + callback: this.checkStatus, + startingInterval: 10000, + maxInterval: 30000, + hiddenInterval: 120000, + incrementByFactorOf: 5000, + }); + }, + initDeploymentsPolling() { + this.deploymentsInterval = new gl.SmartInterval({ + callback: this.fetchDeployments, + startingInterval: 30000, + maxInterval: 120000, + hiddenInterval: 240000, + incrementByFactorOf: 15000, + immediateExecution: true, + }); + }, + setFavicon() { + if (this.mr.ciStatusFaviconPath) { + gl.utils.setFavicon(this.mr.ciStatusFaviconPath); + } + }, + fetchDeployments() { + this.service.fetchDeployments() + .then(res => res.json()) + .then((res) => { + if (res.length) { + this.mr.deployments = res; + } + }) + .catch(() => { + new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line + }); + }, + fetchActionsContent() { + this.service.fetchMergeActionsContent() + .then((res) => { + if (res.body) { + const el = document.createElement('div'); + el.innerHTML = res.body; + document.body.appendChild(el); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + resumePolling() { + this.pollingInterval.resume(); + }, + stopPolling() { + this.pollingInterval.stopTimer(); + }, + bindEventHubListeners() { + eventHub.$on('MRWidgetUpdateRequested', (cb) => { + this.checkStatus(cb); + }); + + // `params` should be an Array contains a Boolean, like `[true]` + // Passing parameter as Boolean didn't work. + eventHub.$on('SetBranchRemoveFlag', (params) => { + this.mr.isRemovingSourceBranch = params[0]; + }); + + eventHub.$on('FailedToMerge', (mergeError) => { + this.mr.state = 'failedToMerge'; + this.mr.mergeError = mergeError; + }); + + eventHub.$on('UpdateWidgetData', (data) => { + this.mr.setData(data); + }); + + eventHub.$on('FetchActionsContent', () => { + this.fetchActionsContent(); + }); + + eventHub.$on('EnablePolling', () => { + this.resumePolling(); + }); + + eventHub.$on('DisablePolling', () => { + this.stopPolling(); + }); + }, + handleMounted() { + this.checkStatus(); + this.setFavicon(); + this.initDeploymentsPolling(); + }, + }, + created() { + this.initPolling(); + this.bindEventHubListeners(); + }, + mounted() { + this.handleMounted(); + }, + components: { + 'mr-widget-header': WidgetHeader, + 'mr-widget-merge-help': WidgetMergeHelp, + 'mr-widget-pipeline': WidgetPipeline, + 'mr-widget-deployment': WidgetDeployment, + 'mr-widget-related-links': WidgetRelatedLinks, + 'mr-widget-merged': MergedState, + 'mr-widget-closed': ClosedState, + 'mr-widget-locked': LockedState, + 'mr-widget-failed-to-merge': FailedToMerge, + 'mr-widget-wip': WipState, + 'mr-widget-archived': ArchivedState, + 'mr-widget-conflicts': ConflictsState, + 'mr-widget-nothing-to-merge': NothingToMergeState, + 'mr-widget-not-allowed': NotAllowedState, + 'mr-widget-missing-branch': MissingBranchState, + 'mr-widget-ready-to-merge': ReadyToMergeState, + 'mr-widget-squash-before-merge': SquashBeforeMerge, + 'mr-widget-checking': CheckingState, + 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, + 'mr-widget-pipeline-blocked': PipelineBlockedState, + 'mr-widget-pipeline-failed': PipelineFailedState, + 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, + 'mr-widget-auto-merge-failed': AutoMergeFailed, + }, + template: ` + <div class="mr-state-widget prepend-top-default"> + <mr-widget-header :mr="mr" /> + <mr-widget-pipeline + v-if="shouldRenderPipelines" + :mr="mr" /> + <mr-widget-deployment + v-if="shouldRenderDeployments" + :mr="mr" + :service="service" /> + <component + :is="componentName" + :mr="mr" + :service="service" /> + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :related-links="mr.relatedLinks" /> + <mr-widget-merge-help v-if="shouldRenderMergeHelp" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js new file mode 100644 index 00000000000..42493be3372 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class MRWidgetService { + constructor(endpoints) { + this.mergeResource = Vue.resource(endpoints.mergePath); + this.mergeCheckResource = Vue.resource(endpoints.mergeCheckPath); + this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath); + this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); + this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); + this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); + this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); + } + + merge(data) { + return this.mergeResource.save(data); + } + + cancelAutomaticMerge() { + return this.cancelAutoMergeResource.save(); + } + + removeWIP() { + return this.removeWIPResource.save(); + } + + removeSourceBranch() { + return this.removeSourceBranchResource.delete(); + } + + fetchDeployments() { + return this.deploymentsResource.get(); + } + + poll() { + return this.pollResource.get(); + } + + checkStatus() { + return this.mergeCheckResource.get(); + } + + fetchMergeActionsContent() { + return this.mergeActionsContentResource.get(); + } + + static stopEnvironment(url) { + return Vue.http.post(url); + } + + static fetchMetrics(metricsUrl) { + return Vue.http.get(`${metricsUrl}.json`); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js new file mode 100644 index 00000000000..fee4113f3c8 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -0,0 +1,28 @@ +export default function deviseState(data) { + if (data.project_archived) { + return 'archived'; + } else if (data.branch_missing) { + return 'missingBranch'; + } else if (!data.commits_count) { + return 'nothingToMerge'; + } else if (this.mergeStatus === 'unchecked') { + return 'checking'; + } else if (data.has_conflicts) { + return 'conflicts'; + } else if (data.work_in_progress) { + return 'workInProgress'; + } else if (this.mergeWhenPipelineSucceeds) { + return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + } else if (!this.canMerge) { + return 'notAllowedToMerge'; + } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { + return 'pipelineFailed'; + } else if (this.hasMergeableDiscussionsState) { + return 'unresolvedDiscussions'; + } else if (this.isPipelineBlocked) { + return 'pipelineBlocked'; + } else if (this.canBeMerged) { + return 'readyToMerge'; + } + return null; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js new file mode 100644 index 00000000000..faafeae5c5b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,134 @@ +import Timeago from 'timeago.js'; +import { getStateKey } from '../dependencies'; + +export default class MergeRequestStore { + + constructor(data) { + this.setData(data); + } + + setData(data) { + const currentUser = data.current_user; + const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; + + this.title = data.title; + this.targetBranch = data.target_branch; + this.sourceBranch = data.source_branch; + this.mergeStatus = data.merge_status; + this.sha = data.diff_head_sha; + this.commitMessage = data.merge_commit_message; + this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitsCount = data.commits_count; + this.divergedCommitsCount = data.diverged_commits_count; + this.pipeline = data.pipeline || {}; + this.deployments = this.deployments || data.deployments || []; + + if (data.issues_links) { + const links = data.issues_links; + const { closing } = links; + const mentioned = links.mentioned_but_not_closing; + const assignToMe = links.assign_to_closing; + + if (closing || mentioned || assignToMe) { + this.relatedLinks = { closing, mentioned, assignToMe }; + } + } + + this.updatedAt = data.updated_at; + this.mergedAt = MergeRequestStore.getEventDate(data.merge_event); + this.closedAt = MergeRequestStore.getEventDate(data.closed_event); + this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event); + this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event); + this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} }); + this.mergeUserId = data.merge_user_id; + this.currentUserId = gon.current_user_id; + this.sourceBranchPath = data.source_branch_path; + this.sourceBranchLink = data.source_branch_with_namespace_link; + this.mergeError = data.merge_error; + this.targetBranchPath = data.target_branch_commits_path; + this.conflictResolutionPath = data.conflict_resolution_path; + this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.removeWIPPath = data.remove_wip_path; + this.sourceBranchRemoved = !data.source_branch_exists; + this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; + this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; + this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.mergePath = data.merge_path; + this.statusPath = data.status_path; + this.emailPatchesPath = data.email_patches_path; + this.plainDiffPath = data.plain_diff_path; + this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; + this.mergeCheckPath = data.merge_check_path; + this.mergeActionsContentPath = data.commit_change_content_path; + this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; + this.isOpen = data.state === 'opened' || data.state === 'reopened' || false; + this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canMerge = !!data.merge_path; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.canBeMerged = data.can_be_merged || false; + + // Cherry-pick and Revert actions related + this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; + this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; + this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; + this.revertInForkPath = currentUser.revert_in_fork_path; + + // CI related + this.ciEnvironmentsStatusPath = data.ci_environments_status_path; + this.hasCI = data.has_ci; + this.ciStatus = data.ci_status; + this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false; + this.pipelineDetailedStatus = pipelineStatus; + this.isPipelineActive = data.pipeline ? data.pipeline.active : false; + this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; + this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + + this.setState(data); + } + + setState(data) { + if (this.isOpen) { + this.state = getStateKey.call(this, data); + } else { + switch (data.state) { + case 'merged': + this.state = 'merged'; + break; + case 'closed': + this.state = 'closed'; + break; + case 'locked': + this.state = 'locked'; + break; + default: + this.state = null; + } + } + } + + static getAuthorObject(event) { + if (!event) { + return {}; + } + + return { + name: event.author.name || '', + username: event.author.username || '', + webUrl: event.author.web_url || '', + avatarUrl: event.author.avatar_url || '', + }; + } + + static getEventDate(event) { + const timeagoInstance = new Timeago(); + + if (!event) { + return ''; + } + + return timeagoInstance.format(event.updated_at); + } + +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js new file mode 100644 index 00000000000..625d7a01c65 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,36 @@ +const stateToComponentMap = { + merged: 'mr-widget-merged', + closed: 'mr-widget-closed', + locked: 'mr-widget-locked', + conflicts: 'mr-widget-conflicts', + missingBranch: 'mr-widget-missing-branch', + workInProgress: 'mr-widget-wip', + readyToMerge: 'mr-widget-ready-to-merge', + nothingToMerge: 'mr-widget-nothing-to-merge', + notAllowedToMerge: 'mr-widget-not-allowed', + archived: 'mr-widget-archived', + checking: 'mr-widget-checking', + unresolvedDiscussions: 'mr-widget-unresolved-discussions', + pipelineBlocked: 'mr-widget-pipeline-blocked', + pipelineFailed: 'mr-widget-pipeline-failed', + mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + failedToMerge: 'mr-widget-failed-to-merge', + autoMergeFailed: 'mr-widget-auto-merge-failed', +}; + +const statesToShowHelpWidget = [ + 'locked', + 'conflicts', + 'workInProgress', + 'readyToMerge', + 'checking', + 'unresolvedDiscussions', + 'pipelineFailed', + 'pipelineBlocked', + 'autoMergeFailed', +]; + +export default { + stateToComponentMap, + statesToShowHelpWidget, +}; diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js index 734b3c6c45e..ee41dc95beb 100644 --- a/app/assets/javascripts/vue_shared/ci_action_icons.js +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -1,6 +1,7 @@ import cancelSVG from 'icons/_icon_action_cancel.svg'; import retrySVG from 'icons/_icon_action_retry.svg'; import playSVG from 'icons/_icon_action_play.svg'; +import stopSVG from 'icons/_icon_action_stop.svg'; export default function getActionIcon(action) { let icon; @@ -14,6 +15,9 @@ export default function getActionIcon(action) { case 'icon_action_play': icon = playSVG; break; + case 'icon_action_stop': + icon = stopSVG; + break; default: icon = ''; } diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js new file mode 100644 index 00000000000..643b77e04c7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/memory_graph.js @@ -0,0 +1,115 @@ +export default { + name: 'MemoryGraph', + props: { + metrics: { type: Array, required: true }, + deploymentTime: { type: Number, required: true }, + width: { type: String, required: true }, + height: { type: String, required: true }, + }, + data() { + return { + pathD: '', + pathViewBox: '', + dotX: '', + dotY: '', + }; + }, + computed: { + getFormattedMedian() { + const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000); + return `Deployed ${deployedSince}`; + }, + }, + methods: { + /** + * Returns metric value index in metrics array + * with timestamp closest to matching median + */ + getMedianMetricIndex(median, metrics) { + let matchIndex = 0; + let timestampDiff = 0; + let smallestDiff = 0; + + const metricTimestamps = metrics.map(v => v[0]); + + // Find metric timestamp which is closest to deploymentTime + timestampDiff = Math.abs(metricTimestamps[0] - median); + metricTimestamps.forEach((timestamp, index) => { + if (index === 0) { // Skip first element + return; + } + + smallestDiff = Math.abs(timestamp - median); + if (smallestDiff < timestampDiff) { + matchIndex = index; + timestampDiff = smallestDiff; + } + }); + + return matchIndex; + }, + + /** + * Get Graph Plotting values to render Line and Dot + */ + getGraphPlotValues(median, metrics) { + const renderData = metrics.map(v => v[1]); + const medianMetricIndex = this.getMedianMetricIndex(median, metrics); + let cx = 0; + let cy = 0; + + // Find Maximum and Minimum values from `renderData` array + const maxMemory = Math.max.apply(null, renderData); + const minMemory = Math.min.apply(null, renderData); + + // Find difference between extreme ends + const diff = maxMemory - minMemory; + const lineWidth = renderData.length; + + // Iterate over metrics values and perform following + // 1. Find x & y co-ords for deploymentTime's memory value + // 2. Return line path against maxMemory + const linePath = renderData.map((y, x) => { + if (medianMetricIndex === x) { + cx = x; + cy = maxMemory - y; + } + return `${x} ${maxMemory - y}`; + }); + + return { + pathD: linePath, + pathViewBox: { + lineWidth, + diff, + }, + dotX: cx, + dotY: cy, + }; + }, + + /** + * Render Graph based on provided median and metrics values + */ + renderGraph(median, metrics) { + const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics); + + // Set props and update graph on UI. + this.pathD = `M ${pathD}`; + this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; + this.dotX = dotX; + this.dotY = dotY; + }, + }, + mounted() { + this.renderGraph(this.deploymentTime, this.metrics); + }, + template: ` + <div class="memory-graph-container"> + <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg"> + <path :d="pathD" :viewBox="pathViewBox" /> + <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" /> + </svg> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js new file mode 100644 index 00000000000..ae246ada01b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js @@ -0,0 +1,23 @@ +import { statusClassToSvgMap } from '../pipeline_svg_icons'; + +export default { + name: 'PipelineStatusIcon', + props: { + pipelineStatus: { type: Object, required: true, default: () => ({}) }, + }, + computed: { + svg() { + return statusClassToSvgMap[this.pipelineStatus.icon]; + }, + statusClass() { + return `ci-status-icon ci-status-icon-${this.pipelineStatus.group}`; + }, + }, + template: ` + <div :class="statusClass"> + <a class="icon-link" :href="pipelineStatus.details_path"> + <span v-html="svg" aria-hidden="true"></span> + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue index ebb14912b00..5e7df22dd83 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.js +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -1,3 +1,4 @@ +<script> const PAGINATION_UI_BUTTON_LIMIT = 4; const UI_LIMIT = 6; const SPREAD = '...'; @@ -114,22 +115,23 @@ export default { return items; }, }, - template: ` - <div class="gl-pagination"> - <ul class="pagination clearfix"> - <li v-for='item in getItems' - :class='{ - page: item.page, - prev: item.prev, - next: item.next, - separator: item.separator, - active: item.active, - disabled: item.disabled - }' - > - <a @click="changePage($event)">{{item.title}}</a> - </li> - </ul> - </div> - `, }; +</script> +<template> + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li + v-for="item in getItems" + :class="{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }"> + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/pipeline_svg_icons.js b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js new file mode 100644 index 00000000000..5af30ae74f0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js @@ -0,0 +1,43 @@ +import canceledSvg from 'icons/_icon_status_canceled.svg'; +import createdSvg from 'icons/_icon_status_created.svg'; +import failedSvg from 'icons/_icon_status_failed.svg'; +import manualSvg from 'icons/_icon_status_manual.svg'; +import pendingSvg from 'icons/_icon_status_pending.svg'; +import runningSvg from 'icons/_icon_status_running.svg'; +import skippedSvg from 'icons/_icon_status_skipped.svg'; +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; + +import canceledBorderlessSvg from 'icons/_icon_status_canceled_borderless.svg'; +import createdBorderlessSvg from 'icons/_icon_status_created_borderless.svg'; +import failedBorderlessSvg from 'icons/_icon_status_failed_borderless.svg'; +import manualBorderlessSvg from 'icons/_icon_status_manual_borderless.svg'; +import pendingBorderlessSvg from 'icons/_icon_status_pending_borderless.svg'; +import runningBorderlessSvg from 'icons/_icon_status_running_borderless.svg'; +import skippedBorderlessSvg from 'icons/_icon_status_skipped_borderless.svg'; +import successBorderlessSvg from 'icons/_icon_status_success_borderless.svg'; +import warningBorderlessSvg from 'icons/_icon_status_warning_borderless.svg'; + +export const statusClassToSvgMap = { + icon_status_canceled: canceledSvg, + icon_status_created: createdSvg, + icon_status_failed: failedSvg, + icon_status_manual: manualSvg, + icon_status_pending: pendingSvg, + icon_status_running: runningSvg, + icon_status_skipped: skippedSvg, + icon_status_success: successSvg, + icon_status_warning: warningSvg, +}; + +export const statusClassToBorderlessSvgMap = { + icon_status_canceled: canceledBorderlessSvg, + icon_status_created: createdBorderlessSvg, + icon_status_failed: failedBorderlessSvg, + icon_status_manual: manualBorderlessSvg, + icon_status_pending: pendingBorderlessSvg, + icon_status_running: runningBorderlessSvg, + icon_status_skipped: skippedBorderlessSvg, + icon_status_success: successBorderlessSvg, + icon_status_warning: warningBorderlessSvg, +}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 5bb7e8caec1..d2ec1791d2b 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -47,3 +47,4 @@ @import "framework/emoji-sprites.scss"; @import "framework/icons.scss"; @import "framework/snippets.scss"; +@import "framework/memory_graph.scss"; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1a6f36d032d..57387b913dc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -92,7 +92,8 @@ hr { .item-title { font-weight: 600; } /** FLASH message **/ -.author_link { +.author_link, +.author-link { color: $gl-link-color; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1dd0e5ab581..f8674b763c8 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,13 +4,14 @@ */ .file-holder { border: 1px solid $border-color; + border-radius: $border-radius-default; &.file-holder-no-border { border: 0; } &.readme-holder { - margin: $gl-padding-top 0; + margin: $gl-padding 0; } table { @@ -25,7 +26,7 @@ text-align: left; padding: 10px $gl-padding; word-wrap: break-word; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; &.file-title-clear { padding-left: 0; @@ -94,9 +95,16 @@ tr { border-bottom: 1px solid $blame-border; + + &:last-child { + border-bottom: none; + } } td { + border-top: none; + border-bottom: none; + &:first-child { border-left: none; } @@ -107,7 +115,7 @@ } td.blame-commit { - padding: 0 10px; + padding: 5px 10px; min-width: 400px; background: $gray-light; } @@ -246,7 +254,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; .file-header-content { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab8..1b7d4e42258 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,4 +1,5 @@ -.ci-status-icon-success { +.ci-status-icon-success, +.ci-status-icon-passed { color: $green-500; svg { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss new file mode 100644 index 00000000000..81cdf6b59e4 --- /dev/null +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -0,0 +1,22 @@ +.memory-graph-container { + svg { + background: $white-light; + cursor: pointer; + + &:hover { + box-shadow: 0 0 4px $gray-darkest inset; + } + } + + path { + fill: none; + stroke: $blue-500; + stroke-width: 2px; + } + + circle { + stroke: $blue-700; + fill: $blue-700; + stroke-width: 4px; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 08bcb582613..17a4e8fd83e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -111,6 +111,7 @@ $gl-link-hover-color: $blue-800; $gl-grayish-blue: #7f8fa4; $gl-gray: $gl-text-color; $gl-gray-dark: #313236; +$gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; $gl-header-nav-hover-color: #434343; $placeholder-text-color: rgba(0, 0, 0, .42); @@ -162,7 +163,7 @@ $fixed-layout-width: 1280px; $limited-layout-width: 990px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; -$border-radius-default: 2px; +$border-radius-default: 3px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 9e3142c8aa3..c8996753809 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -163,7 +163,6 @@ .avatar-cell { width: 46px; - padding-left: 10px; img { margin-right: 0; @@ -175,7 +174,6 @@ justify-content: space-between; align-items: flex-start; flex-grow: 1; - padding-left: 10px; .merge-request-branches & { flex-direction: column; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 77f2638683a..0d5c4aed971 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,39 +1,12 @@ // Common .diff-file { - border: 1px solid $border-color; margin-bottom: $gl-padding; - border-radius: 3px; .commit-short-id { font-family: $regular_font; font-weight: 400; } - .diff-header { - position: relative; - background: $gray-light; - border-bottom: 1px solid $border-color; - padding: 10px 16px; - color: $gl-text-color; - z-index: 10; - border-radius: 3px 3px 0 0; - - .diff-title { - font-family: $monospace_font; - word-break: break-all; - display: block; - - .file-mode { - color: $file-mode-changed; - } - } - - .commit-short-id { - font-family: $monospace_font; - font-size: smaller; - } - } - .file-title, .file-title-flex-parent { cursor: pointer; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 72660113e3c..75c57e369e7 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -37,12 +37,6 @@ @include btn-red; } } - - .dropdown-toggle { - .fa { - color: inherit; - } - } } .accept-control { @@ -88,13 +82,13 @@ } } - .ci_widget { - border-bottom: 1px solid $well-inner-border; + .ci-widget { color: $gl-text-color; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; + padding: $gl-padding-top $gl-padding 0; i, svg { @@ -115,16 +109,15 @@ flex-wrap: wrap; } - .ci-status-icon > .icon-link > svg { + .ci-status-icon > .icon-link svg { width: 22px; height: 22px; } } .mr-widget-body, - .ci_widget, .mr-widget-footer { - padding: 16px; + margin: 16px; } .mr-widget-pipeline-graph { @@ -138,12 +131,6 @@ line-height: 16px; } - @media (min-width: $screen-sm-min) { - .stage-cell { - padding: 0 4px; - } - } - @media (max-width: $screen-xs-max) { order: 1; margin-top: $gl-padding-top; @@ -166,12 +153,40 @@ .normal { color: $gl-text-color; + font-size: 15px; + } + + .capitalize { + text-transform: capitalize; } .js-deployment-link { display: inline-block; } + .mr-widget-help { + margin: $gl-padding; + color: $ci-skipped-color; + } + + .mr-info-list { + + &.mr-links { + margin-left: 28px; + } + + &.mr-memory-usage { + margin: 5px 0 10px 25px; + } + } + + .mr-widget-heading, + .mr-widget-body { + .btn-default.btn-xs { + margin-left: 5px; + } + } + .mr-widget-body { h4 { font-weight: 600; @@ -182,6 +197,10 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } + + time { + font-weight: normal; + } } .btn-grouped { @@ -189,6 +208,80 @@ margin-right: 7px; } + label { + font-weight: normal; + } + + .spacing { + margin: 0 $gl-padding; + } + + .bold { + margin-left: 5px; + font-weight: bold; + color: $gl-gray-light; + } + + .state-label { + font-size: 16px; + font-weight: bold; + padding-right: 10px; + } + + .danger { + color: $gl-danger; + } + + .mr-widget-help { + margin: $gl-padding 0; + } + + .with-button { + position: relative; + top: 6px; + margin-bottom: 24px; + } + + .dropdown-menu { + li a { + padding: 5px; + } + + .merge-opt-icon, + .merge-opt-title { + display: inline-block; + float: left; + } + + .merge-opt-icon svg { + height: 15px; + width: 15px; + } + + .merge-opt-title { + margin-left: 8px; + } + } + + .dropdown-toggle { + .fa { + color: inherit; + } + } + + .has-error-message + .has-custom-error { + margin-left: 0; + } + + .has-custom-error { + display: inline-block; + margin-left: 70px; + } + + .merge-error-text { + margin-left: 70px; + } + @media (max-width: $screen-xs-max) { h4 { font-size: 14px; @@ -220,6 +313,17 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } + + &.mr-state-locked .mr-info-list { + margin-top: 10px; + margin-left: 12px; + } } .mr-widget-footer { @@ -263,6 +367,24 @@ font-size: 90%; margin: 0 3px; word-break: break-all; + + &.label-truncated { + position: relative; + display: inline-block; + width: 250px; + margin-bottom: -3px; + white-space: nowrap; + text-overflow: clip; + line-height: 14px; + + &::after { + position: absolute; + content: '...'; + right: 0; + font-family: $regular_font; + background-color: $gray-light; + } + } } .commits-empty { @@ -343,61 +465,79 @@ } } -.remove-message-pipes { - ul { - margin: 10px 0 0 12px; - padding: 0; - list-style: none; - border-left: 2px solid $border-color; - display: inline-block; - } +.mr-info-list { + position: relative; + margin: 10px 0 $gl-padding 12px; - li { + p { + margin: 6px 0; position: relative; - margin: 0; - padding: 0; - display: block; + padding-left: 15px; + + &::before { + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 8px; + width: 8px; + left: 0; + } + + &:last-child { + margin-bottom: 0; - span { - margin-left: 15px; - max-height: 20px; + &::before { + top: 14px; + } } } - li::before { - content: ''; + .legend { + height: 100%; + width: 2px; + background: $border-color; position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; + top: -5px; } +} - li:last-child { - &::before { - top: 18px; +.mr-info-list.mr-memory-usage { + .legend { + height: 65%; + top: 0; + + @media (max-width: $screen-xs-max) { + height: 20px; } + } - span { - display: block; - position: relative; - top: 5px; - margin-top: 5px; + p { + float: left; + padding-left: 20px; + + &::before { + top: 13px; } } + + .memory-graph-container { + float: left; + margin-left: 5px; + } } .mr-source-target { background-color: $gray-light; - line-height: 31px; - border-style: solid; - border-width: 1px; - border-color: $border-color; - border-top-right-radius: 3px; - border-top-left-radius: 3px; - border-bottom: none; - padding: 16px; - margin-bottom: -1px; + border-radius: 3px 3px 0 0; + border-bottom: 1px solid $border-color; + padding: 0 $gl-padding; + margin-bottom: 6px; + line-height: 44px; + + .dropdown-toggle .fa { + color: $gl-text-color; + } } .panel-new-merge-request { @@ -587,3 +727,22 @@ } } } + +.mr-memory-usage { + p.usage-info-loading, + p.usage-info-unavailable, + p.usage-info-failed { + margin-bottom: 5px; + } + + p.usage-info-loading .usage-info-load-spinner { + margin-right: 10px; + font-size: 16px; + } + + @media (max-width: $screen-md-min) { + .mr-info-list.mr-memory-usage .legend { + height: 80%; + } + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 69c328d09ff..6d7b7031c30 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -284,10 +284,6 @@ ul.notes { } } - .diff-header > span { - margin-right: 10px; - } - .line_content { white-space: pre-wrap; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index eaf3dd49567..5ad0fc9082a 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -257,7 +257,7 @@ .stage-cell { font-size: 0; - padding: 10px 4px; + padding: 0 4px; > .stage-container > div > button > span > svg, > .stage-container > button > svg { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 03c75ce61f5..ab63225147f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -138,11 +138,12 @@ .blob-commit-info { list-style: none; - background: $gray-light; - padding: 16px 16px 16px 6px; - border: 1px solid $border-color; - border-bottom: none; margin: 0; + padding: 0; +} + +.blob-content-holder { + margin-top: $gl-padding; } .blob-upload-dropzone-previews { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 65a1f640a76..8ce9150e4a9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -100,7 +100,10 @@ class ApplicationController < ActionController::Base end def access_denied! - render "errors/access_denied", layout: "errors", status: 404 + respond_to do |format| + format.json { head :not_found } + format.any { render "errors/access_denied", layout: "errors", status: 404 } + end end def git_not_found! diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 6df2c068745..650ec1e326a 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -47,7 +47,7 @@ module IssuableCollections end def merge_requests_collection - merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace) end def issues_finder diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 59247280559..d8ed470e461 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -84,6 +84,7 @@ class Projects::BranchesController < Projects::ApplicationController end format.js { render nothing: true, status: result[:return_code] } + format.json { render json: { message: result[:message] }, status: result[:return_code] } end end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 0fd35bcb790..dfaaea71b9c 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -91,7 +91,7 @@ class Projects::BuildsController < Projects::ApplicationController def status render json: BuildSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@build) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 2b5f0383ac1..7c3cce1c241 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -39,7 +39,7 @@ class Projects::CommitController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index c319671456d..b33c0b00ad9 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -10,8 +10,22 @@ class Projects::DeploymentsController < Projects::ApplicationController .represent_concise(deployments) } end + def metrics + @metrics = deployment.metrics(1.hour) + + if @metrics&.any? + render json: @metrics, status: :ok + else + head :no_content + end + end + private + def deployment + @deployment ||= environment.deployments.find_by(iid: params[:id]) + end + def environment @environment ||= project.environments.find(params[:environment_id]) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fa37963dfd4..fd57afbd05f 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -17,7 +17,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .within_folders .represent(@environments), @@ -37,7 +37,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@environments), available_count: folder_environments.available.count, @@ -81,10 +81,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController stop_action = @environment.stop_with_action!(current_user) - if stop_action - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) - else - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + action_or_env_url = + if stop_action + polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) + else + namespace_project_environment_url(project.namespace, project, @environment) + end + + respond_to do |format| + format.html { redirect_to action_or_env_url } + format.json { render json: { redirect_url: action_or_env_url } } end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index bcd23d61519..58d41e0478d 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -227,7 +227,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue # The Sortable default scope causes performance issues when used with find_by - @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old + @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! end alias_method :subscribable_resource, :issue alias_method :issuable, :issue @@ -266,21 +266,6 @@ class Projects::IssuesController < Projects::ApplicationController end end - # Since iids are implemented only in 6.1 - # user may navigate to issue page using old global ids. - # - # To prevent 404 errors we provide a redirect to correct iids until 7.0 release - # - def redirect_old - issue = @project.issues.find_by(id: params[:id]) - - if issue - redirect_to issue_path(issue) - else - raise ActiveRecord::RecordNotFound.new - end - end - def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a63b7ff0bed..207fbad7856 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,11 +10,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check, - :ci_status, :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues + :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] @@ -74,10 +73,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController def show respond_to do |format| - format.html { define_discussion_vars } + format.html do + define_discussion_vars + end format.json do - render json: MergeRequestSerializer.new.represent(@merge_request) + render json: serializer.represent(@merge_request, basic: params[:basic]) end format.patch do @@ -214,7 +215,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -230,7 +231,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -299,17 +300,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def remove_wip - MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) + @merge_request = MergeRequests::UpdateService + .new(project, current_user, wip_event: 'unwip') + .execute(@merge_request) - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + render json: serializer.represent(@merge_request) end def merge_check @merge_request.check_if_can_be_merged - @pipelines = @merge_request.all_pipelines - render partial: "projects/merge_requests/widget/show.html.haml", layout: false + render json: serializer.represent(@merge_request) + end + + def commit_change_content + render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end def cancel_merge_when_pipeline_succeeds @@ -320,65 +325,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) + + render json: serializer.represent(@merge_request) end def merge return access_denied! unless @merge_request.can_be_merged_by?(current_user) - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have - # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) - @status = :failed - return - end - - if params[:sha] != @merge_request.diff_head_sha - @status = :sha_mismatch - return - end - - @merge_request.update(merge_error: nil) + status = merge! - if params[:merge_when_pipeline_succeeds].present? - unless @merge_request.head_pipeline - @status = :failed - return - end - - if @merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - @status = :merge_when_pipeline_succeeds - elsif @merge_request.head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success - else - @status = :failed - end + if @merge_request.merge_error + render json: { status: status, merge_error: @merge_request.merge_error } else - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success + render json: { status: status } end end - def merge_widget_refresh - @status = - if merge_request.merge_when_pipeline_succeeds - :merge_when_pipeline_succeeds - else - # Only MRs that can be merged end in this action - # MR can be already picked up for merge / merged already or can be waiting for worker to be picked up - # in last case it does not have any special status. Possible error is handled inside widget js function - :success - end - - render 'merge' - end - def branch_from # This is always source @source_project = @merge_request.nil? ? @project : @merge_request.source_project @@ -428,37 +390,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def ci_status - pipeline = @merge_request.head_pipeline - @pipelines = @merge_request.all_pipelines - - if pipeline - status = pipeline.status - coverage = pipeline.coverage - - status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? - - status ||= "preparing" - else - ci_service = @merge_request.source_project.try(:ci_service) - status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service - end - - response = { - title: merge_request.title, - sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha), - status: status, - coverage: coverage, - pipeline: pipeline.try(:id), - has_ci: @merge_request.has_ci? - } - - render json: response - end - def pipeline_status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@merge_request.head_pipeline) end @@ -474,10 +408,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController stop_namespace_project_environment_path(project.namespace, project, environment) end + metrics_url = + if can?(current_user, :read_environment, environment) && environment.has_metrics? + metrics_namespace_project_environment_deployment_path(environment.project.namespace, + environment.project, + environment, + deployment) + end + { id: environment.id, name: environment.name, url: namespace_project_environment_path(project.namespace, project, environment), + metrics_url: metrics_url, stop_url: stop_url, external_url: environment.external_url, external_url_formatted: environment.formatted_external_url, @@ -555,10 +498,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end - def define_widget_vars - @pipeline = @merge_request.head_pipeline - end - def define_commit_vars @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit @@ -694,4 +633,46 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.close end end + + private + + def merge! + # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # to wait until CI completes to know + unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + return :failed + end + + return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha + + @merge_request.update(merge_error: nil) + + if params[:merge_when_pipeline_succeeds].present? + return :failed unless @merge_request.head_pipeline + + if @merge_request.head_pipeline.active? + MergeRequests::MergeWhenPipelineSucceedsService + .new(@project, current_user, merge_params) + .execute(@merge_request) + + :merge_when_pipeline_succeeds + elsif @merge_request.head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + else + :failed + end + else + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + end + end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 5cb2e428201..7fe3c3c116c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -37,7 +37,7 @@ class Projects::PipelinesController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@pipelines), count: { @@ -74,7 +74,7 @@ class Projects::PipelinesController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipeline, grouped: true) end end @@ -94,7 +94,7 @@ class Projects::PipelinesController < Projects::ApplicationController def status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@pipeline) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6d6bcbaf88a..97cf4863ddc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,7 +77,7 @@ module ApplicationHelper end if user - user.avatar_url(size) || default_avatar + user.avatar_url(size: size) || default_avatar else gravatar_icon(user_or_email, size, scale) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index af430270ae4..eb37f2e0267 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -18,7 +18,7 @@ module BlobHelper blob = options.delete(:blob) blob ||= project.repository.blob_at(ref, path) rescue nil - return unless blob + return unless blob && blob.readable_text? common_classes = "btn js-edit-blob #{options[:extra_class]}" diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index cef624430da..97b497a0fed 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -100,17 +100,15 @@ module CommitsHelper end def link_to_browse_code(project, commit) + return unless current_controller?(:projects, :commits) + 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) + elsif @repo.blob_at(commit.id, @path) return link_to( "Browse File", namespace_project_blob_path(project.namespace, project, diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 7656929efe7..fbbce6876c2 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,10 @@ module IssuablesHelper when Issue IssueSerializer.new.represent(issuable).to_json when MergeRequest - MergeRequestSerializer.new.represent(issuable).to_json + MergeRequestSerializer + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 2614cdfe90e..23e55539f0a 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -19,14 +19,6 @@ module MergeRequestsHelper } end - def mr_widget_refresh_url(mr) - if mr && mr.target_project - merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr) - else - '' - end - end - def mr_css_classes(mr) classes = "merge-request" classes << " closed" if mr.closed? @@ -55,23 +47,6 @@ module MergeRequestsHelper end end - def issues_sentence(issues) - # Issuable sorter will sort local issues, then issues from the same - # namespace, then all other issues. - issues = Gitlab::IssuableSorter.sort(@project, issues).map do |issue| - issue.to_reference(@project) - end - issues.to_sentence - end - - def mr_closes_issues - @mr_closes_issues ||= @merge_request.closes_issues(current_user) - end - - def mr_issues_mentioned_but_not_closing - @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user) - end - def mr_change_branches_path(merge_request) new_namespace_project_merge_request_path( @project.namespace, @project, @@ -85,35 +60,6 @@ module MergeRequestsHelper ) end - def mr_assign_issues_link - issues = MergeRequests::AssignIssuesService.new(@project, - current_user, - merge_request: @merge_request, - closes_issues: mr_closes_issues - ).assignable_issues - path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - if issues.present? - pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" - link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post - end - end - - def source_branch_with_namespace(merge_request) - namespace = merge_request.source_project_namespace - branch = merge_request.source_branch - - if merge_request.source_branch_exists? - namespace = link_to(namespace, project_path(merge_request.source_project)) - branch = link_to(branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch)) - end - - if merge_request.for_fork? - namespace + ":" + branch - else - branch - end - end - def format_mr_branch_names(merge_request) source_path = merge_request.source_project_path target_path = merge_request.target_project_path diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index a91e3da309c..e0d3e9b88f3 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -81,7 +81,7 @@ module TreeHelper part_path = "" parts = @path.split('/') - yield('..', nil) if parts.count > max_links + yield('..', File.join(*parts.first(parts.count - 2))) if parts.count > max_links parts.each do |part| part_path = File.join(part_path, part) unless part_path.empty? diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index db994b861e5..81c30b0e077 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -18,6 +18,10 @@ module Ci has_many :builds, foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id + # Merge requests for which the current pipeline is running against + # the merge request's latest commit. + has_many :merge_requests, foreign_key: "head_pipeline_id" + has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' @@ -381,14 +385,6 @@ module Ci project.execute_services(data, :pipeline_hooks) end - # Merge requests for which the current pipeline is running against - # the merge request's latest commit. - def merge_requests - @merge_requests ||= project.merge_requests - .where(source_branch: self.ref) - .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id } - end - # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= project.merge_requests.where(source_branch: ref) diff --git a/app/models/commit.rb b/app/models/commit.rb index 9359b323ed4..dea18bfedef 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -336,6 +336,8 @@ class Commit end end + delegate :deltas, to: :raw, prefix: :raw + def diffs(diff_options = nil) Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) end @@ -373,7 +375,7 @@ class Commit def repo_changes changes = { added: [], modified: [], removed: [] } - raw_diffs(deltas_only: true).each do |diff| + raw_deltas.each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb new file mode 100644 index 00000000000..8fbfed11bdf --- /dev/null +++ b/app/models/concerns/avatarable.rb @@ -0,0 +1,18 @@ +module Avatarable + extend ActiveSupport::Concern + + def avatar_path(only_path: true) + return unless self[:avatar].present? + + # If only_path is true then use the relative path of avatar. + # Otherwise use full path (including host). + asset_host = ActionController::Base.asset_host + gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url + + # If asset_host is set then it is expected that assets are handled by a standalone host. + # That means we do not want to get GitLab's relative_url_root option anymore. + host = asset_host.present? ? asset_host : gitlab_host + + [host, avatar.url].join + end +end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7e56e371b27..6eddeab515e 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -78,6 +78,8 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def referenced_mentionables(current_user = self.author) + return [] unless matches_cross_reference_regex? + refs = all_references(current_user) refs = (refs.issues + refs.merge_requests + refs.commits) @@ -87,6 +89,20 @@ module Mentionable refs.reject { |ref| ref == local_reference } end + # Uses regex to quickly determine if mentionables might be referenced + # Allows heavy processing to be skipped + def matches_cross_reference_regex? + reference_pattern = if !project || project.default_issues_tracker? + ReferenceRegexes::DEFAULT_PATTERN + else + ReferenceRegexes::EXTERNAL_PATTERN + end + + self.class.mentionable_attrs.any? do |attr, _| + __send__(attr) =~ reference_pattern + end + end + # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+. def create_cross_references!(author = self.author, without = []) refs = referenced_mentionables(author) diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb new file mode 100644 index 00000000000..1848230ec7e --- /dev/null +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -0,0 +1,22 @@ +module Mentionable + module ReferenceRegexes + def self.reference_pattern(link_patterns, issue_pattern) + Regexp.union(link_patterns, + issue_pattern, + Commit.reference_pattern, + MergeRequest.reference_pattern) + end + + DEFAULT_PATTERN = begin + issue_pattern = Issue.reference_pattern + link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern)) + reference_pattern(link_patterns, issue_pattern) + end + + EXTERNAL_PATTERN = begin + issue_pattern = ExternalIssue.reference_pattern + link_patterns = URI.regexp(%w(http https)) + reference_pattern(link_patterns, issue_pattern) + end + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 37adfb4de73..f83d9e8edee 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -99,6 +99,21 @@ class Deployment < ActiveRecord::Base created_at.to_time.in_time_zone.to_s(:medium) end + def has_metrics? + project.monitoring_service.present? + end + + def metrics(timeframe) + return {} unless has_metrics? + + half_timeframe = timeframe / 2 + timeframe_start = created_at - half_timeframe + timeframe_end = created_at + half_timeframe + + metrics = project.monitoring_service.metrics(environment, timeframe_start: timeframe_start, timeframe_end: timeframe_end) + metrics&.merge(deployment_time: created_at.to_i) || {} + end + private def ref_path diff --git a/app/models/group.rb b/app/models/group.rb index cbc10b00cf5..6aab477f431 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,6 +4,7 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable + include Avatarable include Referable include SelectForProjectAuthorization @@ -111,10 +112,10 @@ class Group < Namespace allowed_by_projects end - def avatar_url(size = nil) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) end def lfs_enabled? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 35231bab12e..59736f70f24 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -13,6 +13,8 @@ class MergeRequest < ActiveRecord::Base has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') } + belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline" + has_many :events, as: :target, dependent: :destroy has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all @@ -829,12 +831,6 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def head_pipeline - return unless diff_head_sha && source_project - - @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha) - end - def all_pipelines return Ci::Pipeline.none unless source_project @@ -864,7 +860,7 @@ class MergeRequest < ActiveRecord::Base end def can_be_cherry_picked? - merge_commit + merge_commit.present? end def has_complete_diff_refs? @@ -908,6 +904,8 @@ class MergeRequest < ActiveRecord::Base end def conflicts_can_be_resolved_by?(user) + return false unless source_project + access = ::Gitlab::UserAccess.new(user, project: source_project) access.can_push_to_branch?(source_branch) end diff --git a/app/models/project.rb b/app/models/project.rb index a0413b4e651..1f550cc02e2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ class Project < ActiveRecord::Base include Gitlab::VisibilityLevel include Gitlab::CurrentSettings include AccessRequestable + include Avatarable include CacheMarkdownField include Referable include Sortable @@ -798,12 +799,10 @@ class Project < ActiveRecord::Base repository.avatar end - def avatar_url - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - elsif avatar_in_git - Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || (Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) if avatar_in_git) end # For compatibility with old code diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index fa782c6fbb7..6464bf3f4a4 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -150,7 +150,7 @@ class ChatNotificationService < Service def notify_for_ref?(data) return true if data[:object_attributes][:tag] - return true unless notify_only_default_branch + return true unless notify_only_default_branch? data[:object_attributes][:ref] == project.default_branch end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index ea585721e8f..59776552540 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -10,7 +10,7 @@ class MonitoringService < Service end # Environments have a number of metrics - def metrics(environment) + def metrics(environment, timeframe_start: nil, timeframe_end: nil) raise NotImplementedError end end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6854d2243d7..6a4479c4dbc 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -1,7 +1,6 @@ class PrometheusService < MonitoringService - include ReactiveCaching + include ReactiveService - self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.seconds self.reactive_cache_lifetime = 1.minute @@ -64,16 +63,22 @@ class PrometheusService < MonitoringService { success: false, result: err } end - def metrics(environment) - with_reactive_cache(environment.slug) do |data| + def metrics(environment, timeframe_start: nil, timeframe_end: nil) + with_reactive_cache(environment.slug, timeframe_start, timeframe_end) do |data| data end end # Cache metrics for specific environment - def calculate_reactive_cache(environment_slug) + def calculate_reactive_cache(environment_slug, timeframe_start, timeframe_end) return unless active? && project && !project.pending_delete? + timeframe_start = Time.parse(timeframe_start) if timeframe_start + timeframe_end = Time.parse(timeframe_end) if timeframe_end + + timeframe_start ||= 8.hours.ago + timeframe_end ||= Time.now + memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} @@ -81,11 +86,13 @@ class PrometheusService < MonitoringService success: true, metrics: { # Average Memory used in MB - memory_values: client.query_range(memory_query, start: 8.hours.ago), - memory_current: client.query(memory_query), + memory_values: client.query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_current: client.query(memory_query, time: timeframe_end), + memory_previous: client.query(memory_query, time: timeframe_start), # Average CPU Utilization - cpu_values: client.query_range(cpu_query, start: 8.hours.ago), - cpu_current: client.query(cpu_query) + cpu_values: client.query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_current: client.query(cpu_query, time: timeframe_end), + cpu_previous: client.query(cpu_query, time: timeframe_start) }, last_update: Time.now.utc } diff --git a/app/models/user.rb b/app/models/user.rb index accaa91b805..f713a20233c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ActiveRecord::Base include Gitlab::ConfigHelper include Gitlab::CurrentSettings + include Avatarable include Referable include Sortable include CaseSensitivity @@ -40,6 +41,17 @@ class User < ActiveRecord::Base devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + # Override Devise::Models::Trackable#update_tracked_fields! + # to limit database writes to at most once every hour + def update_tracked_fields!(request) + update_tracked_fields(request) + + lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + save(validate: false) + end + attr_accessor :force_random_password # Virtual attribute for authenticating by either username or email @@ -773,12 +785,10 @@ class User < ActiveRecord::Base email.start_with?('temp-email-for-oauth') end - def avatar_url(size = nil, scale = 2) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - else - GravatarService.new.execute(email, size, scale) - end + def avatar_url(size: nil, scale: 2, **args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || GravatarService.new.execute(email, size, scale) end def all_emails @@ -1000,6 +1010,15 @@ class User < ActiveRecord::Base devise_mailer.send(notification, self, *args).deliver_later end + # This works around a bug in Devise 4.2.0 that erroneously causes a user to + # be considered active in MySQL specs due to a sub-second comparison + # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709 + def confirmation_period_valid? + return false if self.class.allow_unconfirmed_access_for == 0.days + + super + end + def ensure_external_user_rights return unless external? diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb new file mode 100644 index 00000000000..255f63db5c2 --- /dev/null +++ b/app/presenters/merge_request_presenter.rb @@ -0,0 +1,168 @@ +class MergeRequestPresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + include GitlabRoutingHelper + include MarkupHelper + include TreeHelper + + presents :merge_request + + def ci_status + if pipeline + status = pipeline.status + status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? + + status || "preparing" + else + ci_service = source_project.try(:ci_service) + ci_service&.commit_status(diff_head_sha, source_branch) + end + end + + def cancel_merge_when_pipeline_succeeds_path + if can_cancel_merge_when_pipeline_succeeds?(current_user) + cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path( + project.namespace, + project, + merge_request) + end + end + + def create_issue_to_resolve_discussions_path + if can?(current_user, :create_issue, project) && project.issues_enabled? + new_namespace_project_issue_path(project.namespace, + project, + merge_request_to_resolve_discussions_of: iid) + end + end + + def remove_wip_path + if can?(current_user, :update_merge_request, merge_request.project) + remove_wip_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def merge_path + if can_be_merged_by?(current_user) + merge_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def revert_in_fork_path + if user_can_fork_project? && can_be_reverted?(current_user) + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(merge_request.project.namespace, merge_request.project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def cherry_pick_in_fork_path + if user_can_fork_project? && can_be_cherry_picked? + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to revert this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(project.namespace, project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def conflict_resolution_path + if conflicts_can_be_resolved_in_ui? && conflicts_can_be_resolved_by?(current_user) + conflicts_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def target_branch_commits_path + if target_branch_exists? + namespace_project_commits_path(project.namespace, project, target_branch) + end + end + + def source_branch_path + if source_branch_exists? + namespace_project_branch_path(source_project.namespace, source_project, source_branch) + end + end + + def source_branch_with_namespace_link + namespace = source_project_namespace + branch = source_branch + + if source_branch_exists? + namespace = link_to(namespace, project_path(source_project)) + branch = link_to(branch, namespace_project_commits_path(source_project.namespace, source_project, source_branch)) + end + + if for_fork? + namespace + ":" + branch + else + branch + end + end + + def closing_issues_links + markdown issues_sentence(project, closing_issues), pipeline: :gfm, author: author, project: project + end + + def mentioned_issues_links + mentioned_issues = issues_mentioned_but_not_closing(current_user) + markdown issues_sentence(project, mentioned_issues), pipeline: :gfm, author: author, project: project + end + + def assign_to_closing_issues_link + issues = MergeRequests::AssignIssuesService.new(project, + current_user, + merge_request: merge_request, + closes_issues: closing_issues + ).assignable_issues + path = assign_related_issues_namespace_project_merge_request_path(project.namespace, project, merge_request) + if issues.present? + pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" + link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post + end + end + + def can_revert_on_current_merge_request? + user_can_collaborate_with_project? && can_be_reverted?(current_user) + end + + def can_cherry_pick_on_current_merge_request? + user_can_collaborate_with_project? && can_be_cherry_picked? + end + + private + + def closing_issues + @closing_issues ||= closes_issues(current_user) + end + + def pipeline + @pipeline ||= head_pipeline + end + + def issues_sentence(project, issues) + # Sorting based on the `#123` or `group/project#123` reference will sort + # local issues first. + issues.map do |issue| + issue.to_reference(project) + end.sort.to_sentence + end + + def user_can_collaborate_with_project? + can?(current_user, :push_code, project) || + (current_user && current_user.already_forked?(project)) + end + + def user_can_fork_project? + can?(current_user, :fork_project, project) + end +end diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 311ee9c96be..4e6c15f673b 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -3,8 +3,10 @@ class BaseSerializer @request = EntityRequest.new(parameters) end - def represent(resource, opts = {}) - self.class.entity_class + def represent(resource, opts = {}, entity_class = nil) + entity_class = entity_class || self.class.entity_class + + entity_class .represent(resource, opts.merge(request: @request)) .as_json end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 75dda1af709..5e99204c658 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 1380b347d8e..e2276808b90 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -26,11 +26,11 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end def detailed_status - build.detailed_status(request.user) + build.detailed_status(request.current_user) end def path_to(route, build) diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4ff15a78115..4e8a3c67b21 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -31,7 +31,7 @@ class EnvironmentEntity < Grape::Entity end expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment| - can?(request.user, :admin_environment, environment.project) && + can?(request.current_user, :admin_environment, environment.project) && terminal_namespace_project_environment_path( environment.project.namespace, environment.project, diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 00000000000..935d67a4f37 --- /dev/null +++ b/app/serializers/event_entity.rb @@ -0,0 +1,4 @@ +class EventEntity < Grape::Entity + expose :author, using: UserEntity + expose :updated_at +end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb index a4d3737429c..04487e59009 100644 --- a/app/serializers/job_group_entity.rb +++ b/app/serializers/job_group_entity.rb @@ -11,6 +11,6 @@ class JobGroupEntity < Grape::Entity alias_method :group, :object def detailed_status - group.detailed_status(request.user) + group.detailed_status(request.current_user) end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 00000000000..8771345c135 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,10 @@ +class MergeRequestBasicEntity < Grape::Entity + expose :merge_status + expose :merge_error + expose :state + expose :source_branch_exists?, as: :source_branch_exists + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent +end diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 00000000000..cc5c664c8fa --- /dev/null +++ b/app/serializers/merge_request_basic_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestBasicSerializer < BaseSerializer + entity MergeRequestBasicEntity +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 453ba52b892..a2542c54f7a 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,6 @@ class MergeRequestEntity < IssuableEntity + include RequestAwareEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at @@ -12,4 +14,174 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # Events + expose :merge_event, using: EventEntity + expose :closed_event, using: EventEntity + + # User entities + expose :author, using: UserEntity + expose :merge_user, using: UserEntity + + # Diff sha's + expose :diff_head_sha do |merge_request| + merge_request.diff_head_sha if merge_request.diff_head_commit + end + + expose :merge_commit_sha + expose :merge_commit_message + expose :head_pipeline, with: PipelineEntity, as: :pipeline + + # Booleans + expose :work_in_progress?, as: :work_in_progress + expose :source_branch_exists?, as: :source_branch_exists + expose :mergeable_discussions_state?, as: :mergeable_discussions_state + expose :branch_missing?, as: :branch_missing + expose :commits_count + expose :cannot_be_merged?, as: :has_conflicts + expose :can_be_merged?, as: :can_be_merged + + expose :project_archived do |merge_request| + merge_request.project.archived? + end + + expose :only_allow_merge_if_pipeline_succeeds do |merge_request| + merge_request.project.only_allow_merge_if_pipeline_succeeds? + end + + # CI related + expose :has_ci?, as: :has_ci + expose :ci_status do |merge_request| + presenter(merge_request).ci_status + end + + expose :issues_links do + expose :assign_to_closing do |merge_request| + presenter(merge_request).assign_to_closing_issues_link + end + + expose :closing do |merge_request| + presenter(merge_request).closing_issues_links + end + + expose :mentioned_but_not_closing do |merge_request| + presenter(merge_request).mentioned_issues_links + end + end + + expose :source_branch_with_namespace_link do |merge_request| + presenter(merge_request).source_branch_with_namespace_link + end + + expose :source_branch_path do |merge_request| + presenter(merge_request).source_branch_path + end + + expose :current_user do + expose :can_remove_source_branch do |merge_request| + merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + end + + expose :can_revert_on_current_merge_request do |merge_request| + presenter(merge_request).can_revert_on_current_merge_request? + end + + expose :can_cherry_pick_on_current_merge_request do |merge_request| + presenter(merge_request).can_cherry_pick_on_current_merge_request? + end + end + + # Paths + # + expose :target_branch_commits_path do |merge_request| + presenter(merge_request).target_branch_commits_path + end + + expose :conflict_resolution_path do |merge_request| + presenter(merge_request).conflict_resolution_path + end + + expose :remove_wip_path do |merge_request| + presenter(merge_request).remove_wip_path + end + + expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| + presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + end + + expose :create_issue_to_resolve_discussions_path do |merge_request| + presenter(merge_request).create_issue_to_resolve_discussions_path + end + + expose :merge_path do |merge_request| + presenter(merge_request).merge_path + end + + expose :cherry_pick_in_fork_path do |merge_request| + presenter(merge_request).cherry_pick_in_fork_path + end + + expose :revert_in_fork_path do |merge_request| + presenter(merge_request).revert_in_fork_path + end + + expose :email_patches_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :patch) + end + + expose :plain_diff_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :diff) + end + + expose :status_path do |merge_request| + namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :json) + end + + expose :merge_check_path do |merge_request| + merge_check_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :ci_environments_status_path do |merge_request| + ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :merge_commit_message_with_description do |merge_request| + merge_request.merge_commit_message(include_description: true) + end + + expose :diverged_commits_count do |merge_request| + if merge_request.open? && merge_request.diverged_from_target_branch? + merge_request.diverged_commits_count + else + 0 + end + end + + expose :commit_change_content_path do |merge_request| + commit_change_content_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + private + + delegate :current_user, to: :request + + def presenter(merge_request) + @presenters ||= {} + @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) + end end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa6e00dfcb4..f67034ce47a 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -1,3 +1,9 @@ class MergeRequestSerializer < BaseSerializer - entity MergeRequestEntity + # This overrided method takes care of which entity should be used + # to serialize the `merge_request` based on `basic` key in `opts` param. + # Hence, `entity` doesn't need to be declared on the class scope. + def represent(merge_request, opts = {}) + entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + super(merge_request, opts, entity) + end end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 7eb7aac72eb..51ad0a3f8ba 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity expose :id expose :user, using: UserEntity + expose :active?, as: :active + expose :coverage expose :path do |pipeline| namespace_project_pipeline_path( @@ -69,16 +71,16 @@ class PipelineEntity < Grape::Entity alias_method :pipeline, :object def can_retry? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.retryable? end def can_cancel? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.cancelable? end def detailed_status - pipeline.detailed_status(request.user) + pipeline.detailed_status(request.current_user) end end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index e7a9df8ac4e..e37af63774c 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -37,4 +37,11 @@ class PipelineSerializer < BaseSerializer data = represent(resource, { only: [{ details: [:status] }] }) data.dig(:details, :status) || {} end + + def represent_stages(resource) + return {} unless resource.present? + + data = represent(resource, { only: [{ details: [:stages] }] }) + data.dig(:details, :stages) || [] + end end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 97ced8730ed..cee0089056f 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -35,6 +35,6 @@ class StageEntity < Grape::Entity alias_method :stage, :object def detailed_status - stage.detailed_status(request.user) + stage.detailed_status(request.current_user) end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ccdda08d885..1f6c1f4a7f6 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -47,7 +47,7 @@ module Ci end Ci::Pipeline.transaction do - pipeline.save + update_merge_requests_head_pipeline if pipeline.save Ci::CreatePipelineBuildsService .new(project, current_user) @@ -118,6 +118,11 @@ module Ci origin_sha && origin_sha != Gitlab::Git::BLANK_SHA end + def update_merge_requests_head_pipeline + MergeRequest.where(source_branch: @pipeline.ref, source_project: @pipeline.project). + update_all(head_pipeline_id: @pipeline.id) + end + def error(message, save: false) pipeline.errors.add(:base, message) pipeline.drop if save diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 33edcd60944..25ba54ffa0d 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -50,7 +50,7 @@ module Ci when 'always' %w[success failed skipped] when 'manual' - %w[success] + %w[success skipped] else [] end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 45411c779cc..d22236b961b 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -67,7 +67,7 @@ class GitPushService < BaseService paths = Set.new @push_commits.each do |commit| - commit.raw_diffs(deltas_only: true).each do |diff| + commit.raw_deltas.each do |diff| paths << diff.new_path end end @@ -85,8 +85,10 @@ class GitPushService < BaseService default = is_default_branch? push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit| - ProcessCommitWorker. - perform_async(project.id, current_user.id, commit.to_hash, default) + if commit.matches_cross_reference_regex? + ProcessCommitWorker. + perform_async(project.id, current_user.id, commit.to_hash, default) + end end end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index cdcac7e4264..e4dfe0c8c08 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -35,7 +35,7 @@ = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests - %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) + %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 35885b2c7b4..ae4658d8ca1 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -3,9 +3,9 @@ = render "projects/commits/head" %div{ class: container_class } - %h3.page-title Blame view - #blob-content-holder.tree-holder + = render "projects/blob/breadcrumb", blob: @blob, blame: true + .file-holder = render "projects/blob/header", blob: @blob, blame: true diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index f04df441ccb..f4307421ed0 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -1,23 +1,10 @@ -.nav-block - .tree-ref-holder - = render 'shared/ref_switcher', destination: 'blob', path: @path += render "projects/blob/breadcrumb", blob: blob - %ul.breadcrumb.repo-breadcrumb - %li - = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - - title = truncate(title, length: 40) - %li - - if path == @path - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do - %strong= title - - else - = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) - -%ul.blob-commit-info.hidden-xs - - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) - = render blob_commit, project: @project, ref: @ref +.info-well.hidden-xs + .well-segment + %ul.blob-commit-info + - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) + = render blob_commit, project: @project, ref: @ref #blob-content-holder.blob-content-holder %article.file-holder diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml new file mode 100644 index 00000000000..3f58e8d232f --- /dev/null +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -0,0 +1,36 @@ +- blame = local_assigns.fetch(:blame, false) +.nav-block + .tree-controls + = render 'projects/find_file_link' + + .btn-group.prepend-left-10{ role: "group" }< + -# only show normal/blame view links for text files + - if blob.readable_text? + - if blame + = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), + class: 'btn' + - else + = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), + class: 'btn js-blob-blame-link' unless blob.empty? + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), + class: 'btn' + + = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, + tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' + + .tree-ref-holder + = render 'shared/ref_switcher', destination: 'blob', path: @path + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title + - else + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index cd098acda81..0be15cc179f 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -11,23 +11,7 @@ = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< - -# only show normal/blame view links for text files - - if blob.readable_text? - - if blame - = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm js-blob-blame-link' unless blob.empty? - - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, - tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - - .btn-group{ role: "group" }< - = edit_blob_link if blob.readable_text? + = edit_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml deleted file mode 100644 index 0090f7a11df..00000000000 --- a/app/views/projects/blob/_markup.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- blob.load_all_data!(@repository) - -.file-content.wiki - = markup(blob.name, blob.data) diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index e87b73c9a34..da2cef17e8a 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,4 +1,4 @@ -.diff-file +.diff-file.file-holder .diff-content - if markup?(@blob.name) .file-content.wiki diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 4cdb44325b3..be0462f91cd 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,5 @@ - page_title "Find File", @ref += render "projects/commits/head" .file-finder-holder.tree-holder.clearfix .nav-block diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 9e306d4543c..25b8567b78f 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" -- page_description @merge_request.description +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') @@ -11,42 +11,17 @@ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/show/mr_box" - .append-bottom-default.mr-source-target.prepend-top-default - - if @merge_request.open? - .pull-right - - if @merge_request.source_branch_exists? - - if koding_enabled? && @repository.koding_yml - = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do - Run in IDE (Koding) - = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do - Check out branch - - %span.dropdown.inline.prepend-left-5 - %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } - Download as - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) - %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) - .normal - %span <b>Request to merge</b> - %span.label-branch= source_branch_with_namespace(@merge_request) - %span <b>into</b> - %span.label-branch - = link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) - - if @merge_request.open? && @merge_request.diverged_from_target_branch? - %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) - if @merge_request.source_branch_exists? = render "projects/merge_requests/show/how_to_merge" - = render "projects/merge_requests/widget/show.html.haml" + :javascript + window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + + #js-vue-mr-widget.mr-widget - - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .merge-manually.light.prepend-top-default - You can also accept this merge request manually using the - = succeed '.' do - = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" + - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('vue_merge_request_widget') .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true @@ -113,9 +88,7 @@ :javascript $(function () { - new MergeRequest({ + window.mergeRequest = new MergeRequest({ action: "#{controller.action_name}" }); }); - - var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}"; diff --git a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml deleted file mode 100644 index eab5be488b5..00000000000 --- a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}"); diff --git a/app/views/projects/merge_requests/merge.js.haml b/app/views/projects/merge_requests/merge.js.haml deleted file mode 100644 index e632fc681cf..00000000000 --- a/app/views/projects/merge_requests/merge.js.haml +++ /dev/null @@ -1,14 +0,0 @@ -- case @status -- when :success - - remove_source_branch = params[:should_remove_source_branch] == '1' || @merge_request.remove_source_branch? - :plain - merge_request_widget.mergeInProgress(#{remove_source_branch}); -- when :merge_when_pipeline_succeeds - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); -- when :sha_mismatch - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); -- else - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}"); diff --git a/app/views/projects/merge_requests/widget/_closed.html.haml b/app/views/projects/merge_requests/widget/_closed.html.haml deleted file mode 100644 index 15f47ecf210..00000000000 --- a/app/views/projects/merge_requests/widget/_closed.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Closed - - if @merge_request.closed_event - by #{link_to_member(@project, @merge_request.closed_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.closed_event.created_at)} - %p - = succeed '.' do - The changes were not merged into - %span.label-branch= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml new file mode 100644 index 00000000000..ad0ce7bf501 --- /dev/null +++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml @@ -0,0 +1,4 @@ +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml deleted file mode 100644 index 1298376ac25..00000000000 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- if @pipeline - .mr-widget-heading - - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - %div{ class: "ci-status-icon ci-status-icon-#{status}" } - = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do - = ci_icon_for_status(status) - %span - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' - = ci_label_for_status(status) - - if @pipeline.stages.any? - .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph' - %span - for - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" - %span.ci-coverage - -- elsif @merge_request.has_ci? - -# Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - -# TODO, remove in later versions when services like Jenkins will set CI status via Commit status API - .mr-widget-heading - - %w[success skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: "display:none" } - = ci_icon_for_status(status) - %span - CI job - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace" - %span.ci-coverage - - .ci_widget - = icon("spinner spin") - Checking CI status for #{@merge_request.diff_head_commit.short_id}… - - .ci_widget.ci-not_found{ style: "display:none" } - = icon("times-circle") - Could not find CI status for #{@merge_request.diff_head_commit.short_id}. - - .ci_widget.ci-error{ style: "display:none" } - = icon("times-circle") - Could not connect to the CI server. Please check your settings and try again. - -.js-success-icon.hidden - = ci_icon_for_status('success') diff --git a/app/views/projects/merge_requests/widget/_locked.html.haml b/app/views/projects/merge_requests/widget/_locked.html.haml deleted file mode 100644 index 78d0783cba0..00000000000 --- a/app/views/projects/merge_requests/widget/_locked.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - = icon("spinner spin") - Merge in progress… - %p - This merge request is in the process of being merged, during which time it is locked and cannot be closed. - diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml deleted file mode 100644 index adc3bbc37f3..00000000000 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Merged - - 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[:deleted_source_branch] - .remove-message-pipes - %ul - %li - %span - 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"}. - %li - %span - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget.remove-message-pipes - %ul - %li - %span - 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"}. - %li - %span - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.remove-message-pipes.hide - %ul - %li - %span - Failed to remove source branch '#{@merge_request.source_branch}'. - .remove_source_branch_in_progress.remove-message-pipes.hide - %ul - %li - %span - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. - %li - %span - Please wait, this page will be automatically reloaded. - - else - .remove-message-pipes - %ul - %li - %span - 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"}. - = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml deleted file mode 100644 index a0f54bd28ec..00000000000 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ /dev/null @@ -1,14 +0,0 @@ -- can_remove_source_branch = local_assigns.fetch(:source_branch_exists, false) && @merge_request.can_remove_source_branch?(current_user) -- mr_can_be_reverted = @merge_request.can_be_reverted?(current_user) -- mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - -- if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .clearfix.merged-buttons - - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do - = icon('trash-o') - Remove source branch - - if mr_can_be_reverted - = revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close") - - if mr_can_be_cherry_picked - = cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default") diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml deleted file mode 100644 index 0872a1a0503..00000000000 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ /dev/null @@ -1,49 +0,0 @@ -.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.branch_missing? - = render 'projects/merge_requests/widget/open/missing_branch' - - elsif @merge_request.has_no_commits? - = render 'projects/merge_requests/widget/open/nothing' - - elsif @merge_request.unchecked? - = render 'projects/merge_requests/widget/open/check' - - 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' - - elsif @merge_request.merge_when_pipeline_succeeds? && @merge_request.merge_error.present? - = render 'projects/merge_requests/widget/open/error' - - elsif @merge_request.merge_when_pipeline_succeeds? - = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds' - - elsif !@merge_request.can_be_merged_by?(current_user) - = render 'projects/merge_requests/widget/open/not_allowed' - - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?) - = render 'projects/merge_requests/widget/open/build_failed' - - elsif !@merge_request.mergeable_discussions_state? - = render 'projects/merge_requests/widget/open/unresolved_discussions' - - elsif @pipeline&.blocked? - = render 'projects/merge_requests/widget/open/manual' - - elsif @merge_request.can_be_merged? || resolved_conflicts - = render 'projects/merge_requests/widget/open/accept' - - - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present? - .mr-widget-footer - %span - = icon('check') - - if mr_closes_issues.present? - Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} - = succeed '.' do - != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link - - if mr_issues_mentioned_but_not_closing.present? - #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed. diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml deleted file mode 100644 index c716b69b35b..00000000000 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ /dev/null @@ -1,40 +0,0 @@ -- if @merge_request.open? - = render 'projects/merge_requests/widget/open' -- elsif @merge_request.merged? - = render 'projects/merge_requests/widget/merged' -- elsif @merge_request.closed? - = render 'projects/merge_requests/widget/closed' -- elsif @merge_request.locked? - = render 'projects/merge_requests/widget/locked' - -:javascript - var opts = { - merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - check_enable: #{@merge_request.unchecked? ? "true" : "false"}, - ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - pipeline_status_url: "#{pipeline_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - gitlab_icon: "#{asset_path 'gitlab_logo.png'}", - ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}", - ci_message: { - normal: "Pipeline {{status}} for \"{{title}}\"", - preparing: "{{status}} pipeline for \"{{title}}\"" - }, - ci_enable: #{@project.ci_service ? "true" : "false"}, - ci_title: { - preparing: "{{status}} pipeline", - normal: "Pipeline {{status}}" - }, - ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}", - ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json}, - commits_path: "#{project_commits_path(@project)}", - pipeline_path: "#{project_pipelines_path(@project)}", - pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" - }; - - if (typeof merge_request_widget !== 'undefined') { - merge_request_widget.cancelPolling(); - merge_request_widget.clearEventListeners(); - } - - merge_request_widget = new window.gl.MergeRequestWidget(opts); diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml deleted file mode 100644 index 4cbd22150c7..00000000000 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -= form_for [:merge, @project.namespace.becomes(Namespace), @project, @merge_request], remote: true, method: :post, html: { class: 'accept-mr-form js-quick-submit js-requires-input' } do |f| - = hidden_field_tag :authenticity_token, form_authenticity_token - = hidden_field_tag :sha, @merge_request.diff_head_sha - .accept-merge-holder.clearfix.js-toggle-container - .clearfix - .accept-action - - if @pipeline && @pipeline.active? - %span.btn-group - = button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do - Merge when pipeline succeeds - - unless @project.only_allow_merge_if_pipeline_succeeds? - = button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do - = icon('caret-down') - %span.sr-only - Select merge moment - %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li - = link_to "#", class: "merge-when-pipeline-succeeds" do - = icon('check fw') - Merge when pipeline succeeds - %li - = link_to "#", class: "accept-merge-request" do - = icon('warning fw') - Merge immediately - - else - = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do - Accept merge request - - if @merge_request.force_remove_source_branch? - .accept-control - The source branch will be removed. - - elsif @merge_request.can_remove_source_branch?(current_user) - .accept-control.checkbox - = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do - = check_box_tag :should_remove_source_branch - Remove source branch - .accept-control - %button.modify-merge-commit-link.js-toggle-button{ type: "button" } - = icon('edit') - Modify commit message - .js-toggle-content.hide.prepend-top-default - = render 'shared/commit_message_container', params: params, - message_with_description: @merge_request.merge_commit_message(include_description: true), - message_without_description: @merge_request.merge_commit_message, - text: @merge_request.merge_commit_message, - rows: 14, hint: true - - = hidden_field_tag :merge_when_pipeline_succeeds, "", autocomplete: "off" diff --git a/app/views/projects/merge_requests/widget/open/_archived.html.haml b/app/views/projects/merge_requests/widget/open/_archived.html.haml deleted file mode 100644 index 0d61e56d8fb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_archived.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Project is archived -%p - This merge request cannot be merged because archived projects cannot be written to. diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml deleted file mode 100644 index 3979d5fa8ed..00000000000 --- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon('exclamation-triangle') - The pipeline for this merge request failed - -%p - Please retry the job or push a new commit to fix the failure. diff --git a/app/views/projects/merge_requests/widget/open/_check.html.haml b/app/views/projects/merge_requests/widget/open/_check.html.haml deleted file mode 100644 index 909dc52fc06..00000000000 --- a/app/views/projects/merge_requests/widget/open/_check.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%strong - = icon("spinner spin") - Checking ability to merge automatically… diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml deleted file mode 100644 index 621ee313026..00000000000 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- can_resolve = @merge_request.conflicts_can_be_resolved_by?(current_user) -- can_resolve_in_ui = @merge_request.conflicts_can_be_resolved_in_ui? -- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user) - -%h4.has-conflicts - %p - = icon("exclamation-triangle") - This merge request contains merge conflicts - -.remove-message-pipes - %ul - %li - %span - To merge this request, resolve these conflicts - - if can_resolve && !can_resolve_in_ui - locally - or - - unless can_merge - ask someone with write access to this repository to - merge it locally. - -- if (can_resolve && can_resolve_in_ui) || can_merge - .merged-buttons.clearfix - - if can_resolve && can_resolve_in_ui - = link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn" - - if can_merge - = link_to "Merge locally", "#modal_merge_info", class: "btn how_to_merge_link vlink", "data-toggle" => "modal" diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml deleted file mode 100644 index 9078b7e21dd..00000000000 --- a/app/views/projects/merge_requests/widget/open/_manual.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Pipeline blocked -%p - The pipeline for this merge request requires a manual action to proceed. diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml deleted file mode 100644 index 76cc1ecd8a5..00000000000 --- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml +++ /dev/null @@ -1,33 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%h4 - Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)} - to be merged automatically when the pipeline succeeds. -.remove-message-pipes - %ul - %li - %span - = succeed '.' do - The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"} - - if @merge_request.remove_source_branch? - %li - %span - The source branch will be removed. - - else - %li - %span - The source branch will not be removed. - - - remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user - - user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - - if remove_source_branch_button || user_can_cancel_automatic_merge - .clearfix.prepend-top-10 - - if remove_source_branch_button - = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do - = icon('times') - Remove source branch when merged - - - if user_can_cancel_automatic_merge - = link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do - Cancel automatic merge diff --git a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml b/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml deleted file mode 100644 index c9f07629493..00000000000 --- a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- unless @merge_request.source_branch_exists? - %h4 - = icon("exclamation-triangle") - Source branch - %span.label-branch= source_branch_with_namespace(@merge_request) - does not exist - %p - Please restore the source branch or close this merge request and open a new merge request with a different source branch. -- else - %h4 - = icon("exclamation-triangle") - Target branch - %span.label-branch= @merge_request.target_branch - does not exist - %p - Please restore the target branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml b/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml deleted file mode 100644 index 57ce1959021..00000000000 --- a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - Ready to be merged automatically -%p - Ask someone with write access to this repository to merge this request. - - if @merge_request.force_remove_source_branch? - The source branch will be removed. diff --git a/app/views/projects/merge_requests/widget/open/_nothing.html.haml b/app/views/projects/merge_requests/widget/open/_nothing.html.haml deleted file mode 100644 index 7af8c01c134..00000000000 --- a/app/views/projects/merge_requests/widget/open/_nothing.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%h4 - = icon("exclamation-triangle") - Nothing to merge from - %span.label-branch= source_branch_with_namespace(@merge_request) - into - %span.label-branch= @merge_request.target_branch -%p - Please push new commits to the source branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_reload.html.haml b/app/views/projects/merge_requests/widget/open/_reload.html.haml deleted file mode 100644 index acfc31725eb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_reload.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request failed to be merged automatically - -%p - Please reload the page to find out the reason. diff --git a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml b/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml deleted file mode 100644 index 499624f8dd8..00000000000 --- a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request has received new commits since the page was loaded. - -%p - Please reload the page to review the new commits before merging. diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml deleted file mode 100644 index ec9346ce89b..00000000000 --- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%h4 - = icon('exclamation-triangle') - This merge request has unresolved discussions - -%p - Please resolve these discussions - - if @project.issues_enabled? && can?(current_user, :create_issue, @project) - or - = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid) - to allow this merge request to be merged. diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml deleted file mode 100644 index c296422a9cf..00000000000 --- a/app/views/projects/merge_requests/widget/open/_wip.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -%h4 - This merge request is currently a Work In Progress - -- if can?(current_user, :update_merge_request, @merge_request) - %p - When this merge request is ready, - = link_to remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post do - remove the - %code WIP: - prefix from the title - to allow it to be merged. diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 2497a2d91b1..2e34803b143 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -6,16 +6,6 @@ %th Name %th.hidden-xs .pull-left Last commit - .last-commit.hidden-sm.pull-left - %i.fa.fa-angle-right - %small.light - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" - = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") - = time_ago_with_tooltip(@commit.committed_date) - \- - = @commit.full_title - %small.commit-history-link-spacer | - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link' %th.text-right Last Update - if @path.present? %tr.tree-item diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 396d1ecd77b..e4d9e24f56e 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,8 @@ .tree-controls = render 'projects/find_file_link' + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped' + = render 'projects/buttons/download', project: @project, ref: @ref .tree-ref-holder diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 910d765aed0..42700c237fc 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -7,4 +7,13 @@ = render 'projects/last_push' %div{ class: container_class } - = render 'projects/files' + #tree-holder.tree-holder.clearfix + .nav-block + = render 'projects/tree/tree_header', tree: @tree + + .info-well.hidden-xs.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: @commit, project: @project, ref: @ref + + = render 'projects/tree/tree_content', tree: @tree diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 44e624c15a7..3a66880e177 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -20,55 +20,7 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - - if issuable.instance_of?(Issue) - #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } - - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself - - .selectbox.hide-collapsed - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil - - - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - - - if issuable.instance_of?(Issue) - - if issuable.assignees.length == 0 - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil - - title = 'Select assignee' - - options[:toggle_class] += ' js-multiselect js-save-user-data' - - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" - - options[:data][:multi_select] = true - - options[:data]['dropdown-title'] = title - - options[:data]['dropdown-header'] = 'Assignee' - - options[:data]['max-select'] = 1 - - else - - title = 'Select assignee' - - = dropdown_tag(title, options: options) + = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml new file mode 100644 index 00000000000..c36a45098a8 --- /dev/null +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -0,0 +1,49 @@ +- if issuable.instance_of?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } +- else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + +.selectbox.hide-collapsed + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - if issuable.instance_of?(Issue) + - if issuable.assignees.length == 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - title = 'Select assignee' + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" + - options[:data][:multi_select] = true + - options[:data]['dropdown-title'] = title + - options[:data]['dropdown-header'] = 'Assignee' + - options[:data]['max-select'] = 1 + - else + - title = 'Select assignee' + + = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 03309722326..d23f79be2be 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,12 +5,3 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) - -.form-group - .col-sm-10.col-sm-offset-2 - - if issuable.can_remove_source_branch?(current_user) - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9281a515744..1608bd59cf1 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -11,26 +11,9 @@ %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - if issuable.is_a?(Issue) - = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder.selectbox - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } - - - if issuable.assignees.length === 0 - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } - - = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date - else - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml new file mode 100644 index 00000000000..8119f19291b --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml @@ -0,0 +1,11 @@ += form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml new file mode 100644 index 00000000000..d0ea4e149df --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml @@ -0,0 +1,8 @@ += form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index c9658b3fe17..22f67fa9e9f 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -142,10 +142,10 @@ class IrkerWorker end def files_count(commit) - diffs = commit.raw_diffs(deltas_only: true) + diff_size = commit.raw_deltas.size - files = "#{diffs.real_size} file" - files += 's' if diffs.size > 1 + files = "#{diff_size} file" + files += 's' if diff_size > 1 files end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 2f7967cf531..d6ed0e253ad 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -23,6 +23,9 @@ class ProcessCommitWorker return unless user commit = build_commit(project, commit_hash) + + return unless commit.matches_cross_reference_regex? + author = commit.author || user process_commit_message(project, commit, user, author, default) diff --git a/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml new file mode 100644 index 00000000000..1f3ab3a2c10 --- /dev/null +++ b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml @@ -0,0 +1,4 @@ +--- +title: Remove redirect for old issue url containing id instead of iid +merge_request: 11135 +author: blackst0ne diff --git a/changelogs/unreleased/document-foreign-keys.yml b/changelogs/unreleased/document-foreign-keys.yml new file mode 100644 index 00000000000..faa467e8185 --- /dev/null +++ b/changelogs/unreleased/document-foreign-keys.yml @@ -0,0 +1,4 @@ +--- +title: Add documentation about adding foreign keys +merge_request: +author: diff --git a/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml b/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml new file mode 100644 index 00000000000..d8d4c668a44 --- /dev/null +++ b/changelogs/unreleased/fix-gb-fix-skipped-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Fix skipped manual actions problem when processing the pipeline +merge_request: 11164 +author: diff --git a/changelogs/unreleased/issue_27168_2.yml b/changelogs/unreleased/issue_27168_2.yml new file mode 100644 index 00000000000..c67692493e0 --- /dev/null +++ b/changelogs/unreleased/issue_27168_2.yml @@ -0,0 +1,4 @@ +--- +title: Preloads head pipeline for merge request collection +merge_request: +author: diff --git a/changelogs/unreleased/mrchrisw-fix-slack-notify.yml b/changelogs/unreleased/mrchrisw-fix-slack-notify.yml new file mode 100644 index 00000000000..bb45a117be6 --- /dev/null +++ b/changelogs/unreleased/mrchrisw-fix-slack-notify.yml @@ -0,0 +1,4 @@ +--- +title: Fix notify_only_default_branch check for Slack service +merge_request: +author: diff --git a/changelogs/unreleased/tc-cache-trackable-attributes.yml b/changelogs/unreleased/tc-cache-trackable-attributes.yml new file mode 100644 index 00000000000..4a2cf50893a --- /dev/null +++ b/changelogs/unreleased/tc-cache-trackable-attributes.yml @@ -0,0 +1,4 @@ +--- +title: "Limit User's trackable attributes, like `current_sign_in_at`, to update at most once/hour" +merge_request: 11053 +author: diff --git a/changelogs/unreleased/up-arrow-focus-discussion-comment.yml b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml new file mode 100644 index 00000000000..5457dab6d3d --- /dev/null +++ b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml @@ -0,0 +1,4 @@ +--- +title: Fix up arrow not editing last discussion comment +merge_request: +author: diff --git a/changelogs/unreleased/use_relative_path_for_project_avatars.yml b/changelogs/unreleased/use_relative_path_for_project_avatars.yml new file mode 100644 index 00000000000..e3d0c0e1187 --- /dev/null +++ b/changelogs/unreleased/use_relative_path_for_project_avatars.yml @@ -0,0 +1,4 @@ +--- +title: Use relative paths for group/project/user avatars +merge_request: 11001 +author: blackst0ne diff --git a/changelogs/unreleased/winh-german-cycle-analytics.yml b/changelogs/unreleased/winh-german-cycle-analytics.yml new file mode 100644 index 00000000000..14b2d672bd0 --- /dev/null +++ b/changelogs/unreleased/winh-german-cycle-analytics.yml @@ -0,0 +1,4 @@ +--- +title: Add German translation for Cycle Analytics +merge_request: 11161 +author: diff --git a/config/application.rb b/config/application.rb index 32ad2393648..bf3fb7a18c1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,7 +22,6 @@ module Gitlab # This is a nice reference article on autoloading/eager loading: # http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload config.eager_load_paths.push(*%W(#{config.root}/lib - #{config.root}/app/models/ci #{config.root}/app/models/hooks #{config.root}/app/models/members #{config.root}/app/models/project_services diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index 700ca25b884..4ff9019c43c 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -30,7 +30,7 @@ Doorkeeper::OpenidConnect.configure do o.claim(:email_verified) { |user| true if user.public_email? } o.claim(:website) { |user| user.full_website_url if user.website_url? } o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user } - o.claim(:picture) { |user| user.avatar_url } + o.claim(:picture) { |user| user.avatar_url(only_path: false) } end end end diff --git a/config/routes/project.rb b/config/routes/project.rb index 7f6e5447b19..a6c104c2d3f 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -75,10 +75,9 @@ constraints(ProjectUrlConstrainer.new) do get :conflict_for_path get :pipelines get :merge_check + get :commit_change_content post :merge - get :merge_widget_refresh post :cancel_merge_when_pipeline_succeeds - get :ci_status get :pipeline_status get :ci_environments_status post :toggle_subscription @@ -146,7 +145,11 @@ constraints(ProjectUrlConstrainer.new) do get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ } end - resources :deployments, only: [:index] + resources :deployments, only: [:index] do + member do + get :metrics + end + end end resource :cycle_analytics, only: [:show] diff --git a/config/webpack.config.js b/config/webpack.config.js index cb6bd949ddb..7e413c8493e 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -42,7 +42,6 @@ var config = { locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', - merge_request_widget: './merge_request_widget/ci_bundle.js', monitoring: './monitoring/monitoring_bundle.js', network: './network/network_bundle.js', notebook_viewer: './blob/notebook_viewer.js', @@ -63,6 +62,7 @@ var config = { u2f: ['vendor/u2f'], users: './users/users_bundle.js', raven: './raven/index.js', + vue_merge_request_widget: './vue_merge_request_widget/index.js', }, output: { diff --git a/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb new file mode 100644 index 00000000000..8fc6e380a77 --- /dev/null +++ b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb @@ -0,0 +1,7 @@ +class AddHeadPipelineIdToMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :merge_requests, :head_pipeline_id, :integer + end +end diff --git a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb new file mode 100644 index 00000000000..bc3850c0c23 --- /dev/null +++ b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb @@ -0,0 +1,25 @@ +class AddHeadPipelineForEachMergeRequest < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + disable_statement_timeout + + pipelines = Arel::Table.new(:ci_pipelines) + merge_requests = Arel::Table.new(:merge_requests) + + head_id = pipelines. + project(Arel::Nodes::NamedFunction.new('max', [pipelines[:id]])). + from(pipelines). + where(pipelines[:ref].eq(merge_requests[:source_branch])). + where(pipelines[:project_id].eq(merge_requests[:source_project_id])) + + sub_query = Arel::Nodes::SqlLiteral.new(Arel::Nodes::Grouping.new(head_id).to_sql) + + update_column_in_batches(:merge_requests, :head_pipeline_id, sub_query) + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index 722e776c27d..b91b3e6e977 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170506185517) do +ActiveRecord::Schema.define(version: 20170508170547) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -690,6 +690,7 @@ ActiveRecord::Schema.define(version: 20170506185517) do t.integer "cached_markdown_version" t.datetime "last_edited_at" t.integer "last_edited_by_id" + t.integer "head_pipeline_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree diff --git a/doc/README.md b/doc/README.md index 4397465bd3d..7bab42bc135 100644 --- a/doc/README.md +++ b/doc/README.md @@ -60,11 +60,8 @@ Manage files and branches from the UI (user interface): ### Issues and Merge Requests (MRs) - [Discussions](user/discussions/index.md) Threads, comments, and resolvable discussions in issues, commits, and merge requests. -- Issues - - [Create an issue](gitlab-basics/create-issue.md#how-to-create-an-issue-in-gitlab) - - [Confidential Issues](user/project/issues/confidential_issues.md) - - [Automatic issue closing](user/project/issues/automatic_issue_closing.md) - - [Issue Boards](user/project/issue_board.md) +- [Issues](user/project/issues/index.md) +- [Issue Board](user/project/issue_board.md) - [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests. - [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles. - [Merge Requests](user/project/merge_requests/index.md) diff --git a/doc/administration/high_availability/database.md b/doc/administration/high_availability/database.md index c22b1af8bfb..da9687aa849 100644 --- a/doc/administration/high_availability/database.md +++ b/doc/administration/high_availability/database.md @@ -27,7 +27,7 @@ If you use a cloud-managed service, or provide your own PostgreSQL: steps on the download page. 1. Create/edit `/etc/gitlab/gitlab.rb` and use the following configuration. Be sure to change the `external_url` to match your eventual GitLab front-end - URL. + URL. If there is a directive listed below that you do not see in the configuration, be sure to add it. ```ruby external_url 'https://gitlab.example.com' @@ -39,6 +39,8 @@ If you use a cloud-managed service, or provide your own PostgreSQL: unicorn['enable'] = false sidekiq['enable'] = false redis['enable'] = false + prometheus['enable'] = false + gitaly['enable'] = false gitlab_workhorse['enable'] = false mailroom['enable'] = false diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index 4638a9c9782..0e92f7c5a34 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -42,10 +42,10 @@ instances run in different machines. If you fail to provision the machines in that specific way, any issue with the shared environment can bring your entire setup down. -It is OK to run a Sentinel along with a master or slave Redis instance. -No more than one Sentinel in the same machine though. +It is OK to run a Sentinel alongside of a master or slave Redis instance. +There should be no more than one Sentinel on the same machine though. -You also need to take in consideration the underlying network topology, +You also need to take into consideration the underlying network topology, making sure you have redundant connectivity between Redis / Sentinel and GitLab instances, otherwise the networks will become a single point of failure. @@ -113,7 +113,7 @@ the Omnibus GitLab package in `5` **independent** machines, both with ### Redis setup overview You must have at least `3` Redis servers: `1` Master, `2` Slaves, and they -need to be each in a independent machine (see explanation above). +need to each be on independent machines (see explanation above). You can have additional Redis nodes, that will help survive a situation where more nodes goes down. Whenever there is only `2` nodes online, a failover @@ -232,7 +232,7 @@ Pick the one that suits your needs. This is the section where we install and setup the new Redis instances. >**Notes:** -- We assume that you install GitLab and all HA components from scratch. If you +- We assume that you have installed GitLab and all HA components from scratch. If you already have it installed and running, read how to [switch from a single-machine installation to Redis HA](#switching-from-an-existing-single-machine-installation-to-redis-ha). - Redis nodes (both master and slaves) will need the same password defined in @@ -245,10 +245,9 @@ The prerequisites for a HA Redis setup are the following: 1. Provision the minimum required number of instances as specified in the [recommended setup](#recommended-setup) section. -1. **Do NOT** install Redis or Redis Sentinel in the same machines your - GitLab application is running on. You can however opt in to install Redis - and Sentinel in the same machine (each in independent ones is recommended - though). +1. We **Do not** recommend installing Redis or Redis Sentinel in the same machines your + GitLab application is running on as this weakens your HA configuration. You can however opt in to install Redis + and Sentinel in the same machine. 1. All Redis nodes must be able to talk to each other and accept incoming connections over Redis (`6379`) and Sentinel (`26379`) ports (unless you change the default ones). diff --git a/doc/development/README.md b/doc/development/README.md index d04380e5b33..63db332b557 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -48,6 +48,7 @@ - [What requires downtime?](what_requires_downtime.md) - [Adding database indexes](adding_database_indexes.md) - [Post Deployment Migrations](post_deployment_migrations.md) +- [Foreign Keys & Associations](foreign_keys.md) ## Compliance diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md index 2bc1a700844..439d228baef 100644 --- a/doc/development/build_test_package.md +++ b/doc/development/build_test_package.md @@ -15,6 +15,10 @@ When you push a commit to either the gitlab-ce or gitlab-ee project, the pipeline for that commit will have a `build-package` manual action you can trigger. +![Manual actions](img/trigger_ss1.png) + +![Build package manual action](img/trigger_ss2.png) + ## Specifying versions of components If you want to create a package from a specific branch, commit or tag of any of diff --git a/doc/development/code_review.md b/doc/development/code_review.md index be3dd1e2cc6..4ed89146072 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -6,18 +6,20 @@ There are a few rules to get your merge request accepted: 1. Your merge request should only be **merged by a [maintainer][team]**. 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. + **approved by a [backend maintainer][projects]**. 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. + **approved by a [frontend maintainer][projects]**. 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. + be **approved by a [frontend and a backend maintainer][projects]**. 1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. + ask or assign any [reviewers][projects] for a first review. 1. If you need some guidance (e.g. it's your first merge request), feel free to ask one of the [Merge request coaches][team]. 1. The reviewer will assign the merge request to a maintainer once the reviewer is satisfied with the state of the merge request. +For more guidance, see [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md). + ## Best practices This guide contains advice and best practices for performing code review, and @@ -30,7 +32,7 @@ code is effective, understandable, and maintainable. Any developer can, and is encouraged to, perform code review on merge requests of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the -[team page](https://about.gitlab.com/team). +[engineering projects][projects]. ### Everyone @@ -140,3 +142,6 @@ Largely based on the [thoughtbot code review guide]. --- [Return to Development documentation](README.md) + +[projects]: https://about.gitlab.com/handbook/engineering/projects/ +[team]: https://about.gitlab.com/team/ diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 157c13352ca..a8c264bbd3c 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -29,6 +29,33 @@ browser and you will not have access to certain APIs, such as which will have to be stubbed. ### Writing tests + +When writing describe test blocks to test specific functions/methods, +please use the method name as the describe block name. + +```javascript +// Good +describe('methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('#methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('.methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); +``` + ### Vue.js unit tests See this [section][vue-test]. diff --git a/doc/development/foreign_keys.md b/doc/development/foreign_keys.md new file mode 100644 index 00000000000..0ab0deb156f --- /dev/null +++ b/doc/development/foreign_keys.md @@ -0,0 +1,63 @@ +# Foreign Keys & Associations + +When adding an association to a model you must also add a foreign key. For +example, say you have the following model: + +```ruby +class User < ActiveRecord::Base + has_many :posts +end +``` + +Here you will need to add a foreign key on column `posts.user_id`. This ensures +that data consistency is enforced on database level. Foreign keys also mean that +the database can very quickly remove associated data (e.g. when removing a +user), instead of Rails having to do this. + +## Adding Foreign Keys In Migrations + +Foreign keys can be added concurrently using `add_concurrent_foreign_key` as +defined in `Gitlab::Database::MigrationHelpers`. See the [Migration Style +Guide](migration_style_guide.md) for more information. + +Keep in mind that you can only safely add foreign keys to existing tables after +you have removed any orphaned rows. The method `add_concurrent_foreign_key` +does not take care of this so you'll need to do so manually. + +## Cascading Deletes + +Every foreign key must define an `ON DELETE` clause, and in 99% of the cases +this should be set to `CASCADE`. + +## Indexes + +When adding a foreign key in PostgreSQL the column is not indexed automatically, +thus you must also add a concurrent index. Not doing so will result in cascading +deletes being very slow. + +## Dependent Removals + +Don't define options such as `dependent: :destroy` or `dependent: :delete` when +defining an association. Defining these options means Rails will handle the +removal of data, instead of letting the database handle this in the most +efficient way possible. + +In other words, this is bad and should be avoided at all costs: + +```ruby +class User < ActiveRecord::Base + has_many :posts, dependent: :destroy +end +``` + +Should you truly have a need for this it should be approved by a database +specialist first. + +You should also not define any `before_destroy` or `after_destroy` callbacks on +your models _unless_ absolutely required and only when approved by database +specialists. For example, if each row in a table has a corresponding file on a +file system it may be tempting to add a `after_destroy` hook. This however +introduces non database logic to a model, and means we can no longer rely on +foreign keys to remove the data as this would result in the filesystem data +being left behind. In such a case you should use a service class instead that +takes care of removing non database data. diff --git a/doc/development/img/trigger_ss1.png b/doc/development/img/trigger_ss1.png Binary files differnew file mode 100644 index 00000000000..ccff1009a25 --- /dev/null +++ b/doc/development/img/trigger_ss1.png diff --git a/doc/development/img/trigger_ss2.png b/doc/development/img/trigger_ss2.png Binary files differnew file mode 100644 index 00000000000..94dfd048793 --- /dev/null +++ b/doc/development/img/trigger_ss2.png diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md index d7e3aa35bdd..12466437edc 100644 --- a/doc/gitlab-basics/README.md +++ b/doc/gitlab-basics/README.md @@ -11,5 +11,5 @@ Step-by-step guides on the basics of working with Git and GitLab. - [Fork a project](fork-project.md) - [Add a file](add-file.md) - [Add an image](add-image.md) -- [Create an issue](create-issue.md) +- [Create an issue](../user/project/issues/create_new_issue.md) - [Create a merge request](add-merge-request.md) diff --git a/doc/gitlab-basics/create-issue.md b/doc/gitlab-basics/create-issue.md index 13e5a738c89..abb163dbf18 100644 --- a/doc/gitlab-basics/create-issue.md +++ b/doc/gitlab-basics/create-issue.md @@ -1,30 +1,2 @@ -# How to create an Issue in GitLab -The issue tracker is a good place to add things that need to be improved or -solved in a project. - ---- - -1. Go to the project where you'd like to create the issue and navigate to the - **Issues** tab on top. - - ![Issues](img/project_navbar.png) - -1. Click on the **New issue** button on the right side of your screen. - - ![New issue](img/new_issue_button.png) - -1. At the very minimum, add a title and a description to your issue. - You may assign it to a user, add a milestone or add labels (all optional). - - ![Issue title and description](img/new_issue_page.png) - -1. When ready, click on **Submit issue**. - ---- - -Your Issue will now be added to the issue tracker of the project you opened it -at and will be ready to be reviewed. You can comment on it and mention the -people involved. You can also link issues to the merge requests where the issues -are solved. To do this, you can use an -[issue closing pattern](../user/project/issues/automatic_issue_closing.md). +This document was moved to [another location](../user/project/issues/index.md#new-issue). diff --git a/doc/install/README.md b/doc/install/README.md index 58cc7d312fd..3bf7923a9ee 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -20,6 +20,8 @@ the hardware requirements. - [Docker](https://docs.gitlab.com/omnibus/docker/) - Install GitLab using Docker. - [Installation on Google Cloud Platform](google_cloud_platform/index.md) - Install GitLab on Google Cloud Platform using our official image. +- [Installing in Kubernetes](kubernetes/index.md) - Install GitLab into a Kubernetes + Cluster using our official Helm Chart Repository. - Testing only! [DigitalOcean and Docker Machine](digitaloceandocker.md) - Quickly test any version of GitLab on DigitalOcean using Docker Machine. diff --git a/doc/install/installation.md b/doc/install/installation.md index dc807d93bbb..5615b2a534b 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -289,9 +289,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-1-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-2-stable gitlab -**Note:** You can change `9-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `9-2-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md new file mode 100644 index 00000000000..35d395af024 --- /dev/null +++ b/doc/install/kubernetes/gitlab_chart.md @@ -0,0 +1,436 @@ +# GitLab Helm Chart + +The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster. + +This chart includes the following: + +- Deployment using the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce) or [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee) container image +- ConfigMap containing the `gitlab.rb` contents that configure [Omnibus GitLab](https://docs.gitlab.com/omnibus/settings/configuration.html#configuration-options) +- Persistent Volume Claims for Data, Config, Logs, and Registry Storage +- A Kubernetes service +- Optional Redis deployment using the [Redis Chart](https://github.com/kubernetes/charts/tree/master/stable/redis) (defaults to enabled) +- Optional PostgreSQL deployment using the [PostgreSQL Chart](https://github.com/kubernetes/charts/tree/master/stable/postgresql) (defaults to enabled) +- Optional Ingress (defaults to disabled) + +## Prerequisites + +- _At least_ 3 GB of RAM available on your cluster, in chunks of 1 GB +- Kubernetes 1.4+ with Beta APIs enabled +- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure +- The ability to point a DNS entry or URL at your GitLab install +- The `kubectl` CLI installed locally and authenticated for the cluster +- The Helm Client installed locally +- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init` +- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository) + +## Configuring GitLab + +Create a `values.yaml` file for your GitLab configuration. See the +[Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md) +for information on how your values file will override the defaults. + +The default configuration can always be [found in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab/values.yaml), in the chart repository. + +### Required configuration + +In order for GitLab to function, your config file **must** specify the following: + +- An `externalUrl` that GitLab will be reachable at. + +### Choosing GitLab Edition + +The Helm chart defaults to installing GitLab CE. This can be controlled by setting the `edition` variable in your values. + +Setting `edition` to GitLab Enterprise Edition (EE) in your `values.yaml` + +```yaml +edition: EE + +externalUrl: 'http://gitlab.example.com' +``` + +### Choosing a different GitLab release version + +The version of GitLab installed is based on the `edition` setting (see [section](#choosing-gitlab-edition) above), and +the value of the corresponding helm setting: `ceImage` or `eeImage`. + +```yaml +## GitLab Edition +## ref: https://about.gitlab.com/products/ +## - CE - Community Edition +## - EE - Enterprise Edition - (requires license issued by GitLab Inc) +## +edition: CE + +## GitLab CE image +## ref: https://hub.docker.com/r/gitlab/gitlab-ce/tags/ +## +ceImage: gitlab/gitlab-ce:9.1.2-ce.0 + +## GitLab EE image +## ref: https://hub.docker.com/r/gitlab/gitlab-ee/tags/ +## +eeImage: gitlab/gitlab-ee:9.1.2-ee.0 +``` + +The different images can be found in the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce/tags/) and [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee/tags/) +repositories on Docker Hub + +> **Note:** +There is no guarantee that other release versions of GitLab, other than what are +used by default in the chart, will be supported by a chart install. + + +### Custom Omnibus GitLab configuration + +In addition to the configuration options provided for GitLab in the Helm Chart, you can also pass any custom configuration +that is valid for the [Omnibus GitLab Configuration](https://docs.gitlab.com/omnibus/settings/configuration.html). + +The setting to pass these values in is `omnibusConfigRuby`. It accepts any valid +Ruby code that could used in the Omnibus `/etc/gitlab/gitlab.rb` file. In +Kubernetes, the contents will be stored in a ConfigMap. + +Example setting: + +```yaml +omnibusConfigRuby: | + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; +``` + +### Persistent storage + +By default, persistent storage is enabled for GitLab and the charts it depends +on (Redis and PostgreSQL). + +Components can have their claim size set from your `values.yaml`, and each +component allows you to optionally configure the `storageClass` variable so you +can take advantage of faster drives on your cloud provider. + +Basic configuration: + +```yaml +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## ref: https://docs.gitlab.com/ce/install/requirements.html#storage +## +persistence: + ## This volume persists generated configuration files, keys, and certs. + ## + gitlabEtc: + enabled: true + size: 1Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + accessMode: ReadWriteOnce + ## This volume is used to store git data and other project files. + ## ref: https://docs.gitlab.com/omnibus/settings/configuration.html#storing-git-data-in-an-alternative-directory + ## + gitlabData: + enabled: true + size: 10Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + accessMode: ReadWriteOnce + gitlabRegistry: + enabled: true + size: 10Gi + ## If defined, volume.beta.kubernetes.io/storage-class: <storageClass> + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + + postgresql: + persistence: + # storageClass: + size: 10Gi + ## Configuration values for the Redis dependency. + ## ref: https://github.com/kubernetes/charts/blob/master/stable/redis/README.md + ## + redis: + persistence: + # storageClass: + size: 10Gi +``` + +>**Note:** +You can make use of faster SSD drives by adding a [StorageClass] to your cluster +and using the `storageClass` setting in the above config to the name of +your new storage class. + +### Routing + +By default, the GitLab chart uses a service type of `LoadBalancer` which will +result in the GitLab service being exposed externally using your cloud provider's +load balancer. + +This field is configurable in your `values.yml` by setting the top-level +`serviceType` field. See the [Service documentation][kube-srv] for more +information on the possible values. + +#### Ingress routing + +Optionally, you can enable the Chart's ingress for use by an ingress controller +deployed in your cluster. + +To enable the ingress, edit its section in your `values.yaml`: + +```yaml +ingress: + ## If true, gitlab Ingress will be created + ## + enabled: true + + ## gitlab Ingress hostnames + ## Must be provided if Ingress is enabled + ## + hosts: + - gitlab.example.com + + ## gitlab Ingress annotations + ## + annotations: + kubernetes.io/ingress.class: nginx +``` + +You must also provide the list of hosts that the ingress will use. In order for +you ingress controller to work with the GitLab Ingress, you will need to specify +its class in an annotation. + +>**Note:** +The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that. +Setting up an Ingress controller can be as simple as installing the `nginx-ingress` helm chart. But be sure +to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md) + +### External database + +You can configure the GitLab Helm chart to connect to an external PostgreSQL +database. + +>**Note:** +This is currently our recommended approach for a Production setup. + +To use an external database, in your `values.yaml`, disable the included +PostgreSQL dependency, then configure access to your database: + +```yaml +dbHost: "<reachable postgres hostname>" +dbPassword: "<password for the user with access to the db>" +dbUsername: "<user with read/write access to the database>" +dbDatabase: "<database name on postgres to connect to for GitLab>" + +postgresql: + # Sets whether the PostgreSQL helm chart is used as a dependency + enabled: false +``` + +Be sure to check the GitLab documentation on how to +[configure the external database](../requirements.md#postgresql-requirements) + +You can also configure the chart to use an external Redis server, but this is +not required for basic production use: + +```yaml +dbHost: "<reachable redis hostname>" +dbPassword: "<password>" + +redis: + # Sets whether the Redis helm chart is used as a dependency + enabled: false +``` + +### Sending email + +By default, the GitLab container will not be able to send email from your cluster. +In order to send email, you should configure SMTP settings in the +`omnibusConfigRuby` section, as per the [GitLab Omnibus documentation](https://docs.gitlab.com/omnibus/settings/smtp.html). + +>**Note:** +Some cloud providers restrict emails being sent out on SMTP, so you will have +to use a SMTP service that is supported by your provider. See this +[Google Cloud Platform page](https://cloud.google.com/compute/docs/tutorials/sending-mail/) +as and example. + +Here is an example configuration for Mailgun SMTP support: + +```yaml +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + # SMTP settings + gitlab_rails['smtp_enable'] = true + gitlab_rails['smtp_address'] = "smtp.mailgun.org" + gitlab_rails['smtp_port'] = 2525 # High port needed for Google Cloud + gitlab_rails['smtp_authentication'] = "plain" + gitlab_rails['smtp_enable_starttls_auto'] = false + gitlab_rails['smtp_user_name'] = "postmaster@mg.your-mail-domain" + gitlab_rails['smtp_password'] = "you-password" + gitlab_rails['smtp_domain'] = "mg.your-mail-domain" +``` + +### HTTPS configuration + +To setup HTTPS access to your GitLab server, first you need to configure the +chart to use the [ingress](#ingress-routing). + +GitLab's config should be updated to support [proxied SSL](https://docs.gitlab.com/omnibus/settings/nginx.html#supporting-proxied-ssl). + +In addition to having a Ingress Controller deployed and the basic ingress +settings configured, you will also need to specify in the ingress settings +which hosts to use HTTPS for. + +Make sure `externalUrl` now includes `https://` instead of `http://` in its +value, and update the `omnibusConfigRuby` section: + +```yaml +externalUrl: 'https://gitlab.example.com' + +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + # These are the settings needed to support proxied SSL + nginx['listen_port'] = 80 + nginx['listen_https'] = false + nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support + + hosts: + - gitlab.example.com + + ## gitlab Ingress TLS configuration + ## Secrets must be created in the namespace, and is not done for you in this chart + ## + tls: + - secretName: gitlab-tls + hosts: + - gitlab.example.com +``` + +You will need to create the named secret in your cluster, specifying the private +and public certificate pair using the format outlined in the +[ingress documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#tls). + +Alternatively, you can use the `kubernetes.io/tls-acme` annotation, and install +the `kube-lego` chart to your cluster to have Let's Encrypt issue your +certificate. See the [kube-lego documentation](https://github.com/kubernetes/charts/blob/master/stable/kube-lego/README.md) +for more information. + +### Enabling the GitLab Container Registry + +The GitLab Registry is disabled by default but can be enabled by providing an +external URL for it in the configuration. In order for the Registry to be easily +used by GitLab CI and your Kubernetes cluster, you will need to set it up with +a TLS certificate, so these examples will include the ingress settings for that +as well. See the [HTTPS Configuration section](#https-configuration) +for more explanation on some of these settings. + +Example config: + +```yaml +externalUrl: 'https://gitlab.example.com' + +omnibusConfigRuby: | + # This is example config of what you may already have in your omnibusConfigRuby object + unicorn['worker_processes'] = 2; + gitlab_rails['trusted_proxies'] = ["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]; + + registry_external_url 'https://registry.example.com'; + + # These are the settings needed to support proxied SSL + nginx['listen_port'] = 80 + nginx['listen_https'] = false + nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + registry_nginx['listen_port'] = 80 + registry_nginx['listen_https'] = false + registry_nginx['proxy_set_headers'] = { + "X-Forwarded-Proto" => "https", + "X-Forwarded-Ssl" => "on" + } + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: 'true' Annotation used for letsencrypt support + + hosts: + - gitlab.example.com + - registry.example.com + + ## gitlab Ingress TLS configuration + ## Secrets must be created in the namespace, and is not done for you in this chart + ## + tls: + - secretName: gitlab-tls + hosts: + - gitlab.example.com + - registry.example.com +``` + +## Installing GitLab using the Helm Chart + +Once you [have configured](#configuration) GitLab in your `values.yml` file, +run the following: + +```bash +helm install --namepace <NAMEPACE> --name gitlab -f <CONFIG_VALUES_FILE> gitlab/gitlab +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where you want to install GitLab. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom + configuration. See the [Configuration](#configuration) section to create it. + +## Updating GitLab using the Helm Chart + +Once your GitLab Chart is installed, configuration changes and chart updates +should we done using `helm upgrade` + +```bash +helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom + [configuration] (#configuration). +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab`. + +## Uninstalling GitLab using the Helm Chart + +To uninstall the GitLab Chart, run the following: + +```bash +helm delete --namespace <NAMESPACE> <RELEASE-NAME> +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab is installed. +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab`. + +[kube-srv]: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services---service-types +[storageclass]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/#storageclasses diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md new file mode 100644 index 00000000000..dbd9ae3f70c --- /dev/null +++ b/doc/install/kubernetes/gitlab_runner_chart.md @@ -0,0 +1,175 @@ +# GitLab Runner Helm Chart + +The `gitlab-runner` Helm chart deploys a GitLab Runner instance into your +Kubernetes cluster. + +This chart configures the Runner to: + +- Run using the GitLab Runner [Kubernetes executor](https://docs.gitlab.com/runner/install/kubernetes.html) +- For each new job it receives from [GitLab CI](https://about.gitlab.com/features/gitlab-ci-cd/), it will provision a + new pod within the specified namespace to run it. + +## Prerequisites + +- Your GitLab Server's API is reachable from the cluster +- Kubernetes 1.4+ with Beta APIs enabled +- The `kubectl` CLI installed locally and authenticated for the cluster +- The Helm Client installed locally +- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init` +- The GitLab Helm Repo added to your Helm Client. See [Adding GitLab Helm Repo](index.md#add-the-gitlab-helm-repository) + +## Configuring GitLab Runner using the Helm Chart + +Create a `values.yaml` file for your GitLab Runner configuration. See [Helm docs](https://github.com/kubernetes/helm/blob/master/docs/chart_template_guide/values_files.md) +for information on how your values file will override the defaults. + +The default configuration can always be found in the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository. + +### Required configuration + +In order for GitLab Runner to function, your config file **must** specify the following: + + - `gitlabURL` - the GitLab Server URL (with protocol) to register the runner against + - `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be + retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information. + +### Other configuration + +The rest of the configuration is [documented in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository. + +Here is a snippet of the important settings: + +```yaml +## The GitLab Server URL (with protocol) that want to register the runner against +## ref: https://docs.gitlab.com/runner/commands/README.html#gitlab-runner-register +## +gitlabURL: http://gitlab.your-domain.com/ + +## The Registration Token for adding new Runners to the GitLab Server. This must +## be retreived from your GitLab Instance. +## ref: https://docs.gitlab.com/ce/ci/runners/README.html#creating-and-registering-a-runner +## +runnerRegistrationToken: "" + +## Configure the maximum number of concurrent jobs +## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section +## +concurrent: 10 + +## Defines in seconds how often to check GitLab for a new builds +## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section +## +checkInterval: 30 + +## Configuration for the Pods that that the runner launches for each new job +## +runners: + ## Default container image to use for builds when none is specified + ## + image: ubuntu:16.04 + + ## Run all containers with the privileged flag enabled + ## This will allow the docker:dind image to run if you need to run Docker + ## commands. Please read the docs before turning this on: + ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind + ## + privileged: false + + ## Namespace to run Kubernetes jobs in (defaults to 'default') + ## + # namespace: + + ## Build Container specific configuration + ## + builds: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + + ## Service Container specific configuration + ## + services: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + + ## Helper Container specific configuration + ## + helpers: + # cpuLimit: 200m + # memoryLimit: 256Mi + cpuRequests: 100m + memoryRequests: 128Mi + +``` + +### Running Docker-in-Docker containers with GitLab Runners + +See [Running Privileged Containers for the Runners](#running-privileged-containers-for-the-runners) for how to enable it, +and the [GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds) on running dind. + +### Running privileged containers for the Runners + +You can tell the GitLab Runner to run using privileged containers. You may need +this enabled if you need to use the Docker executable within your GitLab CI jobs. + +This comes with several risks that you can read about in the +[GitLab CI Runner documentation](https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-in-your-builds). + +If you are okay with the risks, and your GitLab CI Runner instance is registered +against a specific project in GitLab that you trust the CI jobs of, you can +enable privileged mode in `values.yaml`: + +```yaml +runners: + ## Run all containers with the privileged flag enabled + ## This will allow the docker:dind image to run if you need to run Docker + ## commands. Please read the docs before turning this on: + ## ref: https://docs.gitlab.com/runner/executors/kubernetes.html#using-docker-dind + ## + privileged: true +``` + +## Installing GitLab Runner using the Helm Chart + +Once you [have configured](#configuration) GitLab Runner in your `values.yml` file, +run the following: + +```bash +helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner +``` + +- `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner. +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the + [Configuration](#configuration) section to create it. + +## Updating GitLab Runner using the Helm Chart + +Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade` + +```bash +helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner +``` + +Where: +- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed +- `<CONFIG_VALUES_FILE>` is the path to values file containing your custom configuration. See the + [Configuration](#configuration) section to create it. +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab-runner`. + +## Uninstalling GitLab Runner using the Helm Chart + +To uninstall the GitLab Runner Chart, run the following: + +```bash +helm delete --namespace <NAMESPACE> <RELEASE-NAME> +``` + +where: + +- `<NAMESPACE>` is the Kubernetes namespace where GitLab Runner is installed +- `<RELEASE-NAME>` is the name you gave the chart when installing it. + In the [Install section](#installing) we called it `gitlab-runner`. diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md new file mode 100644 index 00000000000..db0430fc27b --- /dev/null +++ b/doc/install/kubernetes/index.md @@ -0,0 +1,44 @@ +# Installing GitLab in Kubernetes + +The easiest method to deploy GitLab in [Kubernetes](https://kubernetes.io/) is +to take advantage of the official GitLab Helm charts. [Helm] is a package +management tool for Kubernetes, allowing apps to be easily managed via their +Charts. A [Chart] is a detailed description of the application including how it +should be deployed, upgraded, and configured. + +The GitLab Helm repository is located at https://charts.gitlab.io. +You can report any issues related to GitLab's Helm Charts at +https://gitlab.com/charts/charts.gitlab.io/issues. +Contributions and improvements are also very welcome. + +## Prerequisites + +To use the charts, the Helm tool must be installed and initialized. The best +place to start is by reviewing the [Helm Quick Start Guide][helm-quick]. + +## Add the GitLab Helm repository + +Once Helm has been installed, the GitLab chart repository must be added: + +```bash +helm repo add gitlab https://charts.gitlab.io +``` + +After adding the repository, Helm must be re-initialized: + +```bash +helm init +``` + +## Using the GitLab Helm Charts + +GitLab makes available two Helm Charts, one for the GitLab server and another +for the Runner. More detailed information on installing and configuring each +Chart can be found below: + +- [Install GitLab](gitlab_chart.md) +- [Install GitLab Runner](gitlab_runner_chart.md) + +[chart]: https://github.com/kubernetes/charts +[helm-quick]: https://github.com/kubernetes/helm/blob/master/docs/quickstart.md +[helm]: https://github.com/kubernetes/helm/blob/master/README.md diff --git a/doc/intro/README.md b/doc/intro/README.md index d52b180a076..7485912d1a2 100644 --- a/doc/intro/README.md +++ b/doc/intro/README.md @@ -11,7 +11,7 @@ Create projects and groups. Create issues, labels, milestones, cast your vote, and review issues. -- [Create a new issue](../gitlab-basics/create-issue.md) +- [Create a new issue](../user/project/issues/index.md#new-issue) - [Assign labels to issues](../user/project/labels.md) - [Use milestones as an overview of your project's tracker](../user/project/milestones/index.md) - [Use voting to express your like/dislike to issues and merge requests](../workflow/award_emoji.md) diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md new file mode 100644 index 00000000000..19db6e5763e --- /dev/null +++ b/doc/update/9.1-to-9.2.md @@ -0,0 +1,288 @@ +# From 9.1 to 9.2 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz +echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and +it has a minimum requirement of node v4.3.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v4.3.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + + +Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage +JavaScript dependencies. + +```bash +curl --location https://yarnpkg.com/install.sh | bash - +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable-ee +``` + +### 6. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 7. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:config/gitlab.yml.example origin/9-2-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab-ssl origin/9-2-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab origin/9-2-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-1-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:lib/support/init.d/gitlab.default.example origin/9-2-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 9. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 10. Optional: install Gitaly + +Gitaly is still an optional component of GitLab. If you want to save time +during your 9.2 upgrade **you can skip this step**. + +If you have not yet set up Gitaly then follow [Gitaly section of the installation +guide](../install/installation.md#install-gitaly). + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 11. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 12. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (9.1) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 9.0 to 9.1](9.0-to-9.1.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index a74014b6b2f..b71d6981d1e 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -169,6 +169,14 @@ Clicking on the Monitoring button will display a new page, showing up to the las 8 hours of performance data. It may take a minute or two for data to appear after initial deployment. +## Determining performance impact of a merge + +> [Introduced][ce-10408] in GitLab 9.1. + +After a merge request has been approved, a sparkline will appear on the merge request page displaying the average memory usage of the application. The sparkline includes thirty minutes of data prior to the merge, a dot to indicate the merge itself, and then will begin capturing thirty minutes of data after the merge. + +This sparkline serves as a quick indicator of the impact on memory consumption of the recently merged changes. If there is a problem, action can then be taken to troubleshoot or revert the merge. + ## Troubleshooting If the "Attempting to load performance data" screen continues to appear, it could be due to: @@ -189,4 +197,5 @@ If the "Attempting to load performance data" screen continues to appear, it coul [gitlab.com-ip-range]: https://gitlab.com/gitlab-com/infrastructure/issues/434 [ci-environment-slug]: https://docs.gitlab.com/ce/ci/variables/#predefined-variables-environment-variables [ce-8935]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935 +[ce-10408]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10408 [promgldocs]: ../../../administration/monitoring/prometheus/index.md diff --git a/doc/user/project/issues/closing_issues.md b/doc/user/project/issues/closing_issues.md new file mode 100644 index 00000000000..dcfa5ff59b2 --- /dev/null +++ b/doc/user/project/issues/closing_issues.md @@ -0,0 +1,59 @@ +# Closing Issues + +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + +## Directly + +Whenever you decide that's no longer need for that issue, +close the issue using the close button: + +![close issue - button](img/button_close_issue.png) + +## Via Merge Request + +When a merge request resolves the discussion over an issue, you can +make it close that issue(s) when merged. + +All you need is to use a [keyword](automatic_issue_closing.md) +accompanying the issue number, add to the description of that MR. + +In this example, the keyword "closes" prefixing the issue number will create a relationship +in such a way that the merge request will close the issue when merged. + +Mentioning various issues in the same line also works for this purpose: + +```md +Closes #333, #444, #555 and #666 +``` + +If the issue is in a different repository rather then the MR's, +add the full URL for that issue(s): + +```md +Closes #333, #444, and https://gitlab.com/<username>/<projectname>/issues/<xxx> +``` + +All the following keywords will produce the same behaviour: + +- Close, Closes, Closed, Closing, close, closes, closed, closing +- Fix, Fixes, Fixed, Fixing, fix, fixes, fixed, fixing +- Resolve, Resolves, Resolved, Resolving, resolve, resolves, resolved, resolving + +![merge request closing issue when merged](img/merge_request_closes_issue.png) + +If you use any other word before the issue number, the issue and the MR will +link to each other, but the MR will NOT close the issue(s) when merged. + +![mention issues in MRs - closing and related](img/closing_and_related_issues.png) + +## From the Issue Board + +You can close an issue from [Issue Boards](../issue_board.md) by draging an issue card +from its list and dropping into **Closed**. + +![close issue from the Issue Board](img/close_issue_from_board.gif) + +## Customizing the issue closing patern + +Alternatively, a GitLab **administrator** can +[customize the issue closing patern](../../../administration/issue_closing_pattern.md). diff --git a/doc/user/project/issues/create_new_issue.md b/doc/user/project/issues/create_new_issue.md new file mode 100644 index 00000000000..9af088374a1 --- /dev/null +++ b/doc/user/project/issues/create_new_issue.md @@ -0,0 +1,38 @@ +# Create a new Issue + +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + +When you create a new issue, you'll be prompted to fill in +the information illustrated on the image below. + +![New issue from the issues list](img/new_issue.png) + +Read through the [issues functionalities documentation](issues_functionalities.md#issues-functionalities) +to understand these fields one by one. + +## New issue from the Issue Tracker + +Navigate to your **Project's Dashboard** > **Issues** > **New Issue** to create a new issue: + +![New issue from the issue list view](img/new_issue_from_tracker_list.png) + +## New issue from an opened issue + +From an **opened issue** in your project, click **New Issue** to create a new +issue in the same project: + +![New issue from an open issue](img/new_issue_from_open_issue.png) + +## New issue from the project's dashboard + +From your **Project's Dashboard**, click the plus sign (**+**) to open a dropdown +menu with a few options. Select **New Issue** to create an issue in that project: + +![New issue from a project's dashboard](img/new_issue_from_projects_dashboard.png) + +## New issue from the Issue Board + +From an Issue Board, create a new issue by clicking on the plus sign (**+**) on the top of a list. +It opens a new issue for that project labeled after its respective list. + +![From the issue board](img/new_issue_from_issue_board.png) diff --git a/doc/user/project/issues/crosslinking_issues.md b/doc/user/project/issues/crosslinking_issues.md new file mode 100644 index 00000000000..5cc7ea383ae --- /dev/null +++ b/doc/user/project/issues/crosslinking_issues.md @@ -0,0 +1,63 @@ +# Crosslinking Issues + +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + +## From Commit Messages + +Every time you mention an issue in your commit message, you're creating +a relationship between the two stages of the development workflow: the +issue itself and the first commit related to that issue. + +If the issue and the code you're committing are both in the same project, +you simply add `#xxx` to the commit message, where `xxx` is the issue number. +If they are not in the same project, you can add the full URL to the issue +(`https://gitlab.com/<username>/<projectname>/issues/<xxx>`). + +```shell +git commit -m "this is my commit message. Ref #xxx" +``` + +or + +```shell +git commit -m "this is my commit message. Related to https://gitlab.com/<username>/<projectname>/issues/<xxx>" +``` + +Of course, you can replace `gitlab.com` with the URL of your own GitLab instance. + +**Note:** Linking your first commit to your issue is going to be relevant +for tracking your process far ahead with +[GitLab Cycle Analytics](https://about.gitlab.com/features/cycle-analytics/)). +It will measure the time taken for planning the implementation of that issue, +which is the time between creating an issue and making the first commit. + +## From Related Issues + +Mentioning related issues in merge requests and other issues is useful +for your team members and collaborators to know that there are opened +issues around that same idea. + +You do that as explained above, when +[mentioning an issue from a commit message](#from-commit-messages). + +When mentioning the issue "A" in a issue "B", the issue "A" will also +display a notification in its tracker. The same is valid for mentioning +issues in merge requests. + +![issue mentioned in issue](img/mention_in_issue.png) + +## From Merge Requests + +Mentioning issues in merge request comments work exactly the same way +they do for [related issues](#from-related-issues). + +When you mention an issue in a merge request description, you can either +[close the issue as soon as the merge request is merged](closing_issues.md#via-merge-request), +or simply link both issue and merge request as described in the +[closing issues documentation](closing_issues.md#from-related-issues). + +![issue mentioned in MR](img/mention_in_merge_request.png) + +### Close an issue by merging a merge request + +To [close an issue when a merge request is merged](closing_issues.md#via-merge-request), use the [automatic issue closing patern](automatic_issue_closing.md). diff --git a/doc/user/project/issues/due_dates.md b/doc/user/project/issues/due_dates.md index b516d47ffa3..e0c405353ce 100644 --- a/doc/user/project/issues/due_dates.md +++ b/doc/user/project/issues/due_dates.md @@ -2,6 +2,8 @@ > [Introduced][ce-3614] in GitLab 8.7. +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + Due dates can be used in issues to keep track of deadlines and make sure features are shipped on time. Due dates require at least [Reporter permissions][permissions] to be able to edit them. On the contrary, they can be seen by everybody. @@ -22,8 +24,8 @@ Changes are saved immediately. ## Making use of due dates -Issues that have a due date can be distinctively seen in the issues index page -with a calendar icon next to them. Issues where the date is past due will have +Issues that have a due date can be distinctively seen in the issue tracker +displaying a date next to them. Issues where the date is overdue will have the icon and the date colored red. You can sort issues by those that are _Due soon_ or _Due later_ from the dropdown menu in the right. diff --git a/doc/user/project/issues/img/button_close_issue.png b/doc/user/project/issues/img/button_close_issue.png Binary files differnew file mode 100755 index 00000000000..8fb2e23f58a --- /dev/null +++ b/doc/user/project/issues/img/button_close_issue.png diff --git a/doc/user/project/issues/img/close_issue_from_board.gif b/doc/user/project/issues/img/close_issue_from_board.gif Binary files differnew file mode 100644 index 00000000000..4814b42687b --- /dev/null +++ b/doc/user/project/issues/img/close_issue_from_board.gif diff --git a/doc/user/project/issues/img/closing_and_related_issues.png b/doc/user/project/issues/img/closing_and_related_issues.png Binary files differnew file mode 100755 index 00000000000..c6543e85fdb --- /dev/null +++ b/doc/user/project/issues/img/closing_and_related_issues.png diff --git a/doc/user/project/issues/img/confidential_issues_create.png b/doc/user/project/issues/img/confidential_issues_create.png Binary files differindex d259255599d..0a141eb39f8 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_create.png +++ b/doc/user/project/issues/img/confidential_issues_create.png diff --git a/doc/user/project/issues/img/confidential_issues_index_page.png b/doc/user/project/issues/img/confidential_issues_index_page.png Binary files differindex 042461e2451..e4b492a2769 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_index_page.png +++ b/doc/user/project/issues/img/confidential_issues_index_page.png diff --git a/doc/user/project/issues/img/confidential_issues_issue_page.png b/doc/user/project/issues/img/confidential_issues_issue_page.png Binary files differindex b3568e9303a..f04ec8ff32b 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_issue_page.png +++ b/doc/user/project/issues/img/confidential_issues_issue_page.png diff --git a/doc/user/project/issues/img/confidential_issues_search_guest.png b/doc/user/project/issues/img/confidential_issues_search_guest.png Binary files differindex b85de90b4d5..dc1b4ba8ad7 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_search_guest.png +++ b/doc/user/project/issues/img/confidential_issues_search_guest.png diff --git a/doc/user/project/issues/img/confidential_issues_search_master.png b/doc/user/project/issues/img/confidential_issues_search_master.png Binary files differindex bf2b9428875..fc01f4da9db 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_search_master.png +++ b/doc/user/project/issues/img/confidential_issues_search_master.png diff --git a/doc/user/project/issues/img/confidential_issues_system_notes.png b/doc/user/project/issues/img/confidential_issues_system_notes.png Binary files differindex 4005f9350f7..82e0dd8e85e 100644..100755 --- a/doc/user/project/issues/img/confidential_issues_system_notes.png +++ b/doc/user/project/issues/img/confidential_issues_system_notes.png diff --git a/doc/user/project/issues/img/due_dates_create.png b/doc/user/project/issues/img/due_dates_create.png Binary files differindex d2fe1172bab..ece35d44213 100644..100755 --- a/doc/user/project/issues/img/due_dates_create.png +++ b/doc/user/project/issues/img/due_dates_create.png diff --git a/doc/user/project/issues/img/due_dates_edit_sidebar.png b/doc/user/project/issues/img/due_dates_edit_sidebar.png Binary files differindex 6b37150e7db..d1c7d1eb7e9 100644..100755 --- a/doc/user/project/issues/img/due_dates_edit_sidebar.png +++ b/doc/user/project/issues/img/due_dates_edit_sidebar.png diff --git a/doc/user/project/issues/img/due_dates_issues_index_page.png b/doc/user/project/issues/img/due_dates_issues_index_page.png Binary files differindex defcd5eca39..94679436b32 100644..100755 --- a/doc/user/project/issues/img/due_dates_issues_index_page.png +++ b/doc/user/project/issues/img/due_dates_issues_index_page.png diff --git a/doc/user/project/issues/img/due_dates_todos.png b/doc/user/project/issues/img/due_dates_todos.png Binary files differindex 92c9fd4021b..4c124c97f67 100644..100755 --- a/doc/user/project/issues/img/due_dates_todos.png +++ b/doc/user/project/issues/img/due_dates_todos.png diff --git a/doc/user/project/issues/img/issue_board.png b/doc/user/project/issues/img/issue_board.png Binary files differnew file mode 100755 index 00000000000..1759b28a9ef --- /dev/null +++ b/doc/user/project/issues/img/issue_board.png diff --git a/doc/user/project/issues/img/issue_template.png b/doc/user/project/issues/img/issue_template.png Binary files differnew file mode 100755 index 00000000000..c63229a4af2 --- /dev/null +++ b/doc/user/project/issues/img/issue_template.png diff --git a/doc/user/project/issues/img/issue_tracker.png b/doc/user/project/issues/img/issue_tracker.png Binary files differnew file mode 100755 index 00000000000..ab25cb64d13 --- /dev/null +++ b/doc/user/project/issues/img/issue_tracker.png diff --git a/doc/user/project/issues/img/issues_main_view.png b/doc/user/project/issues/img/issues_main_view.png Binary files differnew file mode 100755 index 00000000000..e9a94a3aab0 --- /dev/null +++ b/doc/user/project/issues/img/issues_main_view.png diff --git a/doc/user/project/issues/img/issues_main_view_numbered.png b/doc/user/project/issues/img/issues_main_view_numbered.png Binary files differnew file mode 100755 index 00000000000..9cff61d7041 --- /dev/null +++ b/doc/user/project/issues/img/issues_main_view_numbered.png diff --git a/doc/user/project/issues/img/mention_in_issue.png b/doc/user/project/issues/img/mention_in_issue.png Binary files differnew file mode 100755 index 00000000000..c762a812138 --- /dev/null +++ b/doc/user/project/issues/img/mention_in_issue.png diff --git a/doc/user/project/issues/img/mention_in_merge_request.png b/doc/user/project/issues/img/mention_in_merge_request.png Binary files differnew file mode 100755 index 00000000000..681e086d6e0 --- /dev/null +++ b/doc/user/project/issues/img/mention_in_merge_request.png diff --git a/doc/user/project/issues/img/merge_request_closes_issue.png b/doc/user/project/issues/img/merge_request_closes_issue.png Binary files differnew file mode 100755 index 00000000000..6fd27738843 --- /dev/null +++ b/doc/user/project/issues/img/merge_request_closes_issue.png diff --git a/doc/user/project/issues/img/new_issue.png b/doc/user/project/issues/img/new_issue.png Binary files differnew file mode 100755 index 00000000000..e72ac49d6b9 --- /dev/null +++ b/doc/user/project/issues/img/new_issue.png diff --git a/doc/user/project/issues/img/new_issue_from_issue_board.png b/doc/user/project/issues/img/new_issue_from_issue_board.png Binary files differnew file mode 100755 index 00000000000..9c2b3ff50fa --- /dev/null +++ b/doc/user/project/issues/img/new_issue_from_issue_board.png diff --git a/doc/user/project/issues/img/new_issue_from_open_issue.png b/doc/user/project/issues/img/new_issue_from_open_issue.png Binary files differnew file mode 100755 index 00000000000..2aed5372830 --- /dev/null +++ b/doc/user/project/issues/img/new_issue_from_open_issue.png diff --git a/doc/user/project/issues/img/new_issue_from_projects_dashboard.png b/doc/user/project/issues/img/new_issue_from_projects_dashboard.png Binary files differnew file mode 100755 index 00000000000..cddf36b7457 --- /dev/null +++ b/doc/user/project/issues/img/new_issue_from_projects_dashboard.png diff --git a/doc/user/project/issues/img/new_issue_from_tracker_list.png b/doc/user/project/issues/img/new_issue_from_tracker_list.png Binary files differnew file mode 100755 index 00000000000..7e5413f0b7d --- /dev/null +++ b/doc/user/project/issues/img/new_issue_from_tracker_list.png diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md new file mode 100644 index 00000000000..c726da17259 --- /dev/null +++ b/doc/user/project/issues/index.md @@ -0,0 +1,100 @@ +# GitLab Issues Documentation + +The GitLab Issue Tracker is an advanced and complete tool +for tracking the evolution of a new idea or the process +of solving a problem. + +It allows you, your team, and your collaborators to share +and discuss proposals, before and while implementing them. + +Issues and the GitLab Issue Tracker are available in all +[GitLab Products](https://about.gitlab.com/products/) as +part of the [GitLab Workflow](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/). + +## Use-Cases + +Issues can have endless applications. Just to exemplify, these are +some cases for which creating issues are most used: + +- Discussing the implementation of a new idea +- Submitting feature proposals +- Asking questions +- Reporting bugs and malfunction +- Obtaining support +- Elaborating new code implementations + +See also the blog post [Always start a discussion with an issue](https://about.gitlab.com/2016/03/03/start-with-an-issue/). + +## Issue Tracker + +The issue tracker is the collection of opened and closed issues created in a project. + +![Issue tracker](img/issue_tracker.png) + +Find the issue tracker by navigating to your **Project's Dashboard** > **Issues**. + +## GitLab Issues Functionalities + +The image bellow illustrates how an issue looks like: + +![Issue view](img/issues_main_view.png) + +Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md). + +## New Issue + +Read through the [documentation on creating issues](create_new_issue.md). + +## Closing issues + +Read through the distinct ways to [close issues](closing_issues.md) on GitLab. + +## Search for an issue + +Learn how to [find an issue](../../search/index.md) by searching for and filtering them. + +## Advanced features + +### Confidential Issues + +Whenever you want to keep the discussion presented in a +issue within your team only, you can make that +[issue confidential](confidential_issues.md). Even if your project +is public, that issue will be preserved. The browser will +respond with a 404 error whenever someone who is not a project +member with at least [Reporter level](../../permissions.md#project) tries to +access that issue's URL. + +Learn more about them on the [confidential issues documentation](confidential_issues.md). + +### Issue templates + +Create templates for every new issue. They will be available from +the dropdown menu **Choose a template** when you create a new issue: + +![issue template](img/issue_template.png) + +Learn more about them on the [issue templates documentation](../../project/description_templates.md#creating-issue-templates). + +### Crosslinking issues + +Learn more about [crosslinking](crosslinking_issues.md) issues and merge requests. + +### GitLab Issue Board + +The [GitLab Issue Board](https://about.gitlab.com/features/issueboard/) is a way to +enhance your workflow by organizing and prioritizing issues in GitLab. + +![Issue board](img/issue_board.png) + +Find GitLab Issue Boards by navigating to your **Project's Dashboard** > **Issues** > **Board**. + +Read through the documentation for [Issue Boards](../issue_board.md) +to find out more about this feature. + +[Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards) +are available only in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/). + +### Issue's API + +Read through the [API documentation](../../../api/issues.md). diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md new file mode 100644 index 00000000000..1efd07a058b --- /dev/null +++ b/doc/user/project/issues/issues_functionalities.md @@ -0,0 +1,157 @@ +# GitLab Issues Functionalities + +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + +## Issues Functionalities + +The image bellow illustrates how an issue looks like: + +![Issue view](img/issues_main_view_numbered.png) + +You can find all the information on that issue on one screen. + +### Issue screen + +An issue starts with its status (open or closed), followed by its author, +and includes many other functionalities, numbered on the image above to +explain what they mean, one by one. + +#### 1. New Issue, close issue, edit + +- New issue: create a new issue in the same project +- Close issue: close this issue +- Edit: edit the same fields available when you create an issue. + +#### 2. Todos + +- Add todo: add that issue to your [GitLab Todo](../../../workflow/todos.html) list +- Mark done: mark that issue as done (reflects on the Todo list) + +#### 3. Assignee + +Whenever someone starts to work on an issue, it can be assigned +to that person. The assignee can be changed as much as needed. +The idea is that the assignee is responsible for that issue until +it's reassigned to someone else to take it from there. + +> **Tip:** +if a user is not member of that project, it can only be +assigned to them if they created the issue themselves. + +#### 4. Milestone + +- Select a [milestone](../milestones/index.md) to attribute that issue to. + +#### 5. Time Tracking (EES/EEP) + +This feature is available only in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/). + +- Estimate time: add an estimate time in which the issue will be implemented +- Spend: add the time spent on the implementation of that issue + +> **Note:** +both estimate and spend times are set via [GitLab Slash Commands](../slash_commands.md). + +Learn more on the [Time Tracking documentation](https://docs.gitlab.com/ee/workflow/time_tracking.html). + +#### 6. Due date + +When you work on a tight schedule, and it's important to +have a way to setup a deadline for implementations and for solving +problems. This can be facilitated by the [due date](due_dates.md)). Due dates +can be changed as many times as needed. + +#### 7. Labels + +Categorize issues by giving them [labels](../labels.md). They help to +organize team's workflows, once they enable you to work with the +[GitLab Issue Board](index.md#gitlab-issue-board). + +Group Labels, which allow you to use the same labels per +group of projects, can be also given to issues. They work exactly the same, +but they are immediately available to all projects in the group. + +> **Tip:** +if the label doesn't exist yet, when you click **Edit**, it opens a dropdown menu from which you can select **Create new label**. + +#### 8. Weight (EES/EEP) + +Issue Weights are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/). + +- Attribute a weight (in a 0 to 9 range) to that issue. Easy to complete +should weight 1 and very hard to complete should weight 9. + +Learn more on the [Issue Weight documentation](https://docs.gitlab.com/ee/workflow/issue_weight.html). + +#### 9. Participants + +- People involved in that issue (mentioned in the description or in the [discussion](../../discussions/index.md)). + +#### 10. Notifications + +- Subscribe: if you are not a participant of the discussion on that issue, but +want to receive notifications on each new input, subscribe to it. +- Unsubscribe: if you are receiving notifications on that issue but no +longer want to receive them, unsubscribe to it. + +Read more on the [notifications documentation](../../../workflow/notifications.md#issue-merge-request-events). + +#### 11. Reference + +- A quick "copy to clipboard" button to that issue's reference, `foo/bar#xxx`, where `foo` is the `username` or `groupname`, `bar` +is the `project-name`, and `xxx` is the issue number. + +#### 12. Title and description + +- Title: a plain text title describing the issue's subject. +- Description: a text field which fully supports [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm). + +#### 13. @mentions + +- Mentions: you can either `@mention` a user or a group present in your +GitLab instance and they will be notified via todos and email, unless that +person has disabled all notifications in their profile settings. + +To change your [notification settings](../../../workflow/notifications.md) navigate to +**Profile Settings** > **Notifications** > **Global notification level** +and choose your preferences from the dropdown menu. + +> **Tip:** +Avoid mentioning `@all` in issues and merge requests, +as it sends an email notification +to all the members of that project's group, which can be +interpreted as spam. + +#### 14. Related Merge Requests + +- Any merge requests mentioned in that issue's description +or in the issue thread. + +#### 15. Award emoji + +- Award an emoji to that issue. + +> **Tip:** +Posting "+1" as comments in threads spam all +participants of that issue. Awarding an emoji is a way to let them +know you like it without spamming them. + +#### 16. Thread + +- Comments: collaborate to that issue by posting comments in its thread. +These text fields also fully support +[GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm). + +#### 17. Comment, start a discusion, or comment and close + +Once you wrote your comment, you can either: + +- Click "Comment" and your comment will be published. +- Click "Start discussion": start a thread within that issue's thread to discuss specific points. +- Click "Comment and close issue": post your comment and close that issue in one click. + +#### 18. New branch + +- [New branch](../repository/web_editor.md#create-a-new-branch-from-an-issue): +create a new branch, followed by a new merge request which will automatically close that +issue as soon as that merge request is merged. diff --git a/features/project/commits/revert.feature b/features/project/commits/revert.feature index 7a2effafe03..7ee1d717d80 100644 --- a/features/project/commits/revert.feature +++ b/features/project/commits/revert.feature @@ -5,12 +5,14 @@ Feature: Revert Commits And I own a project And I visit my project's commits page + @javascript Scenario: I revert a commit Given I click on commit link And I click on the revert button And I revert the changes directly Then I should see the revert commit notice + @javascript Scenario: I revert a commit that was previously reverted Given I click on commit link And I click on the revert button @@ -21,6 +23,7 @@ Feature: Revert Commits And I revert the changes directly Then I should see a revert error + @javascript Scenario: I revert a commit in a new merge request Given I click on commit link And I click on the revert button diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index bcde497553b..a8c528d3d6f 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -26,11 +26,13 @@ Feature: Project Merge Requests When I visit project "Shop" merge requests page Then I should see "feature_conflict" branch + @javascript Scenario: I should not see the numbers of diverged commits if the branch is rebased on the target Given project "Shop" have "Bug NS-07" open merge request with rebased branch When I visit merge request page "Bug NS-07" Then I should not see the diverged commits count + @javascript Scenario: I should see the numbers of diverged commits if the branch diverged from the target Given project "Shop" have "Bug NS-08" open merge request with diverged branch When I visit merge request page "Bug NS-08" @@ -46,21 +48,25 @@ Feature: Project Merge Requests Then I should see "Feature NS-03" in merge requests And I should see "Bug NS-04" in merge requests + @javascript Scenario: I visit an open merge request page Given I click link "Bug NS-04" Then I should see merge request "Bug NS-04" + @javascript Scenario: I visit a merged merge request page Given project "Shop" have "Feature NS-05" merged merge request And I click link "Merged" And I click link "Feature NS-05" Then I should see merge request "Feature NS-05" + @javascript Scenario: I close merge request page Given I click link "Bug NS-04" And I click link "Close" Then I should see closed merge request "Bug NS-04" + @javascript Scenario: I reopen merge request page Given I click link "Bug NS-04" And I click link "Close" @@ -176,6 +182,7 @@ Feature: Project Merge Requests # Markdown + @javascript Scenario: Headers inside the description should have ids generated for them. When I visit merge request page "Bug NS-04" Then Header "Description header" should have correct id and link diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature index 330ec8ae0fe..c45ed9ea68b 100644 --- a/features/project/merge_requests/accept.feature +++ b/features/project/merge_requests/accept.feature @@ -7,7 +7,6 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request and removing the source branch Given I am on the Merge Request detail page - When I click on "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -15,7 +14,6 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request when URL has an anchor Given I am on the Merge Request detail with note anchor page - When I click on "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -23,6 +21,7 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request without removing the source branch Given I am on the Merge Request detail page + When I click on "Remove source branch" option When I click on Accept Merge Request Then I should see merge request merged And I should see the Remove Source Branch button diff --git a/features/steps/project/commits/revert.rb b/features/steps/project/commits/revert.rb index c9746407344..114de129d19 100644 --- a/features/steps/project/commits/revert.rb +++ b/features/steps/project/commits/revert.rb @@ -10,6 +10,7 @@ class Spinach::Features::RevertCommits < Spinach::FeatureSteps end step 'I click on the revert button' do + find(".header-action-buttons .dropdown").click find("a[href='#modal-revert-commit']").click end diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 8081b764be6..310db6e6dad 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -4,6 +4,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps include SharedNote include SharedPaths include Select2Helper + include WaitForVueResource step 'I am a member of project "Shop"' do @project = ::Project.find_by(name: "Shop") @@ -31,6 +32,8 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps expect(page).to have_content @project.path_with_namespace expect(page).to have_content @merge_request.source_branch expect(page).to have_content @merge_request.target_branch + + wait_for_vue_resource end step 'I fill out a "Merge Request On Forked Project" merge request' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 4b7d6cd840b..d15417fa173 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -8,6 +8,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps include SharedDiffNote include SharedUser include WaitForAjax + include WaitForVueResource after do wait_for_ajax if javascript_test? @@ -45,19 +46,23 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within '.merge-request' do expect(page).to have_content "Wiki Feature" end + wait_for_vue_resource end step 'I should see closed merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" expect(page).to have_content "Closed by" + wait_for_vue_resource end step 'I should see merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" + wait_for_vue_resource end step 'I should see merge request "Feature NS-05"' do expect(page).to have_content "Feature NS-05" + wait_for_vue_resource end step 'I should not see "master" branch' do @@ -358,10 +363,12 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a badge of "1" next to the discussion link' do expect_discussion_badge_to_have_counter("1") + wait_for_vue_resource end step 'I should see a badge of "0" next to the discussion link' do expect_discussion_badge_to_have_counter("0") + wait_for_vue_resource end step 'I should see a discussion has started on commit diff' do @@ -369,6 +376,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content sample_commit.line_code_path page.should have_content "Line is wrong" + wait_for_vue_resource end end @@ -376,16 +384,17 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within(".notes .discussion") do page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content "One comment to rule them all" + wait_for_vue_resource end end step 'merge request is mergeable' do - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end step 'I modify merge commit message' do click_button "Modify commit message" - fill_in 'commit_message', with: 'wow such merge' + fill_in 'Commit message', with: 'wow such merge' end step 'merge request "Bug NS-05" is mergeable' do @@ -394,24 +403,26 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I accept this merge request' do page.within '.mr-state-widget' do - click_button "Accept merge request" + click_button "Merge" end end step 'I should see merged request' do page.within '.status-box' do expect(page).to have_content "Merged" + wait_for_vue_resource end end step 'I click link "Reopen"' do - first(:css, '.reopen-mr-link').click + first(:css, '.reopen-mr-link').trigger('click') end step 'I should see reopened merge request "Bug NS-04"' do page.within '.status-box' do expect(page).to have_content "Open" end + wait_for_vue_resource end step 'I click link "Hide inline discussion" of the third file' do @@ -435,6 +446,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a comment like "Line is wrong" in the third file' do page.within '.files>div:nth-child(3) .note-body > .note-text' do expect(page).to have_visible_content "Line is wrong" + wait_for_vue_resource end end @@ -502,6 +514,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see comments on the side-by-side diff page' do page.within '.files>div:nth-child(2) .parallel .note-body > .note-text' do expect(page).to have_visible_content "Line is correct" + wait_for_vue_resource end end @@ -544,6 +557,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps project = merge_request.source_project project.enable_ci pipeline = create :ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch + merge_request.update(head_pipeline: pipeline) create :ci_build, pipeline: pipeline end @@ -557,12 +571,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within ".mr-source-target" do expect(page).to have_content /([0-9]+ commits behind)/ end + + wait_for_vue_resource end step 'I should not see the diverged commits count' do page.within ".mr-source-target" do expect(page).not_to have_content /([0-9]+ commit[s]? behind)/ end + + wait_for_vue_resource end def merge_request diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index 7521a9439e3..3c976f675a2 100644 --- a/features/steps/project/merge_requests/acceptance.rb +++ b/features/steps/project/merge_requests/acceptance.rb @@ -1,7 +1,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper - include WaitForAjax + include WaitForVueResource step 'I am on the Merge Request detail page' do visit merge_request_path(@merge_request) @@ -12,27 +12,27 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps end step 'I click on "Remove source branch" option' do - check('Remove source branch') + uncheck('Remove source branch') end step 'I click on Accept Merge Request' do - click_button('Accept merge request') + click_button('Merge') end step 'I should see the Remove Source Branch button' do - expect(page).to have_link('Remove source branch') + expect(page).to have_selector('.js-remove-branch-button') - # Wait for AJAX requests to complete so they don't blow up if they are + # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run - wait_for_ajax + wait_for_vue_resource end step 'I should not see the Remove Source Branch button' do - expect(page).not_to have_link('Remove source branch') + expect(page).not_to have_selector('.js-remove-branch-button') - # Wait for AJAX requests to complete so they don't blow up if they are + # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run - wait_for_ajax + wait_for_vue_resource end step 'There is an open Merge Request' do diff --git a/features/steps/project/merge_requests/revert.rb b/features/steps/project/merge_requests/revert.rb index 1149c1c2426..aa76d6f8c48 100644 --- a/features/steps/project/merge_requests/revert.rb +++ b/features/steps/project/merge_requests/revert.rb @@ -1,6 +1,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper + include WaitForVueResource step 'I click on the revert button' do find("a[href='#modal-revert-commit']").click @@ -15,6 +16,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps step 'I should see the revert merge request notice' do page.should have_content('The merge request has been successfully reverted.') + wait_for_vue_resource end step 'I should not see the revert button' do @@ -26,7 +28,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps end step 'I click on Accept Merge Request' do - click_button('Accept merge request') + click_button('Merge') end step 'I am signed in as a developer of the project' do diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index d5b3bb34d7a..46b3cb79af2 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -2,6 +2,7 @@ module SharedPaths include Spinach::DSL include RepoHelpers include DashboardHelper + include WaitForVueResource step 'I visit new project page' do visit new_project_path @@ -377,23 +378,28 @@ module SharedPaths step 'I visit merge request page "Bug NS-04"' do visit merge_request_path("Bug NS-04") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-05"' do visit merge_request_path("Bug NS-05") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-07"' do visit merge_request_path("Bug NS-07") + wait_for_vue_resource end step 'I visit merge request page "Bug NS-08"' do visit merge_request_path("Bug NS-08") + wait_for_vue_resource end step 'I visit merge request page "Bug CO-01"' do mr = MergeRequest.find_by(title: "Bug CO-01") visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr) + wait_for_vue_resource end step 'I visit project "Shop" merge requests page' do diff --git a/features/support/env.rb b/features/support/env.rb index 92d13bea4b6..568eeae4479 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -10,7 +10,7 @@ if ENV['CI'] Knapsack::Adapters::SpinachAdapter.bind end -%w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq).each do |f| +%w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq wait_for_vue_resource).each do |f| require Rails.root.join('spec', 'support', f) end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f8f5548d23d..00d494f02f5 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -5,7 +5,10 @@ module API end class UserBasic < UserSafe - expose :id, :state, :avatar_url + expose :id, :state + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url do |user, options| Gitlab::Routing.url_helpers.user_url(user) @@ -97,7 +100,9 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -141,7 +146,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 7c8be7e51db..56a9b019f1b 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -69,7 +69,9 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: ::API::Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -129,7 +131,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 3a73697dc5d..f9a9b767ef4 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -192,6 +192,10 @@ module Gitlab Commit.diff_from_parent(raw_commit, options) end + def deltas + @deltas ||= diff_from_parent.each_delta.map { |d| Gitlab::Git::Diff.new(d) } + end + def has_zero_stats? stats.total.zero? rescue diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 6a0f12b7e50..9ed12ead023 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1112,56 +1112,6 @@ module Gitlab end end - def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n)) - git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive) - - # Put files into a directory before archiving - prefix = "#{archive_name(treeish)}/" - git_archive_cmd << "--prefix=#{prefix}" - - # Format defaults to tar - git_archive_cmd << "--format=#{format}" if format - - git_archive_cmd += %W(-- #{treeish}) - - open(filename, 'w') do |file| - # Create a pipe to act as the '|' in 'git archive ... | gzip' - pipe_rd, pipe_wr = IO.pipe - - # Get the compression process ready to accept data from the read end - # of the pipe - compress_pid = spawn(*nice(compress_cmd), in: pipe_rd, out: file) - # The read end belongs to the compression process now; we should - # close our file descriptor for it. - pipe_rd.close - - # Start 'git archive' and tell it to write into the write end of the - # pipe. - git_archive_pid = spawn(*nice(git_archive_cmd), out: pipe_wr) - # The write end belongs to 'git archive' now; close it. - pipe_wr.close - - # When 'git archive' and the compression process are finished, we are - # done. - Process.waitpid(git_archive_pid) - raise "#{git_archive_cmd.join(' ')} failed" unless $?.success? - Process.waitpid(compress_pid) - raise "#{compress_cmd.join(' ')} failed" unless $?.success? - end - end - - def nice(cmd) - nice_cmd = %w(nice -n 20) - unless unsupported_platform? - nice_cmd += %w(ionice -c 2 -n 7) - end - nice_cmd + cmd - end - - def unsupported_platform? - %w[darwin freebsd solaris].map { |platform| RUBY_PLATFORM.include?(platform) }.any? - end - # Returns true if the index entry has the special file mode that denotes # a submodule. def submodule?(index_entry) diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 8827507955d..37125980b1c 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -13,18 +13,18 @@ module Gitlab json_api_get('query', query: '1') end - def query(query) + def query(query, time: Time.now) get_result('vector') do - json_api_get('query', query: query) + json_api_get('query', query: query, time: time.utc.to_f) end end - def query_range(query, start: 8.hours.ago) + def query_range(query, start: 8.hours.ago, stop: Time.now) get_result('matrix') do json_api_get('query_range', query: query, start: start.to_f, - end: Time.now.utc.to_f, + end: stop.to_f, step: 1.minute.to_i) end end diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake index 1e00b47303d..4108cee08b4 100644 --- a/lib/tasks/migrate/setup_postgresql.rake +++ b/lib/tasks/migrate/setup_postgresql.rake @@ -4,6 +4,7 @@ require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lowe require Rails.root.join('db/migrate/20151008110232_add_users_lower_username_email_indexes') require Rails.root.join('db/migrate/20161212142807_add_lower_path_index_to_routes') require Rails.root.join('db/migrate/20170317203554_index_routes_path_for_like') +require Rails.root.join('db/migrate/20170503185032_index_redirect_routes_path_for_like') desc 'GitLab | Sets up PostgreSQL' task setup_postgresql: :environment do diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index b804dc0436f..1c44ed4b77c 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -7,201 +7,201 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-12 22:37-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"PO-Revision-Date: 2017-05-09 13:44+0200\n" "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" msgid "ByAuthor|by" -msgstr "" +msgstr "Von" msgid "Commit" msgid_plural "Commits" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Commit" +msgstr[1] "Commits" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" +msgstr "Cycle Analytics liefern einen Ãœberblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht." msgid "CycleAnalyticsStage|Code" -msgstr "" +msgstr "Code" msgid "CycleAnalyticsStage|Issue" -msgstr "" +msgstr "Issue" msgid "CycleAnalyticsStage|Plan" -msgstr "" +msgstr "Planung" msgid "CycleAnalyticsStage|Production" -msgstr "" +msgstr "Produktiv" msgid "CycleAnalyticsStage|Review" -msgstr "" +msgstr "Review" msgid "CycleAnalyticsStage|Staging" -msgstr "" +msgstr "Staging" msgid "CycleAnalyticsStage|Test" -msgstr "" +msgstr "Test" msgid "Deploy" msgid_plural "Deploys" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Deployment" +msgstr[1] "Deployments" msgid "FirstPushedBy|First" -msgstr "" +msgstr "Erster" msgid "FirstPushedBy|pushed by" -msgstr "" +msgstr "gepusht von" msgid "From issue creation until deploy to production" -msgstr "" +msgstr "Vom Anlegen des Issues bis zum Produktivdeployment" msgid "From merge request merge until deploy to production" -msgstr "" +msgstr "Vom Merge Request bis zum Produktivdeployment" msgid "Introducing Cycle Analytics" -msgstr "" +msgstr "Was sind Cycle Analytics?" msgid "Last %d day" msgid_plural "Last %d days" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Letzter %d Tag" +msgstr[1] "Letzten %d Tage" msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Eingeschränkt auf maximal %d Ereignis" +msgstr[1] "Eingeschränkt auf maximal %d Ereignisse" msgid "Median" -msgstr "" +msgstr "Median" msgid "New Issue" msgid_plural "New Issues" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Neues Issue" +msgstr[1] "Neue Issues" msgid "Not available" -msgstr "" +msgstr "Nicht verfügbar" msgid "Not enough data" -msgstr "" +msgstr "Nicht genügend Daten" msgid "OpenedNDaysAgo|Opened" -msgstr "" +msgstr "Erstellt" msgid "Pipeline Health" -msgstr "" +msgstr "Pipeline Kennzahlen" msgid "ProjectLifecycle|Stage" -msgstr "" +msgstr "Phase" msgid "Read more" -msgstr "" +msgstr "Mehr" msgid "Related Commits" -msgstr "" +msgstr "Zugehörige Commits" msgid "Related Deployed Jobs" -msgstr "" +msgstr "Zugehörige Deploymentjobs" msgid "Related Issues" -msgstr "" +msgstr "Zugehörige Issues" msgid "Related Jobs" -msgstr "" +msgstr "Zugehörige Jobs" msgid "Related Merge Requests" -msgstr "" +msgstr "Zugehörige Merge Requests" msgid "Related Merged Requests" -msgstr "" +msgstr "Zugehörige abgeschlossene Merge Requests" msgid "Showing %d event" msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Zeige %d Ereignis" +msgstr[1] "Zeige %d Ereignisse" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "" +msgstr "Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt." msgid "The collection of events added to the data gathered for that stage." -msgstr "" +msgstr "Ereignisse, die für diese Phase ausgewertet wurden." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." -msgstr "" +msgstr "Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen." msgid "The phase of the development lifecycle." -msgstr "" +msgstr "Die Phase im Entwicklungsprozess." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "" +msgstr "Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "" +msgstr "Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier." msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." -msgstr "" +msgstr "Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "" +msgstr "Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt." msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." -msgstr "" +msgstr "Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt." msgid "The time taken by each data entry gathered by that stage." -msgstr "" +msgstr "Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." -msgstr "" +msgstr "Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6." msgid "Time before an issue gets scheduled" -msgstr "" +msgstr "Zeit bis ein Issue geplant wird" msgid "Time before an issue starts implementation" -msgstr "" +msgstr "Zeit bis die Implementierung für ein Issue beginnt" msgid "Time between merge request creation and merge/close" -msgstr "" +msgstr "Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests" msgid "Time until first merge request" -msgstr "" +msgstr "Zeit bis zum ersten Merge Request" msgid "Time|hr" msgid_plural "Time|hrs" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "h" +msgstr[1] "h" msgid "Time|min" msgid_plural "Time|mins" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "min" +msgstr[1] "min" msgid "Time|s" -msgstr "" +msgstr "s" msgid "Total Time" -msgstr "" +msgstr "Gesamtzeit" msgid "Total test time for all commits/merges" -msgstr "" +msgstr "Gesamte Testlaufzeit für alle Commits/Merges" msgid "Want to see the data? Please ask an administrator for access." -msgstr "" +msgstr "Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator." msgid "We don't have enough data to show this stage." -msgstr "" +msgstr "Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen." msgid "You need permission." -msgstr "" +msgstr "Sie benötigen Zugriffsrechte." msgid "day" msgid_plural "days" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "Tag" +msgstr[1] "Tage" diff --git a/scripts/trigger-build b/scripts/trigger-build new file mode 100755 index 00000000000..741e6361f01 --- /dev/null +++ b/scripts/trigger-build @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' + +uri = URI('https://gitlab.com/api/v4/projects/20699/trigger/pipeline') +params = { + "ref" => ENV["OMNIBUS_BRANCH"] || "master", + "token" => ENV["BUILD_TRIGGER_TOKEN"], + "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"], + "variables[ALTERNATIVE_SOURCES]" => true, +} + +Dir.glob("*_VERSION").each do |version_file| + params["variables[#{version_file}]"] = File.read(version_file).strip +end + +res = Net::HTTP.post_form(uri, params) +pipeline_id = JSON.parse(res.body)['id'] + +puts "Triggered pipeline can be found at https://gitlab.com/gitlab-org/omnibus-gitlab/pipelines/#{pipeline_id}" diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 8f915d9d210..f285e5333d6 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -213,33 +213,98 @@ describe Projects::BranchesController do sign_in(user) post :destroy, - format: :js, - id: branch, - namespace_id: project.namespace, - project_id: project + format: format, + id: branch, + namespace_id: project.namespace, + project_id: project end - context "valid branch name, valid source" do + context 'as JS' do let(:branch) { "feature" } + let(:format) { :js } - it { expect(response).to have_http_status(200) } - end + context "valid branch name, valid source" do + let(:branch) { "feature" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with unencoded slashes" do + let(:branch) { "improve/awesome" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with encoded slashes" do + let(:branch) { "improve%2Fawesome" } - context "valid branch name with unencoded slashes" do - let(:branch) { "improve/awesome" } + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end - it { expect(response).to have_http_status(200) } + context "invalid branch name, valid ref" do + let(:branch) { "no-branch" } + + it { expect(response).to have_http_status(404) } + it { expect(response.body).to be_blank } + end end - context "valid branch name with encoded slashes" do - let(:branch) { "improve%2Fawesome" } + context 'as JSON' do + let(:branch) { "feature" } + let(:format) { :json } + + context 'valid branch name, valid source' do + let(:branch) { "feature" } - it { expect(response).to have_http_status(200) } + it 'returns JSON response with message' do + expect(json_response).to eql("message" => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'valid branch name with unencoded slashes' do + let(:branch) { "improve/awesome" } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context "valid branch name with encoded slashes" do + let(:branch) { 'improve%2Fawesome' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'invalid branch name, valid ref' do + let(:branch) { 'no-branch' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'No such branch') + end + + it { expect(response).to have_http_status(404) } + end end - context "invalid branch name, valid ref" do - let(:branch) { "no-branch" } - it { expect(response).to have_http_status(404) } + context 'as HTML' do + let(:branch) { "feature" } + let(:format) { :html } + + it 'redirects to branches path' do + expect(response) + .to redirect_to(namespace_project_branches_path(project.namespace, project)) + end end end diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index 89692b601b2..3de38bb4dac 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -8,7 +8,7 @@ describe Projects::DeploymentsController do let(:environment) { create(:environment, name: 'production', project: project) } before do - project.add_master(user) + project.team << [user, :master] sign_in(user) end @@ -19,7 +19,7 @@ describe Projects::DeploymentsController do create(:deployment, environment: environment, created_at: 7.hours.ago) create(:deployment, environment: environment) - get :index, environment_params(after: 8.hours.ago) + get :index, deployment_params(after: 8.hours.ago) expect(response).to be_ok @@ -29,14 +29,59 @@ describe Projects::DeploymentsController do it 'returns a list with deployments information' do create(:deployment, environment: environment) - get :index, environment_params + get :index, deployment_params expect(response).to be_ok expect(response).to match_response_schema('deployments') end end - def environment_params(opts = {}) - opts.reverse_merge(namespace_id: project.namespace, project_id: project, environment_id: environment.id) + describe 'GET #metrics' do + let(:deployment) { create(:deployment, project: project, environment: environment) } + + before do + allow(controller).to receive(:deployment).and_return(deployment) + end + + context 'when environment has no metrics' do + before do + expect(deployment).to receive(:metrics).and_return(nil) + end + + it 'returns a empty response 204 resposne' do + get :metrics, deployment_params(id: deployment.id) + expect(response).to have_http_status(204) + expect(response.body).to eq('') + end + end + + context 'when environment has some metrics' do + let(:empty_metrics) do + { + success: true, + metrics: {}, + last_update: 42 + } + end + + before do + expect(deployment).to receive(:metrics).and_return(empty_metrics) + end + + it 'returns a metrics JSON document' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + end + + def deployment_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, + project_id: project, + environment_id: environment.id) end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 5c478534ff3..c0f8c36a018 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -149,6 +149,48 @@ describe Projects::EnvironmentsController do end end + describe 'PATCH #stop' do + context 'when env not available' do + it 'returns 404' do + allow_any_instance_of(Environment).to receive(:available?) { false } + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(404) + end + end + + context 'when stop action' do + it 'returns action url' do + action = create(:ci_build, :manual) + + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_action!: action) + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(200) + expect(json_response).to eq( + { 'redirect_url' => + "http://test.host/#{project.path_with_namespace}/builds/#{action.id}" }) + end + end + + context 'when no stop action' do + it 'returns env url' do + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_action!: nil) + + patch :stop, environment_params(format: :json) + + expect(response).to have_http_status(200) + expect(json_response).to eq( + { 'redirect_url' => + "http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" }) + end + end + end + describe 'GET #terminal' do context 'with valid id' do it 'responds with a status code 200' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 0483c6b7879..37a253fde9b 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -59,6 +59,18 @@ describe Projects::MergeRequestsController do end end + describe 'GET commit_change_content' do + it 'renders commit_change_content template' do + get :commit_change_content, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + format: 'html' + + expect(response).to render_template('_commit_change_content') + end + end + shared_examples "loads labels" do |action| it "loads labels into the @labels variable" do get action, @@ -71,63 +83,47 @@ describe Projects::MergeRequestsController do end describe "GET show" do - shared_examples "export merge as" do |format| - it "does generally work" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + def go(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid + } - expect(response).to be_success - end + get :show, params.merge(extra_params) + end - it_behaves_like "loads labels", :show + it_behaves_like "loads labels", :show - it "generates it" do - expect_any_instance_of(MergeRequest).to receive(:"to_#{format}") + describe 'as html' do + it "renders merge request page" do + go(format: :html) - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + expect(response).to be_success end + end - it "renders it" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + describe 'as json' do + context 'with basic param' do + it 'renders basic MR entity as json' do + go(basic: true, format: :json) - expect(response.body).to eq(merge_request.send(:"to_#{format}").to_s) + expect(response).to match_response_schema('entities/merge_request_basic') + end end - it "does not escape Html" do - allow_any_instance_of(MergeRequest).to receive(:"to_#{format}"). - and_return('HTML entities &<>" ') + context 'without basic param' do + it 'renders the merge request in the json format' do + go(format: :json) - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) - - expect(response.body).not_to include('&') - expect(response.body).not_to include('>') - expect(response.body).not_to include('<') - expect(response.body).not_to include('"') + expect(response).to match_response_schema('entities/merge_request') + end end end describe "as diff" do it "triggers workhorse to serve the request" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :diff) + go(format: :diff) expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-diff:") end @@ -135,11 +131,7 @@ describe Projects::MergeRequestsController do describe "as patch" do it 'triggers workhorse to serve the request' do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :patch) + go(format: :patch) expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-format-patch:") end @@ -295,19 +287,18 @@ describe Projects::MergeRequestsController do namespace_id: project.namespace, project_id: project, id: merge_request.iid, - format: 'raw' + format: 'json' } end - context 'when the user does not have access' do + context 'when user cannot access' do before do - project.team.truncate - project.team << [user, :reporter] - post :merge, base_params + project.add_reporter(user) + xhr :post, :merge, base_params end - it 'returns not found' do - expect(response).to be_not_found + it 'returns 404' do + expect(response).to have_http_status(404) end end @@ -319,7 +310,7 @@ describe Projects::MergeRequestsController do end it 'returns :failed' do - expect(assigns(:status)).to eq(:failed) + expect(json_response).to eq('status' => 'failed') end end @@ -327,7 +318,7 @@ describe Projects::MergeRequestsController do before { post :merge, base_params.merge(sha: 'foo') } it 'returns :sha_mismatch' do - expect(assigns(:status)).to eq(:sha_mismatch) + expect(json_response).to eq('status' => 'sha_mismatch') end end @@ -339,7 +330,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end it 'starts the merge immediately' do @@ -354,13 +345,14 @@ describe Projects::MergeRequestsController do end before do - create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + pipeline = create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + merge_request.update(head_pipeline: pipeline) end it 'returns :merge_when_pipeline_succeeds' do merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds') end it 'sets the MR to merge when the pipeline succeeds' do @@ -382,7 +374,7 @@ describe Projects::MergeRequestsController do it 'returns :merge_when_pipeline_succeeds' do merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds') end end end @@ -403,7 +395,7 @@ describe Projects::MergeRequestsController do it 'returns :failed' do merge_with_sha - expect(assigns(:status)).to eq(:failed) + expect(json_response).to eq('status' => 'failed') end end @@ -416,7 +408,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end end @@ -434,7 +426,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end @@ -447,7 +439,7 @@ describe Projects::MergeRequestsController do it 'returns :success' do merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(json_response).to eq('status' => 'success') end end end @@ -831,18 +823,55 @@ describe Projects::MergeRequestsController do end end - context 'POST remove_wip' do - it 'removes the wip status' do + describe 'POST remove_wip' do + before do merge_request.title = merge_request.wip_title merge_request.save - post :remove_wip, - namespace_id: merge_request.project.namespace.to_param, - project_id: merge_request.project, - id: merge_request.iid + xhr :post, :remove_wip, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project, + id: merge_request.iid, + format: :json + end + it 'removes the wip status' do expect(merge_request.reload.title).to eq(merge_request.wipless_title) end + + it 'renders MergeRequest as JSON' do + expect(json_response.keys).to include('id', 'iid', 'description') + end + end + + describe 'POST cancel_merge_when_pipeline_succeeds' do + subject do + xhr :post, :cancel_merge_when_pipeline_succeeds, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project, + id: merge_request.iid, + format: :json + end + + it 'calls MergeRequests::MergeWhenPipelineSucceedsService' do + mwps_service = double + + allow(MergeRequests::MergeWhenPipelineSucceedsService) + .to receive(:new) + .and_return(mwps_service) + + expect(mwps_service).to receive(:cancel).with(merge_request) + + subject + end + + it { is_expected.to have_http_status(:success) } + + it 'renders MergeRequest as JSON' do + subject + + expect(json_response.keys).to include('id', 'iid', 'description') + end end describe 'GET conflict_for_path' do @@ -1121,74 +1150,6 @@ describe Projects::MergeRequestsController do end end - describe 'GET merge_widget_refresh' do - let(:params) do - { - namespace_id: project.namespace, - project_id: project, - id: merge_request.iid, - format: :raw - } - end - - before do - project.team << [user, :developer] - xhr :get, :merge_widget_refresh, params - end - - context 'when merge in progress' do - let(:merge_request) { create(:merge_request, source_project: project, in_progress_merge_commit_sha: 'sha') } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - - context 'when merge request was merged already' do - let(:merge_request) { create(:merge_request, source_project: project, state: :merged) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - - context 'when waiting for build' do - let(:merge_request) { create(:merge_request, source_project: project, merge_when_pipeline_succeeds: true, merge_user: user) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to :merge_when_pipeline_succeeds' do - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) - expect(response).to render_template('merge') - end - end - - context 'when MR does not have special state' do - let(:merge_request) { create(:merge_request, source_project: project) } - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - - it 'sets status to success' do - expect(assigns(:status)).to eq(:success) - expect(response).to render_template('merge') - end - end - end - describe 'GET pipeline_status.json' do context 'when head_pipeline exists' do let!(:pipeline) do @@ -1199,7 +1160,10 @@ describe Projects::MergeRequestsController do let(:status) { pipeline.detailed_status(double('user')) } - before { get_pipeline_status } + before do + merge_request.update(head_pipeline: pipeline) + get_pipeline_status + end it 'return a detailed head_pipeline status in json' do expect(response).to have_http_status(:ok) diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index c50155a6d14..bfa2a72a256 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -38,6 +38,8 @@ describe 'Issue Boards', :feature, :js do it 'moves un-ordered issue to top of list' do drag(from_index: 3, to_index: 0) + wait_for_vue_resource + page.within(first('.board')) do expect(first('.card')).to have_content(issue4.title) end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 7c9d522273b..cbeb73d9cae 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -6,14 +6,16 @@ feature 'Cycle Analytics', feature: true, js: true do let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } - let(:mr) { create_merge_request_closing_issue(issue) } + let(:mr) { create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) } context 'as an allowed user' do context 'when project is new' do before do - project.team << [user, :master] + project.add_master(user) + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) wait_for_ajax end @@ -30,9 +32,10 @@ feature 'Cycle Analytics', feature: true, js: true do context "when there's cycle analytics data" do before do - project.team << [user, :master] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + mr.update(head_pipeline: pipeline) + project.add_master(user) + create_cycle deploy_master @@ -85,7 +88,7 @@ feature 'Cycle Analytics', feature: true, js: true do context "as a guest" do before do - project.team << [guest, :guest] + project.add_guest(guest) allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) create_cycle diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 608aedd3471..902d3f789ff 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -68,7 +68,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(owner.name) @@ -76,7 +76,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index f3ec80bb149..414838fa22e 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -52,6 +52,9 @@ describe 'issuable list', feature: true do create(:issue, project: project, author: user) else create(:merge_request, source_project: project, source_branch: generate(:branch)) + source_branch = FFaker::Name.name + pipeline = create(:ci_empty_pipeline, project: project, ref: source_branch, status: %w(running failed success).sample, sha: 'any') + create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline) end 2.times do diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 58f897cba3e..dc13cab2cd1 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -49,7 +49,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'open an issue to resolve them later' + expect(page).not_to have_link 'Create an issue to resolve them later' end end @@ -59,18 +59,18 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'shows a warning that the merge request contains unresolved discussions' do - expect(page).to have_content 'This merge request has unresolved discussions' + expect(page).to have_content 'There are unresolved discussions.' end it 'has a link to resolve all discussions by creating an issue' do page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + expect(page).to have_link 'Create an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for discussions' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + page.click_link 'Create an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end it_behaves_like 'creating an issue for a discussion' diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 11d417c253d..c82e8c03343 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -41,7 +41,7 @@ feature 'Login', feature: true do expect(page).to have_content('Your account has been blocked.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do user = create(:user, :blocked) expect { login_with(user) }.not_to change { user.reload.sign_in_count } @@ -55,7 +55,7 @@ feature 'Login', feature: true do expect(page).to have_content('Invalid Login or password.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do expect { login_with(User.ghost) }.not_to change { User.ghost.reload.sign_in_count } end end diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index ec49003772b..b306e2f5f75 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -18,7 +18,7 @@ feature 'Merge request issue assignment', js: true, feature: true do end context 'logged in as author' do - scenario 'updates related issues' do + it 'updates related issues' do visit_merge_request click_link "Assign yourself to these issues" diff --git a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb index 77b7ba4ac7a..fa306c02a43 100644 --- a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb +++ b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb @@ -19,8 +19,8 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'does not allow to merge' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' - expect(page).to have_content('This merge request has unresolved discussions') + expect(page).not_to have_button 'Merge' + expect(page).to have_content('There are unresolved discussions.') end end @@ -32,7 +32,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end end @@ -46,7 +46,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'does not allow to merge' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end @@ -58,7 +58,7 @@ feature 'Check if mergeable with unresolved discussions', js: true, feature: tru it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + expect(page).to have_button 'Merge' end end end diff --git a/spec/features/merge_requests/cherry_pick_spec.rb b/spec/features/merge_requests/cherry_pick_spec.rb index dfe7c910a10..6ba681e36f7 100644 --- a/spec/features/merge_requests/cherry_pick_spec.rb +++ b/spec/features/merge_requests/cherry_pick_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Cherry-pick Merge Requests' do +describe 'Cherry-pick Merge Requests', js: true do let(:user) { create(:user) } let(:group) { create(:group) } let(:project) { create(:project, namespace: group) } diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb index eafcab6a0d7..ee0880a1e2f 100644 --- a/spec/features/merge_requests/closes_issues_spec.rb +++ b/spec/features/merge_requests/closes_issues_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -feature 'Merge Request closing issues message', feature: true do +feature 'Merge Request closing issues message', feature: true, js: true do + include WaitForAjax + let(:user) { create(:user) } let(:project) { create(:project, :public) } let(:issue_1) { create(:issue, project: project)} @@ -23,6 +25,7 @@ feature 'Merge Request closing issues message', feature: true do login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) + wait_for_ajax end context 'not closing or mentioning any issue' do @@ -35,7 +38,7 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_description) { "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}") + expect(page).to have_content("Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}") end end @@ -51,7 +54,8 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Closes issue #{issue_1.to_reference}.") + expect(page).to have_content("Issue #{issue_2.to_reference} is mentioned but will not be closed.") end end @@ -59,7 +63,7 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closing #{issue_1.to_reference}, #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}") + expect(page).to have_content("Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}") end end @@ -75,7 +79,8 @@ feature 'Merge Request closing issues message', feature: true do let(:merge_request_title) { "closes #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" } it 'does not display closing issue message' do - expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Closes issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not be closed.") + expect(page).to have_content("Issue #{issue_2.to_reference} is mentioned but will not be closed.") end end end diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 18833ba7266..bf34c99b92a 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -31,7 +31,7 @@ feature 'Merge request created from fork' do fork_project.destroy! end - scenario 'user can access merge request' do + scenario 'user can access merge request', js: true do visit_merge_request(merge_request) expect(page).to have_content 'Test merge request' diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb index 648678e2b1a..01e5e4f3a05 100644 --- a/spec/features/merge_requests/deleted_source_branch_spec.rb +++ b/spec/features/merge_requests/deleted_source_branch_spec.rb @@ -20,7 +20,7 @@ describe 'Deleted source branch', feature: true, js: true do it 'shows a message about missing source branch' do expect(page).to have_content( - 'Source branch this-branch-does-not-exist does not exist' + 'Source branch does not exist.' ) end @@ -35,6 +35,6 @@ describe 'Deleted source branch', feature: true, js: true do wait_for_ajax expect(page).to have_selector('.diffs.tab-pane .nothing-here-block') - expect(page).to have_content('Nothing to merge from this-branch-does-not-exist into feature') + expect(page).to have_content('Source branch does not exist.') end end diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index cb3bc392903..ec87a99b3ab 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -29,18 +29,6 @@ feature 'Edit Merge Request', feature: true do expect(page).to have_content 'Someone edited the merge request the same time you did' end - it 'allows to unselect "Remove source branch"' do - merge_request.update(merge_params: { 'force_remove_source_branch' => '1' }) - expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy - - visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) - uncheck 'Remove source branch when merge request is accepted' - - click_button 'Save changes' - - expect(page).to have_content 'Remove source branch' - end - it 'should preserve description textarea height', js: true do long_description = %q( Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat. diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb index 1bc2a5548dd..221ddb5873c 100644 --- a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb +++ b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb @@ -14,8 +14,6 @@ feature 'Clicking toggle commit message link', feature: true, js: true do ) end let(:textbox) { page.find(:css, '.js-commit-message', visible: false) } - let(:include_link) { page.find(:css, '.js-with-description-link', visible: false) } - let(:do_not_include_link) { page.find(:css, '.js-without-description-link', visible: false) } let(:default_message) do [ "Merge branch 'feature' into 'master'", @@ -40,7 +38,7 @@ feature 'Clicking toggle commit message link', feature: true, js: true do visit namespace_project_merge_request_path(project.namespace, project, merge_request) - expect(textbox).not_to be_visible + expect(page).not_to have_selector('.js-commit-message') click_button "Modify commit message" expect(textbox).to be_visible end @@ -56,19 +54,4 @@ feature 'Clicking toggle commit message link', feature: true, js: true do expect(textbox.value).to eq(default_message) end - - it "toggles link between 'Include description' and 'Don't include description'" do - expect(include_link).to be_visible - expect(do_not_include_link).not_to be_visible - - click_link "Include description in commit message" - - expect(include_link).not_to be_visible - expect(do_not_include_link).to be_visible - - click_link "Don't include description in commit message" - - expect(include_link).to be_visible - expect(do_not_include_link).not_to be_visible - end end diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb index 497240803d4..5820784f8e7 100644 --- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb +++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb @@ -4,16 +4,18 @@ feature 'Merge immediately', :feature, :js do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) do + let!(:merge_request) do create(:merge_request_with_diffs, source_project: project, author: user, - title: 'Bug NS-04') + title: 'Bug NS-04', + head_pipeline: pipeline, + source_branch: pipeline.ref) end let(:pipeline) do create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch) + ref: 'master', + sha: project.repository.commit('master').id) end before { project.team << [user, :master] } @@ -34,7 +36,7 @@ feature 'Merge immediately', :feature, :js do click_link 'Merge immediately' - expect(find('.js-merge-when-pipeline-succeeds-button')).to have_content('Merge in progress') + expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') wait_for_ajax end diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index cd540ca113a..11b6f0c0a64 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -16,7 +16,10 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do ref: merge_request.source_branch) end - before { project.team << [user, :master] } + before do + project.add_master(user) + merge_request.update(head_pipeline_id: pipeline.id) + end context 'when there is active pipeline for merge request' do background do @@ -38,8 +41,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do click_button "Merge when pipeline succeeds" expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will not be removed." - expect(page).to have_link "Cancel automatic merge" + expect(page).to have_content "The source branch will be removed." + expect(page).to have_selector ".js-cancel-auto-merge" visit_merge_request(merge_request) # Needed to refresh the page expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i end @@ -93,12 +96,10 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do describe 'enabling Merge when pipeline succeeds via dropdown' do it 'activates the Merge when pipeline succeeds feature' do click_button 'Select merge moment' - within('.js-merge-dropdown') do - click_link 'Merge when pipeline succeeds' - end + click_link 'Merge when pipeline succeeds' expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will not be removed." + expect(page).to have_content "The source branch will be removed." expect(page).to have_link "Cancel automatic merge" end end @@ -131,13 +132,6 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do expect(page).to have_content "canceled the automatic merge" end - it "allows the user to remove the source branch" do - expect(page).to have_link "Remove source branch when merged" - - click_link "Remove source branch when merged" - expect(page).to have_content "The source branch will be removed" - end - context 'when pipeline succeeds' do background { build.success } diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 449a60c1d05..5b2798af32f 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' feature 'Mini Pipeline Graph', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) { create(:merge_request, source_project: project) } + let(:merge_request) { create(:merge_request, source_project: project, head_pipeline: pipeline) } let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) } let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb index 4a590e3bf68..cdda0542c51 100644 --- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb +++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true do +feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true, js: true do + include WaitForVueResource + let(:merge_request) { create(:merge_request_with_diffs) } let(:project) { merge_request.target_project } @@ -10,15 +12,17 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu project.team << [merge_request.author, :master] end - context 'project does not have CI enabled' do + context 'project does not have CI enabled', js: true do it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end - context 'when project has CI enabled' do + context 'when project has CI enabled', js: true do given!(:pipeline) do create(:ci_empty_pipeline, project: project, @@ -27,6 +31,8 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu status: status) end + before { merge_request.update(head_pipeline: pipeline) } + context 'when merge requests can only be merged if the pipeline succeeds' do before do project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true) @@ -38,6 +44,8 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow to merge immediately' do visit_merge_request(merge_request) + wait_for_vue_resource + expect(page).to have_button 'Merge when pipeline succeeds' expect(page).not_to have_button 'Select merge moment' end @@ -49,7 +57,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_css('button[disabled="disabled"]', text: 'Merge') expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') end end @@ -60,7 +70,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) - expect(page).not_to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).not_to have_button 'Merge' expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') end end @@ -71,7 +83,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end @@ -81,7 +95,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end end @@ -94,9 +110,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu context 'when CI is running' do given(:status) { :running } - it 'allows MR to be merged immediately', js: true do + it 'allows MR to be merged immediately' do visit_merge_request(merge_request) + wait_for_vue_resource + expect(page).to have_button 'Merge when pipeline succeeds' click_button 'Select merge moment' @@ -110,7 +128,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end @@ -120,7 +140,9 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) - expect(page).to have_button 'Accept merge request' + wait_for_vue_resource + + expect(page).to have_button 'Merge' end end end diff --git a/spec/features/merge_requests/target_branch_spec.rb b/spec/features/merge_requests/target_branch_spec.rb index b6134540273..c154cf8ade9 100644 --- a/spec/features/merge_requests/target_branch_spec.rb +++ b/spec/features/merge_requests/target_branch_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Target branch', feature: true do +describe 'Target branch', feature: true, js: true do let(:user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } @@ -17,11 +17,6 @@ describe 'Target branch', feature: true do project.team << [user, :master] end - it 'shows link to target branch' do - visit path_to_merge_request - expect(page).to have_link('feature', href: namespace_project_commits_path(project.namespace, project, merge_request.target_branch)) - end - context 'when branch was deleted' do before do DeleteBranchService.new(project, user).execute('feature') @@ -30,12 +25,12 @@ describe 'Target branch', feature: true do it 'shows a message about missing target branch' do expect(page).to have_content( - 'Target branch feature does not exist' + 'Target branch does not exist' ) end it 'does not show link to target branch' do - expect(page).not_to have_link('feature') + expect(page).not_to have_selector('.mr-widget-body .js-branch-text a') end end end diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb index 00d191ddf2c..8370499f6ed 100644 --- a/spec/features/merge_requests/widget_deployments_spec.rb +++ b/spec/features/merge_requests/widget_deployments_spec.rb @@ -21,7 +21,7 @@ feature 'Widget Deployments Header', feature: true, js: true do wait_for_ajax expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.ci_widget > span > span')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) + expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) end context 'with stop action' do @@ -38,11 +38,11 @@ feature 'Widget Deployments Header', feature: true, js: true do end scenario 'does show stop button' do - expect(page).to have_link('Stop environment') + expect(page).to have_button('Stop environment') end scenario 'does start build when stop button clicked' do - click_link('Stop environment') + click_button('Stop environment') expect(page).to have_content('close_app') end @@ -51,7 +51,7 @@ feature 'Widget Deployments Header', feature: true, js: true do given(:role) { :reporter } scenario 'does not show stop button' do - expect(page).not_to have_link('Stop environment') + expect(page).not_to have_button('Stop environment') end end end diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index d918181a238..ae799584c0f 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -30,6 +30,7 @@ describe 'Merge request', :feature, :js do wait_for_ajax expect(page).to have_selector('.accept-merge-request') + expect(find('.accept-merge-request')['disabled']).not_to be(true) end end @@ -51,14 +52,15 @@ describe 'Merge request', :feature, :js do page.within('.mr-widget-heading') do expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.js-environment-link')[:href]).to include(environment.formatted_external_url) + expect(find('.js-deploy-url')[:href]).to include(environment.formatted_external_url) end end it 'shows green accept merge request button' do # Wait for the `ci_status` and `merge_check` requests wait_for_ajax - expect(page).to have_selector('.accept-merge-request.btn-create') + expect(page).to have_selector('.accept-merge-request') + expect(find('.accept-merge-request')['disabled']).not_to be(true) end end @@ -89,6 +91,8 @@ describe 'Merge request', :feature, :js do statuses: [commit_status]) create(:ci_build, :pending, pipeline: pipeline) + merge_request.update(head_pipeline: pipeline) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end @@ -101,10 +105,15 @@ describe 'Merge request', :feature, :js do context 'when merge request is in the blocked pipeline state' do before do - create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch, - status: :manual) + pipeline = create( + :ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: :manual + ) + + merge_request.update(head_pipeline: pipeline) visit namespace_project_merge_request_path(project.namespace, project, @@ -129,13 +138,36 @@ describe 'Merge request', :feature, :js do statuses: [commit_status]) create(:ci_build, :pending, pipeline: pipeline) + merge_request.update(head_pipeline: pipeline) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end it 'has info button when MWBS button' do # Wait for the `ci_status` and `merge_check` requests wait_for_ajax - expect(page).to have_selector('.merge-when-pipeline-succeeds.btn-info') + expect(page).to have_selector('.accept-merge-request.btn-info') + end + end + + context 'view merge request with MWPS enabled but automatically merge fails' do + before do + merge_request.update( + merge_when_pipeline_succeeds: true, + merge_user: merge_request.author, + merge_error: 'Something went wrong' + ) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows information about the merge error' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + + page.within('.mr-widget-body') do + expect(page).to have_content('Something went wrong') + end end end @@ -164,11 +196,11 @@ describe 'Merge request', :feature, :js do before do allow_any_instance_of(Repository).to receive(:merge).and_return(false) visit namespace_project_merge_request_path(project.namespace, project, merge_request) - click_button 'Accept merge request' - wait_for_ajax end it 'updates the MR widget' do + click_button 'Merge' + page.within('.mr-widget-body') do expect(page).to have_content('Conflicts detected during merge') end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index c7a32a65e49..b7ae5f0b925 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -68,7 +68,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(master.name) @@ -76,7 +76,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index 957baac02eb..698eb46573f 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -78,9 +78,11 @@ describe 'Comments on personal snippets', :js, feature: true do end page.within("#notes-list li#note_#{snippet_notes[0].id}") do + edited_text = find('.edited-text') + expect(page).to have_css('.note_edited_ago') expect(page).to have_content('new content') - expect(find('.note_edited_ago').text).to match(/less than a minute ago/) + expect(edited_text).to have_selector('.note_edited_ago') end end end diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json new file mode 100644 index 00000000000..0a7e0e2d5f2 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -0,0 +1,98 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "assignee_id": { "type": ["integer", "null"] }, + "author_id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "lock_version": { "type": ["string", "null"] }, + "milestone_id": { "type": ["string", "null"] }, + "position": { "type": "integer" }, + "state": { "type": "string" }, + "title": { "type": "string" }, + "updated_by_id": { "type": ["string", "null"] }, + "created_at": { "type": "string" }, + "updated_at": { "type": "string" }, + "deleted_at": { "type": ["string", "null"] }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "in_progress_merge_commit_sha": { "type": ["string", "null"] }, + "locked_at": { "type": ["string", "null"] }, + "merge_error": { "type": ["string", "null"] }, + "merge_commit_sha": { "type": ["string", "null"] }, + "merge_params": { "type": ["object", "null"] }, + "merge_status": { "type": "string" }, + "merge_user_id": { "type": ["integer", "null"] }, + "merge_when_pipeline_succeeds": { "type": "boolean" }, + "source_branch": { "type": "string" }, + "source_project_id": { "type": "integer" }, + "target_branch": { "type": "string" }, + "target_project_id": { "type": "integer" }, + "merge_event": { "type": ["object", "null"] }, + "closed_event": { "type": ["object", "null"] }, + "author": { "type": ["object", "null"] }, + "merge_user": { "type": ["object", "null"] }, + "diff_head_sha": { "type": ["string", "null"] }, + "diff_head_commit_short_id": { "type": ["string", "null"] }, + "merge_commit_message": { "type": ["string", "null"] }, + "pipeline": { "type": ["object", "null"] }, + "work_in_progress": { "type": "boolean" }, + "source_branch_exists": { "type": "boolean" }, + "mergeable_discussions_state": { "type": "boolean" }, + "conflicts_can_be_resolved_in_ui": { "type": "boolean" }, + "branch_missing": { "type": "boolean" }, + "has_conflicts": { "type": "boolean" }, + "can_be_merged": { "type": "boolean" }, + "project_archived": { "type": "boolean" }, + "only_allow_merge_if_pipeline_succeeds": { "type": "boolean" }, + "has_ci": { "type": "boolean" }, + "ci_status": { "type": ["string", "null"] }, + "issues_links": { + "type": "object", + "required": ["closing", "mentioned_but_not_closing", "assign_to_closing"], + "properties" : { + "closing": { "type": "string" }, + "mentioned_but_not_closing": { "type": "string" }, + "assign_to_closing": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "source_branch_with_namespace_link": { "type": "string" }, + "current_user": { + "type": "object", + "required": [ + "can_remove_source_branch", + "can_revert_on_current_merge_request", + "can_cherry_pick_on_current_merge_request" + ], + "properties": { + "can_remove_source_branch": { "type": "boolean" }, + "can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, + "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] } + }, + "additionalProperties": false + }, + "target_branch_commits_path": { "type": "string" }, + "source_branch_path": { "type": "string" }, + "conflict_resolution_path": { "type": ["string", "null"] }, + "cancel_merge_when_pipeline_succeeds_path": { "type": "string" }, + "create_issue_to_resolve_discussions_path": { "type": "string" }, + "merge_path": { "type": "string" }, + "cherry_pick_in_fork_path": { "type": ["string", "null"] }, + "revert_in_fork_path": { "type": ["string", "null"] }, + "email_patches_path": { "type": "string" }, + "plain_diff_path": { "type": "string" }, + "status_path": { "type": "string" }, + "merge_check_path": { "type": "string" }, + "ci_environments_status_path": { "type": "string" }, + "merge_commit_message_with_description": { "type": "string" }, + "diverged_commits_count": { "type": "integer" }, + "commit_change_content_path": { "type": "string" }, + "remove_wip_path": { "type": "string" }, + "commits_count": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json new file mode 100644 index 00000000000..ea6364b878c --- /dev/null +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties" : { + "state": { "type": "string" }, + "merge_status": { "type": "string" }, + "source_branch_exists": { "type": "boolean" }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["string", "null"] }, + "human_total_time_spent": { "type": ["string", "null"] }, + "merge_error": { "type": ["string", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 01bdf01ad22..785fb724132 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe ApplicationHelper do include UploadHelpers + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + describe 'current_controller?' do it 'returns true when controller matches argument' do stub_controller_name('foo') @@ -56,8 +58,14 @@ describe ApplicationHelper do describe 'project_icon' do it 'returns an url for the avatar' do project = create(:empty_project, avatar: File.open(uploaded_image_temp_path)) + avatar_url = "/uploads/project/avatar/#{project.id}/banana_sample.gif" + + expect(helper.project_icon(project.full_path).to_s). + to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/project/avatar/#{project.id}/banana_sample.gif" - avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif" expect(helper.project_icon(project.full_path).to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -67,9 +75,8 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) - avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" - expect(helper.project_icon(project.full_path).to_s).to match( - image_tag(avatar_url)) + avatar_url = "#{gitlab_host}#{namespace_project_avatar_path(project.namespace, project)}" + expect(helper.project_icon(project.full_path).to_s).to match(image_tag(avatar_url)) end end @@ -77,8 +84,14 @@ describe ApplicationHelper do it 'returns an url for the avatar' do user = create(:user, avatar: File.open(uploaded_image_temp_path)) - expect(helper.avatar_icon(user.email).to_s). - to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") + avatar_url = "/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) end it 'returns an url for the avatar with relative url' do diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 581726c1d0e..6157abfe339 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -15,7 +15,7 @@ describe AvatarsHelper do end it "contains the user's avatar image" do - is_expected.to include(CGI.escapeHTML(user.avatar_url(16))) + is_expected.to include(CGI.escapeHTML(user.avatar_url(size: 16))) end end end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 10681af5f7e..f2c9d927388 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -21,55 +21,6 @@ describe MergeRequestsHelper do end end - describe '#issues_sentence' do - let(:project) { create :project } - - subject { issues_sentence(issues) } - let(:issues) do - [build(:issue, iid: 2, project: project), - build(:issue, iid: 3, project: project), - build(:issue, iid: 1, project: project)] - end - - it do - @project = project - - is_expected.to eq('#1, #2, and #3') - end - - context 'for JIRA issues' do - let(:project) { create(:empty_project) } - let(:issues) do - [ - ExternalIssue.new('JIRA-456', project), - ExternalIssue.new('FOOBAR-7890', project), - ExternalIssue.new('JIRA-123', project) - ] - end - - it do - @project = project - is_expected.to eq('FOOBAR-7890, JIRA-123, and JIRA-456') - end - end - - context 'for issues from multiple namespaces' do - let(:project) { create(:project) } - let(:other_project) { create(:project) } - let(:issues) do - [build(:issue, iid: 2, project: project), - build(:issue, iid: 3, project: other_project), - build(:issue, iid: 1, project: project)] - end - - it do - @project = project - - is_expected.to eq("#1, #2, and #{other_project.namespace.path}/#{other_project.path}#3") - end - end - end - describe '#format_mr_branch_names' do describe 'within the same project' do let(:merge_request) { create(:merge_request) } @@ -89,147 +40,4 @@ describe MergeRequestsHelper do it { is_expected.to eq([source_title, target_title]) } end end - - describe '#mr_widget_refresh_url' do - let(:guest) { create(:user) } - let(:project) { create(:project, :public) } - let(:project_fork) { Projects::ForkService.new(project, guest).execute } - let(:merge_request) { create(:merge_request, source_project: project_fork, target_project: project) } - - it 'returns correct url for MR' do - expected_url = "#{project.path_with_namespace}/merge_requests/#{merge_request.iid}/merge_widget_refresh" - - expect(mr_widget_refresh_url(merge_request)).to end_with(expected_url) - end - - it 'returns empty string for nil' do - expect(mr_widget_refresh_url(nil)).to eq('') - end - end - - describe '#mr_closes_issues' do - let(:user_1) { create(:user) } - let(:user_2) { create(:user) } - - let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } - let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } - - let(:issue_1) { create(:issue, project: project_1) } - let(:issue_2) { create(:issue, project: project_2) } - - let(:merge_request) { create(:merge_request, source_project: project_1, target_project: project_1,) } - - let(:merge_request) do - create(:merge_request, - source_project: project_1, target_project: project_1, - description: "Fixes #{issue_1.to_reference} Fixes #{issue_2.to_reference(project_1)}") - end - - before do - project_1.team << [user_2, :developer] - project_2.team << [user_2, :developer] - allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) - @merge_request = merge_request - end - - context 'user without access to another private project' do - let(:current_user) { user_1 } - - it 'cannot see that project\'s issue that will be closed on acceptance' do - expect(mr_closes_issues).to contain_exactly(issue_1) - end - end - - context 'user with access to another private project' do - let(:current_user) { user_2 } - - it 'can see that project\'s issue that will be closed on acceptance' do - expect(mr_closes_issues).to contain_exactly(issue_1, issue_2) - end - end - end - - describe '#target_projects' do - let(:project) { create(:empty_project) } - let(:fork_project) { create(:empty_project, forked_from_project: project) } - - context 'when target project has enabled merge requests' do - it 'returns the forked_from project' do - expect(target_projects(fork_project)).to contain_exactly(project, fork_project) - end - end - - context 'when target project has disabled merge requests' do - it 'returns the forked project' do - project.project_feature.update(merge_requests_access_level: 0) - - expect(target_projects(fork_project)).to contain_exactly(fork_project) - end - end - end - - describe '#new_mr_path_from_push_event' do - subject(:url_params) { URI.decode_www_form(new_mr_path_from_push_event(event)).to_h } - let(:user) { create(:user) } - let(:project) { create(:empty_project, creator: user) } - let(:fork_project) { create(:project, forked_from_project: project, creator: user) } - let(:event) do - push_data = Gitlab::DataBuilder::Push.build_sample(fork_project, user) - create(:event, :pushed, project: fork_project, target: fork_project, author: user, data: push_data) - end - - context 'when target project has enabled merge requests' do - it 'returns link to create merge request on source project' do - expect(url_params['merge_request[target_project_id]'].to_i).to eq(project.id) - end - end - - context 'when target project has disabled merge requests' do - it 'returns link to create merge request on forked project' do - project.project_feature.update(merge_requests_access_level: 0) - - expect(url_params['merge_request[target_project_id]'].to_i).to eq(fork_project.id) - end - end - end - - describe '#mr_issues_mentioned_but_not_closing' do - let(:user_1) { create(:user) } - let(:user_2) { create(:user) } - - let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } - let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } - - let(:issue_1) { create(:issue, project: project_1) } - let(:issue_2) { create(:issue, project: project_2) } - - let(:merge_request) do - create(:merge_request, - source_project: project_1, target_project: project_1, - description: "#{issue_1.to_reference} #{issue_2.to_reference(project_1)}") - end - - before do - project_1.team << [user_2, :developer] - project_2.team << [user_2, :developer] - allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) - @merge_request = merge_request - end - - context 'user without access to another private project' do - let(:current_user) { user_1 } - - it 'cannot see that project\'s issue that will be closed on acceptance' do - expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1) - end - end - - context 'user with access to another private project' do - let(:current_user) { user_2 } - - it 'can see that project\'s issue that will be closed on acceptance' do - expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1, issue_2) - end - end - end end diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js index dd9ab33289f..5ff66167718 100644 --- a/spec/javascripts/behaviors/bind_in_out_spec.js +++ b/spec/javascripts/behaviors/bind_in_out_spec.js @@ -2,7 +2,7 @@ import BindInOut from '~/behaviors/bind_in_out'; import ClassSpecHelper from '../helpers/class_spec_helper'; describe('BindInOut', function () { - describe('.constructor', function () { + describe('constructor', function () { beforeEach(function () { this.in = {}; this.out = {}; @@ -53,7 +53,7 @@ describe('BindInOut', function () { }); }); - describe('.addEvents', function () { + describe('addEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['addEventListener']); @@ -79,7 +79,7 @@ describe('BindInOut', function () { }); }); - describe('.updateOut', function () { + describe('updateOut', function () { beforeEach(function () { this.in = { value: 'the-value' }; this.out = { textContent: 'not-the-value' }; @@ -98,7 +98,7 @@ describe('BindInOut', function () { }); }); - describe('.removeEvents', function () { + describe('removeEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['removeEventListener']); this.updateOut = () => {}; @@ -122,7 +122,7 @@ describe('BindInOut', function () { }); }); - describe('.initAll', function () { + describe('initAll', function () { beforeEach(function () { this.ins = [0, 1, 2]; this.instances = []; @@ -153,7 +153,7 @@ describe('BindInOut', function () { }); }); - describe('.init', function () { + describe('init', function () { beforeEach(function () { spyOn(BindInOut.prototype, 'addEvents').and.callFake(function () { return this; }); spyOn(BindInOut.prototype, 'updateOut').and.callFake(function () { return this; }); diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js index 4fb79663c51..bb436978a0f 100644 --- a/spec/javascripts/blob/target_branch_dropdown_spec.js +++ b/spec/javascripts/blob/target_branch_dropdown_spec.js @@ -63,7 +63,7 @@ describe('TargetBranchDropdown', () => { expect('change.branch').toHaveBeenTriggeredOn(dropdown.$dropdown); }); - describe('#dropdownData', () => { + describe('dropdownData', () => { it('cache the refs', () => { const refs = dropdown.cachedRefs; dropdown.cachedRefs = null; @@ -88,7 +88,7 @@ describe('TargetBranchDropdown', () => { }); }); - describe('#setNewBranch', () => { + describe('setNewBranch', () => { it('adds the new branch and select it', () => { const branchName = 'new_branch'; diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js index 82b00b4c1ec..10a60620f49 100644 --- a/spec/javascripts/commit/pipelines/mock_data.js +++ b/spec/javascripts/commit/pipelines/mock_data.js @@ -61,6 +61,7 @@ export default { tag: false, branch: true, }, + coverage: '42.21', commit: { id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4', short_id: 'fbd79f04', diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 47d904b865b..a746a776548 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -16,6 +16,16 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont sha: merge_request.diff_head_sha ) end + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + end render_views @@ -39,6 +49,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merged_merge_request) end + it 'merge_requests/diff_comment.html.raw' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + render_merge_request(example.description, merge_request) + end + private def render_merge_request(fixture_file_name, merge_request) diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js index 71d6e2a7e22..5ed18d0a76b 100644 --- a/spec/javascripts/gl_form_spec.js +++ b/spec/javascripts/gl_form_spec.js @@ -32,7 +32,7 @@ describe('GLForm', () => { }); }); - describe('.setupAutosize', () => { + describe('setupAutosize', () => { beforeEach((done) => { this.glForm.setupAutosize(); setTimeout(() => { @@ -59,7 +59,7 @@ describe('GLForm', () => { }); }); - describe('.setHeightData', () => { + describe('setHeightData', () => { beforeEach(() => { spyOn($.prototype, 'data'); spyOn($.prototype, 'outerHeight').and.returnValue(200); @@ -75,7 +75,7 @@ describe('GLForm', () => { }); }); - describe('.destroyAutosize', () => { + describe('destroyAutosize', () => { describe('when called', () => { beforeEach(() => { spyOn($.prototype, 'data'); diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/javascripts/helpers/class_spec_helper_spec.js index 0a61e561640..a1cfc8ba820 100644 --- a/spec/javascripts/helpers/class_spec_helper_spec.js +++ b/spec/javascripts/helpers/class_spec_helper_spec.js @@ -3,7 +3,7 @@ require('./class_spec_helper'); describe('ClassSpecHelper', () => { - describe('.itShouldBeAStaticMethod', function () { + describe('itShouldBeAStaticMethod', function () { beforeEach(() => { class TestClass { instanceMethod() { this.prop = 'val'; } diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index a1fd2d38968..b1b08989028 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -58,7 +58,7 @@ require('~/line_highlighter'); return expect(func).not.toThrow(); }); }); - describe('#clickHandler', function() { + describe('clickHandler', function() { it('handles clicking on a child icon element', function() { var spy; spy = spyOn(this["class"], 'setHash').and.callThrough(); @@ -176,7 +176,7 @@ require('~/line_highlighter'); }); }); }); - describe('#hashToRange', function() { + describe('hashToRange', function() { beforeEach(function() { return this.subject = this["class"].hashToRange; }); @@ -190,7 +190,7 @@ require('~/line_highlighter'); return expect(this.subject('#foo')).toEqual([null, null]); }); }); - describe('#highlightLine', function() { + describe('highlightLine', function() { beforeEach(function() { return this.subject = this["class"].highlightLine; }); @@ -203,7 +203,7 @@ require('~/line_highlighter'); return expect($('#LC13')).toHaveClass(this.css); }); }); - return describe('#setHash', function() { + return describe('setHash', function() { beforeEach(function() { return this.subject = this["class"].setHash; }); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js new file mode 100644 index 00000000000..e54acfa8e44 --- /dev/null +++ b/spec/javascripts/merge_request_notes_spec.js @@ -0,0 +1,61 @@ +/* global Notes */ + +import 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/render_gfm'; +import '~/render_math'; +import '~/notes'; + +describe('Merge request notes', () => { + window.gon = window.gon || {}; + window.gl = window.gl || {}; + gl.utils = gl.utils || {}; + + const fixture = 'merge_requests/diff_comment.html.raw'; + preloadFixtures(fixture); + + beforeEach(() => { + loadFixtures(fixture); + gl.utils.disableButtonIfEmptyField = _.noop; + window.project_uploads_path = 'http://test.host/uploads'; + $('body').data('page', 'projects:merge_requests:show'); + window.gon.current_user_id = $('.note:last').data('author-id'); + + return new Notes('', []); + }); + + describe('up arrow', () => { + it('edits last comment when triggered in main form', () => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note:last .js-note-edit', 'click'); + + $('.js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note:last .js-note-edit'); + }); + + it('edits last comment in discussion when triggered in discussion form', (done) => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note-discussion .js-note-edit', 'click'); + + $('.js-discussion-reply-button').click(); + + setTimeout(() => { + expect( + $('.note-discussion .js-note-text'), + ).toExist(); + + $('.note-discussion .js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note-discussion .js-note-edit'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index e437333d522..254a41db160 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -47,7 +47,7 @@ require('vendor/jquery.scrollTo'); this.class.destroyPipelinesView(); }); - describe('#activateTab', function () { + describe('activateTab', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); @@ -71,7 +71,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#opensInNewTab', function () { + describe('opensInNewTab', function () { var tabUrl; var windowTarget = '_blank'; @@ -152,7 +152,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#setCurrentAction', function () { + describe('setCurrentAction', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); this.subject = this.class.setCurrentAction; @@ -221,7 +221,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#tabShown', () => { + describe('tabShown', () => { beforeEach(function () { spyOn($, 'ajax').and.callFake(function (options) { options.success({ html: '' }); @@ -281,7 +281,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#loadDiff', function () { + describe('loadDiff', function () { it('requires an absolute pathname', function () { spyOn($, 'ajax').and.callFake(function (options) { expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json'); diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js deleted file mode 100644 index 88dae8c3e06..00000000000 --- a/spec/javascripts/merge_request_widget_spec.js +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable space-before-function-paren, quotes, comma-dangle, dot-notation, quote-props, no-var, max-len */ - -require('~/merge_request_widget'); -require('~/smart_interval'); -require('~/lib/utils/datetime_utility'); - -(function() { - describe('MergeRequestWidget', function() { - beforeEach(function() { - window.notifyPermissions = function() {}; - window.notify = function() {}; - this.opts = { - ci_status_url: "http://sampledomain.local/ci/getstatus", - ci_environments_status_url: "http://sampledomain.local/ci/getenvironmentsstatus", - ci_status: "", - ci_message: { - normal: "Build {{status}} for \"{{title}}\"", - preparing: "{{status}} build for \"{{title}}\"" - }, - ci_title: { - preparing: "{{status}} build", - normal: "Build {{status}}" - }, - gitlab_icon: "gitlab_logo.png", - ci_pipeline: 80, - ci_sha: "12a34bc5", - builds_path: "http://sampledomain.local/sampleBuildsPath", - commits_path: "http://sampledomain.local/commits", - pipeline_path: "http://sampledomain.local/pipelines" - }; - this["class"] = new window.gl.MergeRequestWidget(this.opts); - }); - - describe('getCIEnvironmentsStatus', function() { - beforeEach(function() { - this.ciEnvironmentsStatusData = [{ - created_at: '2016-09-12T13:38:30.636Z', - environment_id: 1, - environment_name: 'env1', - external_url: 'https://test-url.com', - external_url_formatted: 'test-url.com' - }]; - - spyOn(jQuery, 'getJSON').and.callFake(function(req, cb) { - cb(this.ciEnvironmentsStatusData); - }.bind(this)); - }); - - it('should call renderEnvironments when the environments property is set', function() { - const spy = spyOn(this.class, 'renderEnvironments').and.stub(); - this.class.getCIEnvironmentsStatus(); - expect(spy).toHaveBeenCalledWith(this.ciEnvironmentsStatusData); - }); - - it('should not call renderEnvironments when the environments property is not set', function() { - this.ciEnvironmentsStatusData = null; - const spy = spyOn(this.class, 'renderEnvironments').and.stub(); - this.class.getCIEnvironmentsStatus(); - expect(spy).not.toHaveBeenCalled(); - }); - }); - - describe('renderEnvironments', function() { - describe('should render correct timeago', function() { - beforeEach(function() { - this.environments = [{ - id: 'test-environment-id', - url: 'testurl', - deployed_at: new Date().toISOString(), - deployed_at_formatted: true - }]; - }); - - function getTimeagoText(template) { - var el = document.createElement('html'); - el.innerHTML = template; - return el.querySelector('.js-environment-timeago').innerText.trim(); - } - - it('should render less than a minute ago text', function() { - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('less than a minute ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - - it('should render about an hour ago text', function() { - var oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); - - this.environments[0].deployed_at = oneHourAgo.toISOString(); - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('about an hour ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - - it('should render about 2 hours ago text', function() { - var twoHoursAgo = new Date(); - twoHoursAgo.setHours(twoHoursAgo.getHours() - 2); - - this.environments[0].deployed_at = twoHoursAgo.toISOString(); - spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) { - expect(getTimeagoText(template)).toBe('about 2 hours ago.'); - }); - - this.class.renderEnvironments(this.environments); - }); - }); - }); - - describe('mergeInProgress', function() { - it('should display error with h4 tag', function() { - spyOn(this.class.$widgetBody, 'html').and.callFake(function(html) { - expect(html).toBe('<h4>Sorry, something went wrong.</h4>'); - }); - spyOn($, 'ajax').and.callFake(function(e) { - e.success({ merge_error: 'Sorry, something went wrong.' }); - }); - this.class.mergeInProgress(null); - }); - }); - - describe('getCIStatus', function() { - beforeEach(function() { - this.ciStatusData = { - "title": "Sample MR title", - "pipeline": 80, - "sha": "12a34bc5", - "status": "success", - "coverage": 98 - }; - - spyOn(jQuery, 'getJSON').and.callFake((function(_this) { - return function(req, cb) { - return cb(_this.ciStatusData); - }; - })(this)); - }); - it('should call showCIStatus even if a notification should not be displayed', function() { - var spy; - spy = spyOn(this["class"], 'showCIStatus').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); - }); - it('should call showCIStatus when a notification should be displayed', function() { - var spy; - spy = spyOn(this["class"], 'showCIStatus').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(true); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); - }); - it('should call showCICoverage when the coverage rate is set', function() { - var spy; - spy = spyOn(this["class"], 'showCICoverage').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalledWith(this.ciStatusData.coverage); - }); - it('should not call showCICoverage when the coverage rate is not set', function() { - var spy; - this.ciStatusData.coverage = null; - spy = spyOn(this["class"], 'showCICoverage').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - return expect(spy).not.toHaveBeenCalled(); - }); - it('should not display a notification on the first check after the widget has been created', function() { - var spy; - spy = spyOn(window, 'notify'); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"] = new window.gl.MergeRequestWidget(this.opts); - this["class"].getCIStatus(true); - return expect(spy).not.toHaveBeenCalled(); - }); - it('should update the pipeline URL when the pipeline changes', function() { - var spy; - spy = spyOn(this["class"], 'updatePipelineUrls').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - this.ciStatusData.pipeline += 1; - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalled(); - }); - it('should update the commit URL when the sha changes', function() { - var spy; - spy = spyOn(this["class"], 'updateCommitUrls').and.stub(); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - this["class"].getCIStatus(false); - this.ciStatusData.sha = "9b50b99a"; - this["class"].getCIStatus(false); - return expect(spy).toHaveBeenCalled(); - }); - }); - }); -}).call(window); diff --git a/spec/javascripts/merged_buttons_spec.js b/spec/javascripts/merged_buttons_spec.js deleted file mode 100644 index b5c5e60dd97..00000000000 --- a/spec/javascripts/merged_buttons_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -/* global MergedButtons */ - -import '~/merged_buttons'; - -describe('MergedButtons', () => { - const fixturesPath = 'merge_requests/merged_merge_request.html.raw'; - preloadFixtures(fixturesPath); - - beforeEach(() => { - loadFixtures(fixturesPath); - this.mergedButtons = new MergedButtons(); - this.$removeBranchWidget = $('.remove_source_branch_widget:not(.failed)'); - this.$removeBranchProgress = $('.remove_source_branch_in_progress'); - this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); - this.$removeBranchButton = $('.remove_source_branch'); - }); - - describe('removeSourceBranch', () => { - it('shows loader', () => { - $('.remove_source_branch').trigger('click'); - expect(this.$removeBranchProgress).toBeVisible(); - expect(this.$removeBranchWidget).not.toBeVisible(); - }); - }); - - describe('removeBranchSuccess', () => { - it('refreshes page when branch removed', () => { - spyOn(gl.utils, 'refreshCurrentPage').and.stub(); - const response = { status: 200 }; - this.$removeBranchButton.trigger('ajax:success', response, 'xhr'); - expect(gl.utils.refreshCurrentPage).toHaveBeenCalled(); - }); - }); - - describe('removeBranchError', () => { - it('shows error message', () => { - const response = { status: 500 }; - this.$removeBranchButton.trigger('ajax:error', response, 'xhr'); - expect(this.$removeBranchFailed).toBeVisible(); - expect(this.$removeBranchProgress).not.toBeVisible(); - expect(this.$removeBranchWidget).not.toBeVisible(); - }); - }); -}); diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index a756617e65e..6bd0eb86263 100644 --- a/spec/javascripts/pipelines/graph/graph_component_spec.js +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import graphComponent from '~/pipelines/components/graph/graph_component.vue'; +import graphJSON from './mock_data'; describe('graph component', () => { preloadFixtures('static/graph.html.raw'); @@ -20,41 +21,7 @@ describe('graph component', () => { describe('with a successfull response', () => { const interceptor = (request, next) => { - next(request.respondWith(JSON.stringify({ - details: { - stages: [{ - name: 'test', - title: 'test: passed', - status: { - icon: 'icon_status_success', - text: 'passed', - label: 'passed', - details_path: '/root/ci-mock/pipelines/123#test', - }, - path: '/root/ci-mock/pipelines/123#test', - groups: [{ - name: 'test', - size: 1, - jobs: [{ - id: 4153, - name: 'test', - status: { - icon: 'icon_status_success', - text: 'passed', - label: 'passed', - details_path: '/root/ci-mock/builds/4153', - action: { - icon: 'icon_action_retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', - }, - }, - }], - }], - }], - }, - }), { + next(request.respondWith(JSON.stringify(graphJSON), { status: 200, })); }; @@ -73,6 +40,18 @@ describe('graph component', () => { setTimeout(() => { expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + expect( + component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), + ).toEqual(true); + + expect( + component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'), + ).toEqual(true); + + expect( + component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'), + ).toEqual(true); + expect(component.$el.querySelector('loading-icon')).toBe(null); expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js new file mode 100644 index 00000000000..56c522b7f77 --- /dev/null +++ b/spec/javascripts/pipelines/graph/mock_data.js @@ -0,0 +1,232 @@ +/* eslint-disable quote-props, quotes, comma-dangle */ +export default { + "id": 123, + "user": { + "name": "Root", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": null, + "web_url": "http://localhost:3000/root" + }, + "active": false, + "coverage": null, + "path": "/root/ci-mock/pipelines/123", + "details": { + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "duration": 9, + "finished_at": "2017-04-19T14:30:27.542Z", + "stages": [{ + "name": "test", + "title": "test: passed", + "groups": [{ + "name": "test", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4153", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4153/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4153, + "name": "test", + "build_path": "/root/ci-mock/builds/4153", + "retry_path": "/root/ci-mock/builds/4153/retry", + "playable": false, + "created_at": "2017-04-13T09:25:18.959Z", + "updated_at": "2017-04-13T09:25:23.118Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4153", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4153/retry", + "method": "post" + } + } + }] + }], + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123#test", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "path": "/root/ci-mock/pipelines/123#test", + "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=test" + }, { + "name": "deploy", + "title": "deploy: passed", + "groups": [{ + "name": "deploy to production", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4166", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4166/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4166, + "name": "deploy to production", + "build_path": "/root/ci-mock/builds/4166", + "retry_path": "/root/ci-mock/builds/4166/retry", + "playable": false, + "created_at": "2017-04-19T14:29:46.463Z", + "updated_at": "2017-04-19T14:30:27.498Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4166", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4166/retry", + "method": "post" + } + } + }] + }, { + "name": "deploy to staging", + "size": 1, + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4159", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4159/retry", + "method": "post" + } + }, + "jobs": [{ + "id": 4159, + "name": "deploy to staging", + "build_path": "/root/ci-mock/builds/4159", + "retry_path": "/root/ci-mock/builds/4159/retry", + "playable": false, + "created_at": "2017-04-18T16:32:08.420Z", + "updated_at": "2017-04-18T16:32:12.631Z", + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/builds/4159", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", + "action": { + "icon": "icon_action_retry", + "title": "Retry", + "path": "/root/ci-mock/builds/4159/retry", + "method": "post" + } + } + }] + }], + "status": { + "icon": "icon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/ci-mock/pipelines/123#deploy", + "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + }, + "path": "/root/ci-mock/pipelines/123#deploy", + "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=deploy" + }], + "artifacts": [], + "manual_actions": [{ + "name": "deploy to production", + "path": "/root/ci-mock/builds/4166/play", + "playable": false + }] + }, + "flags": { + "latest": true, + "triggered": false, + "stuck": false, + "yaml_errors": false, + "retryable": false, + "cancelable": false + }, + "ref": { + "name": "master", + "path": "/root/ci-mock/tree/master", + "tag": false, + "branch": true + }, + "commit": { + "id": "798e5f902592192afaba73f4668ae30e56eae492", + "short_id": "798e5f90", + "title": "Merge branch 'new-branch' into 'master'\r", + "created_at": "2017-04-13T10:25:17.000+01:00", + "parent_ids": ["54d483b1ed156fbbf618886ddf7ab023e24f8738", "c8e2d38a6c538822e81c57022a6e3a0cfedebbcc"], + "message": "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + "author_name": "Root", + "author_email": "admin@example.com", + "authored_date": "2017-04-13T10:25:17.000+01:00", + "committer_name": "Root", + "committer_email": "admin@example.com", + "committed_date": "2017-04-13T10:25:17.000+01:00", + "author": { + "name": "Root", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": null, + "web_url": "http://localhost:3000/root" + }, + "author_gravatar_url": null, + "commit_url": "http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492", + "commit_path": "/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492" + }, + "created_at": "2017-04-13T09:25:18.881Z", + "updated_at": "2017-04-19T14:30:27.561Z" +}; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 9e19dabd0e3..757b8d595a4 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -13,7 +13,7 @@ require('~/shortcuts_issuable'); document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); this.shortcut = new ShortcutsIssuable(); }); - describe('#replyWithSelectedText', function() { + describe('replyWithSelectedText', function() { var stubSelection; // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. stubSelection = function(html) { diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 07dc51a7815..0464b5d2329 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -1,8 +1,8 @@ // enable test fixtures require('jasmine-jquery'); -jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; -jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; +jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; +jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; // include common libraries require('~/commons/index.js'); @@ -55,7 +55,6 @@ if (process.env.BABEL_ENV === 'coverage') { './merge_conflicts/merge_conflicts_bundle.js', './merge_conflicts/components/inline_conflict_lines.js', './merge_conflicts/components/parallel_conflict_lines.js', - './merge_request_widget/ci_bundle.js', './monitoring/monitoring_bundle.js', './network/network_bundle.js', './network/branch_graph.js', diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js index 6677fe9c1ee..4eb8ad3d9e4 100644 --- a/spec/javascripts/u2f/mock_u2f_device.js +++ b/spec/javascripts/u2f/mock_u2f_device.js @@ -1,12 +1,10 @@ /* eslint-disable space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-expressions, no-return-assign, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MockU2FDevice = (function() { function MockU2FDevice() { - this.respondToAuthenticateRequest = bind(this.respondToAuthenticateRequest, this); - this.respondToRegisterRequest = bind(this.respondToRegisterRequest, this); + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); window.u2f || (window.u2f = {}); window.u2f.register = (function(_this) { return function(appId, registerRequests, signRequests, callback) { diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js index 464c1fce210..83ffeca253a 100644 --- a/spec/javascripts/version_check_image_spec.js +++ b/spec/javascripts/version_check_image_spec.js @@ -3,7 +3,7 @@ const VersionCheckImage = require('~/version_check_image'); require('jquery'); describe('VersionCheckImage', function () { - describe('.bindErrorEvent', function () { + describe('bindErrorEvent', function () { ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); beforeEach(function () { diff --git a/spec/javascripts/visibility_select_spec.js b/spec/javascripts/visibility_select_spec.js index 9727c03c91e..b26ed41f27a 100644 --- a/spec/javascripts/visibility_select_spec.js +++ b/spec/javascripts/visibility_select_spec.js @@ -22,7 +22,7 @@ require('~/visibility_select'); spyOn(Element.prototype, 'querySelector').and.callFake(selector => mockElements[selector]); }); - describe('#constructor', function () { + describe('constructor', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); }); @@ -48,7 +48,7 @@ require('~/visibility_select'); }); }); - describe('#init', function () { + describe('init', function () { describe('if there is a select', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); @@ -85,7 +85,7 @@ require('~/visibility_select'); }); }); - describe('#updateHelpText', function () { + describe('updateHelpText', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); this.visibilitySelect.init(); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js new file mode 100644 index 00000000000..a750bc78f36 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author'; + +const author = { + webUrl: 'http://foo.bar', + avatarUrl: 'http://gravatar.com/foo', + name: 'fatihacet', +}; +const createComponent = () => { + const Component = Vue.extend(authorComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { author }, + }); +}; + +describe('MRWidgetAuthor', () => { + describe('props', () => { + it('should have props', () => { + const authorProp = authorComponent.props.author; + + expect(authorProp).toBeDefined(); + expect(authorProp.type instanceof Object).toBeTruthy(); + expect(authorProp.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + + expect(el.tagName).toEqual('A'); + expect(el.getAttribute('href')).toEqual(author.webUrl); + expect(el.querySelector('img').getAttribute('src')).toEqual(author.avatarUrl); + expect(el.querySelector('.author').innerText.trim()).toEqual(author.name); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js new file mode 100644 index 00000000000..515ddcbb875 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time'; + +const props = { + actionText: 'Merged by', + author: { + webUrl: 'http://foo.bar', + avatarUrl: 'http://gravatar.com/foo', + name: 'fatihacet', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', +}; +const createComponent = () => { + const Component = Vue.extend(authorTimeComponent); + + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetAuthorTime', () => { + describe('props', () => { + it('should have props', () => { + const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props; + const ActionTextClass = actionText.type; + const DateTitleClass = dateTitle.type; + const DateReadableClass = dateReadable.type; + + expect(new ActionTextClass() instanceof String).toBeTruthy(); + expect(actionText.required).toBeTruthy(); + + expect(author.type instanceof Object).toBeTruthy(); + expect(author.required).toBeTruthy(); + + expect(new DateTitleClass() instanceof String).toBeTruthy(); + expect(dateTitle.required).toBeTruthy(); + + expect(new DateReadableClass() instanceof String).toBeTruthy(); + expect(dateReadable.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components', () => { + expect(authorTimeComponent.components['mr-widget-author']).toBeDefined(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + + expect(el.tagName).toEqual('H4'); + expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl); + expect(el.querySelector('time').innerText).toContain(props.dateReadable); + expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js new file mode 100644 index 00000000000..2f971b39d16 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js @@ -0,0 +1,188 @@ +import Vue from 'vue'; +import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import { statusClassToSvgMap } from '~/vue_shared/pipeline_svg_icons'; + +const deploymentMockData = [ + { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + }, +]; +const createComponent = () => { + const Component = Vue.extend(deploymentComponent); + const mr = { + deployments: deploymentMockData, + }; + const service = {}; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetDeployment', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = deploymentComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + describe('svg', () => { + it('should have the proper SVG icon', () => { + const vm = createComponent(deploymentMockData); + expect(vm.svg).toEqual(statusClassToSvgMap.icon_status_success); + }); + }); + }); + + describe('methods', () => { + let vm = createComponent(); + const deployment = deploymentMockData[0]; + + describe('formatDate', () => { + it('should work', () => { + const readable = gl.utils.getTimeago().format(deployment.deployed_at); + expect(vm.formatDate(deployment.deployed_at)).toEqual(readable); + }); + }); + + describe('hasExternalUrls', () => { + it('should return true', () => { + expect(vm.hasExternalUrls(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasExternalUrls()).toBeFalsy(); + expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy(); + expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('hasDeploymentTime', () => { + it('should return true', () => { + expect(vm.hasDeploymentTime(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasDeploymentTime()).toBeFalsy(); + expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy(); + expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('hasDeploymentMeta', () => { + it('should return true', () => { + expect(vm.hasDeploymentMeta(deployment)).toBeTruthy(); + }); + + it('should return false when there is not enough information', () => { + expect(vm.hasDeploymentMeta()).toBeFalsy(); + expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy(); + expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy(); + }); + }); + + describe('stopEnvironment', () => { + const url = '/foo/bar'; + const returnPromise = () => new Promise((resolve) => { + resolve({ + json() { + return { + redirect_url: url, + }; + }, + }); + }); + const mockStopEnvironment = () => { + vm.stopEnvironment(deploymentMockData); + return vm; + }; + + it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); + spyOn(gl.utils, 'visitUrl').and.returnValue(true); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); + setTimeout(() => { + expect(gl.utils.visitUrl).toHaveBeenCalledWith(url); + done(); + }, 333); + }); + + it('should show a confirm dialog but should not work if the dialog is rejected', () => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + const [deployment] = deploymentMockData; + + beforeEach(() => { + vm = createComponent(deploymentMockData); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); + expect(el.querySelector('.js-icon-link')).toBeDefined(); + expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url); + expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name); + expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url); + expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted); + expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at)); + expect(el.querySelector('.js-mr-memory-usage')).toBeDefined(); + expect(el.querySelector('button')).toBeDefined(); + }); + + it('should list multiple deployments', (done) => { + vm.mr.deployments.push(deployment); + vm.mr.deployments.push(deployment); + + Vue.nextTick(() => { + expect(el.querySelectorAll('.ci-widget').length).toEqual(3); + expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3); + done(); + }); + }); + + it('should not have some elements when there is not enough data', (done) => { + vm.mr.deployments = [{}]; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0); + expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0); + expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0); + expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0); + expect(el.querySelectorAll('.button').length).toEqual(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js new file mode 100644 index 00000000000..48f816c8460 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header'; + +const createComponent = (mr) => { + const Component = Vue.extend(headerComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetHeader', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = headerComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + let vm; + beforeEach(() => { + vm = createComponent({ + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '/foo/bar/mr-widget-refactor', + targetBranch: 'master', + }); + }); + + it('shouldShowCommitsBehindText', () => { + expect(vm.shouldShowCommitsBehindText).toBeTruthy(); + + vm.mr.divergedCommitsCount = 0; + expect(vm.shouldShowCommitsBehindText).toBeFalsy(); + }); + + it('commitsText', () => { + expect(vm.commitsText).toEqual('commits'); + + vm.mr.divergedCommitsCount = 1; + expect(vm.commitsText).toEqual('commit'); + }); + }); + + describe('template', () => { + let vm; + let el; + const mr = { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '/foo/bar/mr-widget-refactor', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + }; + + beforeEach(() => { + vm = createComponent(mr); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-source-target')).toBeTruthy(); + expect(el.querySelectorAll('.label-branch')[0].textContent).toContain(mr.sourceBranch); + expect(el.querySelectorAll('.label-branch')[1].textContent).toContain(mr.targetBranch); + expect(el.querySelector('.diverged-commits-count').textContent).toContain('12 commits behind'); + + expect(el.textContent).toContain('Check out branch'); + expect(el.querySelectorAll('.dropdown li a')[0].getAttribute('href')).toEqual(mr.emailPatchesPath); + expect(el.querySelectorAll('.dropdown li a')[1].getAttribute('href')).toEqual(mr.plainDiffPath); + }); + + it('should not have right action links if the MR state is not open', (done) => { + vm.mr.isOpen = false; + Vue.nextTick(() => { + expect(el.textContent).not.toContain('Check out branch'); + expect(el.querySelectorAll('.dropdown li a').length).toEqual(0); + done(); + }); + }); + + it('should not render diverged commits count if the MR has no diverged commits', (done) => { + vm.mr.divergedCommitsCount = null; + Vue.nextTick(() => { + expect(el.textContent).not.toContain('commits behind'); + expect(el.querySelectorAll('.diverged-commits-count').length).toEqual(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js new file mode 100644 index 00000000000..da9dff18ada --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -0,0 +1,184 @@ +import Vue from 'vue'; +import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; + +const metricsMockData = { + success: true, + metrics: { + memory_values: [ + { + metric: {}, + values: [ + [1493716685, '4.30859375'], + ], + }, + ], + }, + last_update: '2017-05-02T12:34:49.628Z', + deployment_time: 1493718485, +}; + +const createComponent = () => { + const Component = Vue.extend(memoryUsageComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + metricsUrl: url, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }, + }); +}; + +const messages = { + loadingMetrics: 'Loading deployment statistics.', + hasMetrics: 'Deployment memory usage:', + loadFailed: 'Failed to load deployment statistics.', + metricsUnavailable: 'Deployment statistics are not available currently.', +}; + +describe('MemoryUsage', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + describe('props', () => { + it('should have props with defaults', () => { + const { metricsUrl } = memoryUsageComponent.props; + const MetricsUrlTypeClass = metricsUrl.type; + + Vue.nextTick(() => { + expect(new MetricsUrlTypeClass() instanceof String).toBeTruthy(); + expect(metricsUrl.required).toBeTruthy(); + }); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = memoryUsageComponent.data(); + + expect(Array.isArray(data.memoryMetrics)).toBeTruthy(); + expect(data.memoryMetrics.length).toBe(0); + + expect(typeof data.deploymentTime).toBe('number'); + expect(data.deploymentTime).toBe(0); + + expect(typeof data.hasMetrics).toBe('boolean'); + expect(data.hasMetrics).toBeFalsy(); + + expect(typeof data.loadFailed).toBe('boolean'); + expect(data.loadFailed).toBeFalsy(); + + expect(typeof data.loadingMetrics).toBe('boolean'); + expect(data.loadingMetrics).toBeTruthy(); + + expect(typeof data.backOffRequestCounter).toBe('number'); + expect(data.backOffRequestCounter).toBe(0); + }); + }); + + describe('methods', () => { + const { metrics, deployment_time } = metricsMockData; + + describe('computeGraphData', () => { + it('should populate sparkline graph', () => { + vm.computeGraphData(metrics, deployment_time); + const { hasMetrics, memoryMetrics, deploymentTime } = vm; + + expect(hasMetrics).toBeTruthy(); + expect(memoryMetrics.length > 0).toBeTruthy(); + expect(deploymentTime).toEqual(deployment_time); + }); + }); + + describe('loadMetrics', () => { + const returnServicePromise = () => new Promise((resolve) => { + resolve({ + json() { + return metricsMockData; + }, + }); + }); + + it('should load metrics data using MRWidgetService', (done) => { + spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true)); + spyOn(vm, 'computeGraphData'); + + vm.loadMetrics(); + setTimeout(() => { + expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url); + expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-memory-usage')).toBeTruthy(); + expect(el.querySelector('.js-usage-info')).toBeDefined(); + }); + + it('should show loading metrics message while metrics are being loaded', (done) => { + vm.loadingMetrics = true; + vm.hasMetrics = false; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); + done(); + }); + }); + + it('should show deployment memory usage when metrics are loaded', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = true; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); + done(); + }); + }); + + it('should show failure message when metrics loading failed', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); + done(); + }); + }); + + it('should show metrics unavailable message when metrics loading failed', (done) => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = false; + + Vue.nextTick(() => { + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js new file mode 100644 index 00000000000..4da4fc82c26 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help'; + +const props = { + missingBranch: 'this-is-not-the-branch-you-are-looking-for', +}; +const text = `If the ${props.missingBranch} branch exists in your local repository`; + +const createComponent = () => { + const Component = Vue.extend(mergeHelpComponent); + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetMergeHelp', () => { + describe('props', () => { + it('should have props', () => { + const { missingBranch } = mergeHelpComponent.props; + const MissingBranchTypeClass = missingBranch.type; + + expect(new MissingBranchTypeClass() instanceof String).toBeTruthy(); + expect(missingBranch.required).toBeFalsy(); + expect(missingBranch.default).toEqual(''); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have the correct elements', () => { + expect(el.classList.contains('mr-widget-help')).toBeTruthy(); + expect(el.textContent).toContain(text); + }); + + it('should not show missing branch name if missingBranch props is not provided', (done) => { + vm.missingBranch = null; + Vue.nextTick(() => { + expect(el.textContent).not.toContain(text); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js new file mode 100644 index 00000000000..1b418c7dfcf --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -0,0 +1,131 @@ +import Vue from 'vue'; +import { statusClassToSvgMap } from '~/vue_shared/pipeline_svg_icons'; +import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline'; +import mockData from '../mock_data'; + +const createComponent = (mr) => { + const Component = Vue.extend(pipelineComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetPipeline', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = pipelineComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(pipelineComponent.components['pipeline-stage']).toBeDefined(); + expect(pipelineComponent.components['pipeline-status-icon']).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('svg', () => { + it('should have the proper SVG icon', () => { + const vm = createComponent({ pipeline: mockData.pipeline }); + + expect(vm.svg).toEqual(statusClassToSvgMap.icon_status_failed); + }); + }); + + describe('hasCIError', () => { + it('should return false when there is no CI error', () => { + const vm = createComponent({ + pipeline: mockData.pipeline, + hasCI: true, + ciStatus: 'success', + }); + + expect(vm.hasCIError).toBeFalsy(); + }); + + it('should return true when there is a CI error', () => { + const vm = createComponent({ + pipeline: mockData.pipeline, + hasCI: true, + ciStatus: null, + }); + + expect(vm.hasCIError).toBeTruthy(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + const { pipeline } = mockData; + const mr = { + hasCI: true, + ciStatus: 'success', + pipelineDetailedStatus: pipeline.details.status, + pipeline, + }; + + beforeEach(() => { + vm = createComponent(mr); + el = vm.$el; + }); + + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); + expect(el.querySelectorAll('.ci-status-icon.ci-status-icon-success').length).toEqual(1); + expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipeline.id}`); + expect(el.innerText).toContain('passed'); + expect(el.innerText).toContain('with stages'); + expect(el.querySelector('.pipeline-id').getAttribute('href')).toEqual(pipeline.path); + expect(el.querySelectorAll('.stage-container').length).toEqual(2); + expect(el.querySelector('.js-ci-error')).toEqual(null); + expect(el.querySelector('.js-commit-link').getAttribute('href')).toEqual(pipeline.commit.commit_path); + expect(el.querySelector('.js-commit-link').textContent).toContain(pipeline.commit.short_id); + expect(el.querySelector('.js-mr-coverage').textContent).toContain(`Coverage ${pipeline.coverage}%.`); + }); + + it('should list single stage', (done) => { + pipeline.details.stages.splice(0, 1); + + Vue.nextTick(() => { + expect(el.querySelectorAll('.stage-container button').length).toEqual(1); + expect(el.innerText).toContain('with stage'); + done(); + }); + }); + + it('should not have stages when there is no stage', (done) => { + vm.mr.pipeline.details.stages = []; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.stage-container button').length).toEqual(0); + done(); + }); + }); + + it('should not have coverage text when pipeline has no coverage info', (done) => { + vm.mr.pipeline.coverage = null; + + Vue.nextTick(() => { + expect(el.querySelector('.js-mr-coverage')).toEqual(null); + done(); + }); + }); + + it('should show CI error when there is a CI error', (done) => { + vm.mr.ciStatus = null; + + Vue.nextTick(() => { + expect(el.querySelectorAll('.js-ci-error').length).toEqual(1); + expect(el.innerText).toContain('Could not connect to the CI server'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js new file mode 100644 index 00000000000..f6e0c3dfb74 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -0,0 +1,138 @@ +import Vue from 'vue'; +import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links'; + +const createComponent = (data) => { + const Component = Vue.extend(relatedLinksComponent); + + return new Component({ + el: document.createElement('div'), + propsData: data, + }); +}; + +describe('MRWidgetRelatedLinks', () => { + describe('props', () => { + it('should have props', () => { + const { relatedLinks } = relatedLinksComponent.props; + + expect(relatedLinks).toBeDefined(); + expect(relatedLinks.type instanceof Object).toBeTruthy(); + expect(relatedLinks.required).toBeTruthy(); + }); + }); + + describe('computed', () => { + describe('hasLinks', () => { + it('should return correct value when we have links reference', () => { + const data = { + relatedLinks: { + closing: '/foo', + mentioned: '/foo', + assignToMe: '/foo', + }, + }; + const vm = createComponent(data); + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.closing = null; + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.mentioned = null; + expect(vm.hasLinks).toBeTruthy(); + + vm.relatedLinks.assignToMe = null; + expect(vm.hasLinks).toBeFalsy(); + }); + }); + }); + + describe('methods', () => { + const data = { + relatedLinks: { + closing: '<a href="#">#23</a> and <a>#42</a>', + mentioned: '<a href="#">#7</a>', + }, + }; + const vm = createComponent(data); + + describe('hasMultipleIssues', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.closing)).toBeTruthy(); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.mentioned)).toBeFalsy(); + }); + }); + + describe('issueLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.issueLabel('closing')).toEqual('issues'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.issueLabel('mentioned')).toEqual('issue'); + }); + }); + + describe('verbLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.verbLabel('closing')).toEqual('are'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.verbLabel('mentioned')).toEqual('is'); + }); + }); + }); + + describe('template', () => { + it('should have only have closing issues text', () => { + const vm = createComponent({ + relatedLinks: { + closing: '<a href="#">#23</a> and <a>#42</a>', + }, + }); + const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(content).toContain('Closes issues #23 and #42'); + expect(content).not.toContain('mentioned'); + }); + + it('should have only have mentioned issues text', () => { + const vm = createComponent({ + relatedLinks: { + mentioned: '<a href="#">#7</a>', + }, + }); + + expect(vm.$el.innerText).toContain('issue #7'); + expect(vm.$el.innerText).toContain('is mentioned but will not be closed.'); + expect(vm.$el.innerText).not.toContain('Closes'); + }); + + it('should have closing and mentioned issues at the same time', () => { + const vm = createComponent({ + relatedLinks: { + closing: '<a href="#">#7</a>', + mentioned: '<a href="#">#23</a> and <a>#42</a>', + }, + }); + const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(content).toContain('Closes issue #7.'); + expect(content).toContain('issues #23 and #42'); + expect(content).toContain('are mentioned but will not be closed.'); + }); + + it('should have assing issues link', () => { + const vm = createComponent({ + relatedLinks: { + assignToMe: '<a href="#">Assign yourself to these issues</a>', + }, + }); + + expect(vm.$el.innerText).toContain('Assign yourself to these issues'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js new file mode 100644 index 00000000000..cac2f561a0b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived'; + +describe('MRWidgetArchived', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(archivedComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('This project is archived, write access has been disabled.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js new file mode 100644 index 00000000000..47b4ba893e0 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import autoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed'; + +const mergeError = 'This is the merge error'; + +describe('MRWidgetAutoMergeFailed', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = autoMergeFailedComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('template', () => { + const Component = Vue.extend(autoMergeFailedComponent); + const vm = new Component({ + el: document.createElement('div'), + propsData: { + mr: { mergeError }, + }, + }); + + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('This merge request failed to be merged automatically.'); + expect(vm.$el.innerText).toContain(mergeError); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js new file mode 100644 index 00000000000..3be11d47227 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking'; + +describe('MRWidgetChecking', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(checkingComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('Checking ability to merge automatically.'); + expect(el.querySelector('i')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js new file mode 100644 index 00000000000..78a70725e94 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed'; + +const mr = { + targetBranch: 'good-branch', + targetBranchCommitsPath: '/good-branch', + closedBy: { + name: 'Fatih Acet', + username: 'fatihacet', + }, + updatedAt: '2017-03-23T20:08:08.845Z', + closedAt: '1 day ago', +}; + +const createComponent = () => { + const Component = Vue.extend(closedComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }).$el; +}; + +describe('MRWidgetClosed', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = closedComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(closedComponent.components['mr-widget-author-and-time']).toBeDefined(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent(); + + expect(el.querySelector('h4').textContent).toContain('Closed by'); + expect(el.querySelector('h4').textContent).toContain(mr.closedBy.name); + expect(el.textContent).toContain('The changes were not merged into'); + expect(el.querySelector('.label-branch').getAttribute('href')).toEqual(mr.targetBranchCommitsPath); + expect(el.querySelector('.label-branch').textContent).toContain(mr.targetBranch); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js new file mode 100644 index 00000000000..e7ae85caec4 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts'; + +const path = '/conflicts'; +const createComponent = () => { + const Component = Vue.extend(conflictsComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + mr: { + canMerge: true, + conflictResolutionPath: path, + }, + }, + }); +}; + +describe('MRWidgetConflicts', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = conflictsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + const resolveButton = el.querySelectorAll('.btn-group .btn')[0]; + const mergeLocallyButton = el.querySelectorAll('.btn-group .btn')[1]; + + expect(el.textContent).toContain('There are merge conflicts.'); + expect(el.textContent).not.toContain('ask someone with write access'); + expect(el.querySelector('.btn-success').disabled).toBeTruthy(); + expect(el.querySelectorAll('.btn-group .btn').length).toBe(2); + expect(resolveButton.textContent).toContain('Resolve conflicts'); + expect(resolveButton.getAttribute('href')).toEqual(path); + expect(mergeLocallyButton.textContent).toContain('Merge locally'); + }); + + describe('when user does not have permission to merge', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + vm.mr.canMerge = false; + }); + + it('should show proper message', (done) => { + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('ask someone with write access'); + done(); + }); + }); + + it('should not have action buttons', (done) => { + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.btn').length).toBe(1); + expect(vm.$el.querySelector('a.js-resolve-conflicts-button')).toEqual(null); + expect(vm.$el.querySelector('a.js-merge-locally-button')).toEqual(null); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js new file mode 100644 index 00000000000..587b83430d9 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const mr = { + mergeError: 'Merge error happened.', +}; +const createComponent = () => { + const Component = Vue.extend(failedToMergeComponent); + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetFailedToMerge', () => { + describe('data', () => { + it('should have default data', () => { + const data = failedToMergeComponent.data(); + + expect(data.timer).toEqual(10); + expect(data.isRefreshing).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('timerText', () => { + it('should return correct timer text', () => { + const vm = createComponent(); + expect(vm.timerText).toEqual('10 seconds'); + + vm.timer = 1; + expect(vm.timerText).toEqual('a second'); + }); + }); + }); + + describe('created', () => { + it('should disable polling', () => { + spyOn(eventHub, '$emit'); + createComponent(); + + expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling'); + }); + }); + + describe('methods', () => { + describe('refresh', () => { + it('should emit event to request component refresh', () => { + spyOn(eventHub, '$emit'); + const vm = createComponent(); + + expect(vm.isRefreshing).toBeFalsy(); + + vm.refresh(); + expect(vm.isRefreshing).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling'); + }); + }); + + describe('updateTimer', () => { + it('should update timer and emit event when timer end', () => { + const vm = createComponent(); + spyOn(vm, 'refresh'); + + expect(vm.timer).toEqual(10); + + for (let i = 0; i < 10; i++) { // eslint-disable-line + expect(vm.timer).toEqual(10 - i); + vm.updateTimer(); + } + + expect(vm.refresh).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', (done) => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('Merge error happened.'); + expect(el.innerText).toContain('Refreshing in 10 seconds'); + expect(el.innerText).not.toContain('Merge failed.'); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(el.querySelector('button').innerText).toContain('Merge'); + expect(el.querySelector('.js-refresh-button').innerText).toContain('Refresh now'); + expect(el.querySelector('.js-refresh-label')).toEqual(null); + expect(el.innerText).not.toContain('Refreshing now...'); + setTimeout(() => { + expect(el.innerText).toContain('Refreshing in 9 seconds'); + done(); + }, 1010); + }); + + it('should just generic merge failed message if merge_error is not available', (done) => { + vm.mr.mergeError = null; + + Vue.nextTick(() => { + expect(el.innerText).toContain('Merge failed.'); + expect(el.innerText).not.toContain('Merge error happened.'); + done(); + }); + }); + + it('should show refresh label when refresh requested', () => { + vm.refresh(); + Vue.nextTick(() => { + expect(el.innerText).not.toContain('Merge failed. Refreshing'); + expect(el.innerText).toContain('Refreshing now...'); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js new file mode 100644 index 00000000000..fb2ef606604 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import lockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_locked'; + +describe('MRWidgetLocked', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = lockedComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(lockedComponent); + const mr = { + targetBranchPath: '/branch-path', + targetBranch: 'branch', + }; + const el = new Component({ + el: document.createElement('div'), + propsData: { mr }, + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('it is locked'); + expect(el.innerText).toContain('changes will be merged into'); + expect(el.querySelector('.label-branch a').getAttribute('href')).toEqual(mr.targetBranchPath); + expect(el.querySelector('.label-branch a').textContent).toContain(mr.targetBranch); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js new file mode 100644 index 00000000000..8d8b90cea16 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const targetBranchPath = '/foo/bar'; +const targetBranch = 'foo'; +const sha = '1EA2EZ34'; + +const createComponent = () => { + const Component = Vue.extend(mwpsComponent); + const mr = { + shouldRemoveSourceBranch: false, + canRemoveSourceBranch: true, + canCancelAutomaticMerge: true, + mergeUserId: 1, + currentUserId: 1, + setToMWPSBy: {}, + sha, + targetBranchPath, + targetBranch, + }; + + const service = { + cancelAutomaticMerge() {}, + mergeResource: { + save() {}, + }, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetMergeWhenPipelineSucceeds', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = mwpsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(mwpsComponent.components['mr-widget-author']).toBeDefined(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = mwpsComponent.data(); + + expect(data.isCancellingAutoMerge).toBeFalsy(); + expect(data.isRemovingSourceBranch).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('canRemoveSourceBranch', () => { + it('should return true when user is able to remove source branch', () => { + const vm = createComponent(); + + expect(vm.canRemoveSourceBranch).toBeTruthy(); + }); + + it('should return false when user id is not the same with who set the MWPS', () => { + const vm = createComponent(); + + vm.mr.mergeUserId = 2; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + + vm.mr.currentUserId = 2; + expect(vm.canRemoveSourceBranch).toBeTruthy(); + + vm.mr.currentUserId = 3; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + + it('should return false when shouldRemoveSourceBranch set to false', () => { + const vm = createComponent(); + + vm.mr.shouldRemoveSourceBranch = true; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + + it('should return false if user is not able to remove the source branch', () => { + const vm = createComponent(); + + vm.mr.canRemoveSourceBranch = false; + expect(vm.canRemoveSourceBranch).toBeFalsy(); + }); + }); + }); + + describe('methods', () => { + describe('cancelAutomaticMerge', () => { + it('should set flag and call service then tell main component to update the widget with data', (done) => { + const vm = createComponent(); + const mrObj = { + is_new_mr_data: true, + }; + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'cancelAutomaticMerge').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return mrObj; + }, + }); + })); + + vm.cancelAutomaticMerge(); + setTimeout(() => { + expect(vm.isCancellingAutoMerge).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + done(); + }, 333); + }); + }); + + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$emit'); + spyOn(vm.service.mergeResource, 'save').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return { + status: 'merge_when_pipeline_succeeds', + }; + }, + }); + })); + + vm.removeSourceBranch(); + setTimeout(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(vm.service.mergeResource.save).toHaveBeenCalledWith({ + sha, + merge_when_pipeline_succeeds: true, + should_remove_source_branch: true, + }); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('to be merged automatically when the pipeline succeeds.'); + expect(el.innerText).toContain('The changes will be merged into'); + expect(el.innerText).toContain(targetBranch); + expect(el.innerText).toContain('The source branch will not be removed.'); + expect(el.querySelector('.js-cancel-auto-merge').innerText).toContain('Cancel automatic merge'); + expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeFalsy(); + expect(el.querySelector('.js-remove-source-branch').innerText).toContain('Remove source branch'); + expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeFalsy(); + }); + + it('should disable cancel auto merge button when the action is in progress', (done) => { + vm.isCancellingAutoMerge = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeTruthy(); + done(); + }); + }); + + it('should show source branch will be removed text when it source branch set to remove', (done) => { + vm.mr.shouldRemoveSourceBranch = true; + + Vue.nextTick(() => { + const normalizedText = el.innerText.replace(/\s+/g, ' '); + expect(normalizedText).toContain('The source branch will be removed.'); + expect(normalizedText).not.toContain('The source branch will not be removed.'); + done(); + }); + }); + + it('should not show remove source branch button when user not able to remove source branch', (done) => { + vm.mr.currentUserId = 4; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-source-branch')).toEqual(null); + done(); + }); + }); + + it('should disable remove source branch button when the action is in progress', (done) => { + vm.isRemovingSourceBranch = true; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeTruthy(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js new file mode 100644 index 00000000000..6628010112d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const targetBranch = 'foo'; + +const createComponent = () => { + const Component = Vue.extend(mergedComponent); + const mr = { + isRemovingSourceBranch: false, + cherryPickInForkPath: false, + canCherryPickInCurrentMR: true, + revertInForkPath: false, + canRevertInCurrentMR: true, + canRemoveSourceBranch: true, + sourceBranchRemoved: true, + mergedBy: {}, + mergedAt: '', + updatedAt: '', + targetBranch, + }; + + const service = { + removeSourceBranch() {}, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetMerged', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = mergedComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(mergedComponent.components['mr-widget-author-and-time']).toBeDefined(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = mergedComponent.data(); + + expect(data.isMakingRequest).toBeFalsy(); + }); + }); + + describe('computed', () => { + describe('shouldShowRemoveSourceBranch', () => { + it('should correct value when fields changed', () => { + const vm = createComponent(); + vm.mr.sourceBranchRemoved = false; + expect(vm.shouldShowRemoveSourceBranch).toBeTruthy(); + + vm.mr.sourceBranchRemoved = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.sourceBranchRemoved = false; + vm.mr.canRemoveSourceBranch = false; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.isRemovingSourceBranch = true; + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + }); + }); + describe('shouldShowSourceBranchRemoving', () => { + it('should correct value when fields changed', () => { + const vm = createComponent(); + vm.mr.sourceBranchRemoved = false; + expect(vm.shouldShowSourceBranchRemoving).toBeFalsy(); + + vm.mr.sourceBranchRemoved = true; + expect(vm.shouldShowRemoveSourceBranch).toBeFalsy(); + + vm.mr.sourceBranchRemoved = false; + vm.isMakingRequest = true; + expect(vm.shouldShowSourceBranchRemoving).toBeTruthy(); + + vm.isMakingRequest = false; + vm.mr.isRemovingSourceBranch = true; + expect(vm.shouldShowSourceBranchRemoving).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'removeSourceBranch').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return { + message: 'Branch was removed', + }; + }, + }); + })); + + vm.removeSourceBranch(); + setTimeout(() => { + const args = eventHub.$emit.calls.argsFor(0); + expect(vm.isMakingRequest).toBeTruthy(); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).not.toThrow(); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('.js-mr-widget-author')).toBeDefined(); + expect(el.innerText).toContain('The changes were merged into'); + expect(el.innerText).toContain(targetBranch); + expect(el.innerText).toContain('The source branch has been removed.'); + expect(el.innerText).toContain('Revert'); + expect(el.innerText).toContain('Cherry-pick'); + expect(el.innerText).not.toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch is being removed.'); + }); + + it('should not show source branch removed text', (done) => { + vm.mr.sourceBranchRemoved = false; + + Vue.nextTick(() => { + expect(el.innerText).toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch has been removed.'); + done(); + }); + }); + + it('should show source branch removing text', (done) => { + vm.mr.isRemovingSourceBranch = true; + vm.mr.sourceBranchRemoved = false; + + Vue.nextTick(() => { + expect(el.innerText).toContain('The source branch is being removed.'); + expect(el.innerText).not.toContain('You can remove source branch now.'); + expect(el.innerText).not.toContain('The source branch has been removed.'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js new file mode 100644 index 00000000000..98674d12afb --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch'; + +const createComponent = () => { + const Component = Vue.extend(missingBranchComponent); + const mr = { + sourceBranchRemoved: true, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetMissingBranch', () => { + describe('props', () => { + it('should have props', () => { + const mrProp = missingBranchComponent.props.mr; + + expect(mrProp.type instanceof Object).toBeTruthy(); + expect(mrProp.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components added', () => { + expect(missingBranchComponent.components['mr-widget-merge-help']).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('missingBranchName', () => { + it('should return proper branch name', () => { + const vm = createComponent(); + expect(vm.missingBranchName).toEqual('source'); + + vm.mr.sourceBranchRemoved = false; + expect(vm.missingBranchName).toEqual('target'); + }); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + const content = el.textContent.replace(/\n(\s)+/g, ' ').trim(); + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(content).toContain('source branch does not exist.'); + expect(content).toContain('Please restore the source branch or use a different source branch.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js new file mode 100644 index 00000000000..61e00f4cf79 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed'; + +describe('MRWidgetNotAllowed', () => { + describe('template', () => { + const Component = Vue.extend(notAllowedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); + expect(vm.$el.innerText).toContain('Ask someone with write access to this repository to merge this request.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js new file mode 100644 index 00000000000..d40c67b189d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import nothingToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge'; + +describe('MRWidgetNothingToMerge', () => { + describe('template', () => { + const Component = Vue.extend(nothingToMergeComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('There is nothing to merge from source branch into target branch.'); + expect(vm.$el.innerText).toContain('Please push new commits or use a different branch.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js new file mode 100644 index 00000000000..b293d118571 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked'; + +describe('MRWidgetPipelineBlocked', () => { + describe('template', () => { + const Component = Vue.extend(pipelineBlockedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js new file mode 100644 index 00000000000..807fba705d4 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import pipelineFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_failed'; + +describe('MRWidgetPipelineFailed', () => { + describe('template', () => { + const Component = Vue.extend(pipelineFailedComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js new file mode 100644 index 00000000000..74df99415c9 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -0,0 +1,389 @@ +import Vue from 'vue'; +import readyToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_ready_to_merge'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import * as simplePoll from '~/lib/utils/simple_poll'; + +const commitMessage = 'This is the commit message'; +const commitMessageWithDescription = 'This is the commit message description'; +const createComponent = () => { + const Component = Vue.extend(readyToMergeComponent); + const mr = { + isPipelineActive: false, + pipeline: null, + isPipelineFailed: false, + onlyAllowMergeIfPipelineSucceeds: false, + hasCI: false, + ciStatus: null, + sha: '12345678', + commitMessage, + commitMessageWithDescription, + }; + + const service = { + merge() {}, + poll() {}, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetReadyToMerge', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + describe('props', () => { + it('should have props', () => { + const { mr, service } = readyToMergeComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + expect(vm.removeSourceBranch).toBeTruthy(true); + expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.showCommitMessageEditor).toBeFalsy(); + expect(vm.isMakingRequest).toBeFalsy(); + expect(vm.isMergingImmediately).toBeFalsy(); + expect(vm.commitMessage).toBe(vm.mr.commitMessage); + expect(vm.successSvg).toBeDefined(); + expect(vm.warningSvg).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('commitMessageLinkTitle', () => { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + it('should return message wit description', () => { + expect(vm.commitMessageLinkTitle).toEqual(withDesc); + }); + + it('should return message without description', () => { + vm.useCommitMessageWithDescription = true; + expect(vm.commitMessageLinkTitle).toEqual(withoutDesc); + }); + }); + + describe('mergeButtonClass', () => { + const defaultClass = 'btn btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + + it('should return default class', () => { + vm.mr.pipeline = true; + expect(vm.mergeButtonClass).toEqual(defaultClass); + }); + + it('should return failed class when MR has CI but also has an unknown status', () => { + vm.mr.hasCI = true; + expect(vm.mergeButtonClass).toEqual(failedClass); + }); + + it('should return default class when MR has no pipeline', () => { + expect(vm.mergeButtonClass).toEqual(defaultClass); + }); + + it('should return in action class when pipeline is active', () => { + vm.mr.pipeline = {}; + vm.mr.isPipelineActive = true; + expect(vm.mergeButtonClass).toEqual(inActionClass); + }); + + it('should return failed class when pipeline is failed', () => { + vm.mr.pipeline = {}; + vm.mr.isPipelineFailed = true; + expect(vm.mergeButtonClass).toEqual(failedClass); + }); + }); + + describe('mergeButtonText', () => { + it('should return Merge', () => { + expect(vm.mergeButtonText).toEqual('Merge'); + }); + + it('should return Merge in progress', () => { + vm.isMergingImmediately = true; + expect(vm.mergeButtonText).toEqual('Merge in progress'); + }); + + it('should return Merge when pipeline succeeds', () => { + vm.isMergingImmediately = false; + vm.mr.isPipelineActive = true; + expect(vm.mergeButtonText).toEqual('Merge when pipeline succeeds'); + }); + }); + + describe('shouldShowMergeOptionsDropdown', () => { + it('should return false with initial data', () => { + expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy(); + }); + + it('should return true when pipeline active', () => { + vm.mr.isPipelineActive = true; + expect(vm.shouldShowMergeOptionsDropdown).toBeTruthy(); + }); + + it('should return false when pipeline active but only merge when pipeline succeeds set in project options', () => { + vm.mr.isPipelineActive = true; + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + expect(vm.shouldShowMergeOptionsDropdown).toBeFalsy(); + }); + }); + + describe('isMergeButtonDisabled', () => { + it('should return false with initial data', () => { + expect(vm.isMergeButtonDisabled).toBeFalsy(); + }); + + it('should return true when there is no commit message', () => { + vm.commitMessage = ''; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + + it('should return true if merge is not allowed', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + vm.mr.isPipelineFailed = true; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + + it('should return true when there vm instance is making request', () => { + vm.isMakingRequest = true; + expect(vm.isMergeButtonDisabled).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('isMergeAllowed', () => { + it('should return false with initial data', () => { + expect(vm.isMergeAllowed()).toBeTruthy(); + }); + + it('should return false when MR is set only merge when pipeline succeeds', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + expect(vm.isMergeAllowed()).toBeTruthy(); + }); + + it('should return true true', () => { + vm.mr.onlyAllowMergeIfPipelineSucceeds = true; + vm.mr.isPipelineFailed = true; + expect(vm.isMergeAllowed()).toBeFalsy(); + }); + }); + + describe('updateCommitMessage', () => { + it('should revert flag and change commitMessage', () => { + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.commitMessage).toEqual(commitMessage); + vm.updateCommitMessage(); + expect(vm.useCommitMessageWithDescription).toBeTruthy(); + expect(vm.commitMessage).toEqual(commitMessageWithDescription); + vm.updateCommitMessage(); + expect(vm.useCommitMessageWithDescription).toBeFalsy(); + expect(vm.commitMessage).toEqual(commitMessage); + }); + }); + + describe('toggleCommitMessageEditor', () => { + it('should toggle showCommitMessageEditor flag', () => { + expect(vm.showCommitMessageEditor).toBeFalsy(); + vm.toggleCommitMessageEditor(); + expect(vm.showCommitMessageEditor).toBeTruthy(); + }); + }); + + describe('handleMergeButtonClick', () => { + const returnPromise = status => new Promise((resolve) => { + resolve({ + json() { + return { status }; + }, + }); + }); + + it('should handle merge when pipeline succeeds', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'merge').and.returnValue(returnPromise('merge_when_pipeline_succeeds')); + vm.removeSourceBranch = false; + vm.handleMergeButtonClick(true); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeTruthy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.sha).toEqual(vm.mr.sha); + expect(params.commit_message).toEqual(vm.mr.commitMessage); + expect(params.should_remove_source_branch).toBeFalsy(); + expect(params.merge_when_pipeline_succeeds).toBeTruthy(); + done(); + }, 333); + }); + + it('should handle merge failed', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'merge').and.returnValue(returnPromise('failed')); + vm.handleMergeButtonClick(false, true); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.should_remove_source_branch).toBeTruthy(); + expect(params.merge_when_pipeline_succeeds).toBeFalsy(); + done(); + }, 333); + }); + + it('should handle merge action accepted case', (done) => { + spyOn(vm.service, 'merge').and.returnValue(returnPromise('success')); + spyOn(vm, 'initiateMergePolling'); + vm.handleMergeButtonClick(); + + setTimeout(() => { + expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); + expect(vm.isMakingRequest).toBeTruthy(); + expect(vm.initiateMergePolling).toHaveBeenCalled(); + + const params = vm.service.merge.calls.argsFor(0)[0]; + expect(params.should_remove_source_branch).toBeTruthy(); + expect(params.merge_when_pipeline_succeeds).toBeFalsy(); + done(); + }, 333); + }); + }); + + describe('initiateMergePolling', () => { + it('should call simplePoll', () => { + spyOn(simplePoll, 'default'); + vm.initiateMergePolling(); + expect(simplePoll.default).toHaveBeenCalled(); + }); + }); + + describe('handleMergePolling', () => { + const returnPromise = state => new Promise((resolve) => { + resolve({ + json() { + return { state, source_branch_exists: true }; + }, + }); + }); + + it('should call start and stop polling when MR merged', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged')); + spyOn(vm, 'initiateRemoveSourceBranchPolling'); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(vm.service.poll).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('FetchActionsContent'); + expect(vm.initiateRemoveSourceBranchPolling).toHaveBeenCalled(); + expect(cpc).toBeFalsy(); + expect(spc).toBeTruthy(); + + done(); + }, 333); + }); + + it('should continue polling until MR is merged', (done) => { + spyOn(vm.service, 'poll').and.returnValue(returnPromise('some_other_state')); + spyOn(vm, 'initiateRemoveSourceBranchPolling'); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleMergePolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(cpc).toBeTruthy(); + expect(spc).toBeFalsy(); + + done(); + }, 333); + }); + }); + + describe('initiateRemoveSourceBranchPolling', () => { + it('should emit event and call simplePoll', () => { + spyOn(eventHub, '$emit'); + spyOn(simplePoll, 'default'); + + vm.initiateRemoveSourceBranchPolling(); + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [true]); + expect(simplePoll.default).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveBranchPolling', () => { + const returnPromise = state => new Promise((resolve) => { + resolve({ + json() { + return { source_branch_exists: state }; + }, + }); + }); + + it('should call start and stop polling when MR merged', (done) => { + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'poll').and.returnValue(returnPromise(false)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(vm.service.poll).toHaveBeenCalled(); + + const args = eventHub.$emit.calls.argsFor(0); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).toBeDefined(); + args[1](); + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [false]); + + expect(cpc).toBeFalsy(); + expect(spc).toBeTruthy(); + + done(); + }, 333); + }); + + it('should continue polling until MR is merged', (done) => { + spyOn(vm.service, 'poll').and.returnValue(returnPromise(true)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + vm.handleRemoveBranchPolling(() => { cpc = true; }, () => { spc = true; }); + setTimeout(() => { + expect(cpc).toBeTruthy(); + expect(spc).toBeFalsy(); + + done(); + }, 333); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js new file mode 100644 index 00000000000..fe87f110354 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions'; + +describe('MRWidgetUnresolvedDiscussions', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = unresolvedDiscussionsComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + }); + }); + + describe('template', () => { + let el; + let vm; + const path = 'foo/bar'; + + beforeEach(() => { + const Component = Vue.extend(unresolvedDiscussionsComponent); + const mr = { + createIssueToResolveDiscussionsPath: path, + }; + vm = new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('There are unresolved discussions. Please resolve these discussions'); + expect(el.innerText).toContain('Create an issue to resolve them later'); + expect(el.querySelector('.js-create-issue').getAttribute('href')).toEqual(path); + }); + + it('should not show create issue button if user cannot create issue', (done) => { + vm.mr.createIssueToResolveDiscussionsPath = ''; + + Vue.nextTick(() => { + expect(el.querySelector('.js-create-issue')).toEqual(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js new file mode 100644 index 00000000000..45bd1a69964 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import wipComponent from '~/vue_merge_request_widget/components/states/mr_widget_wip'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +const createComponent = () => { + const Component = Vue.extend(wipComponent); + const mr = { + title: 'The best MR ever', + removeWIPPath: '/path/to/remove/wip', + }; + const service = { + removeWIP() {}, + }; + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('MRWidgetWIP', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = wipComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.required).toBeTruthy(); + + expect(service.type instanceof Object).toBeTruthy(); + expect(service.required).toBeTruthy(); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const vm = createComponent(); + expect(vm.isMakingRequest).toBeFalsy(); + }); + }); + + describe('methods', () => { + const mrObj = { + is_new_mr_data: true, + }; + + describe('removeWIP', () => { + it('should make a request to service and handle response', (done) => { + const vm = createComponent(); + + spyOn(window, 'Flash').and.returnValue(true); + spyOn(eventHub, '$emit'); + spyOn(vm.service, 'removeWIP').and.returnValue(new Promise((resolve) => { + resolve({ + json() { + return mrObj; + }, + }); + })); + + vm.removeWIP(); + setTimeout(() => { + expect(vm.isMakingRequest).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + expect(window.Flash).toHaveBeenCalledWith('The merge request can now be merged.', 'notice'); + done(); + }, 333); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.innerText).toContain('This merge request is currently Work In Progress and therefore unable to merge'); + expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(el.querySelector('button').innerText).toContain('Merge'); + expect(el.querySelector('.js-remove-wip').innerText).toContain('Resolve WIP status'); + }); + + it('should not show removeWIP button is user cannot update MR', (done) => { + vm.mr.removeWIPPath = ''; + + Vue.nextTick(() => { + expect(el.querySelector('.js-remove-wip')).toEqual(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js new file mode 100644 index 00000000000..e6f96d5588b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -0,0 +1,214 @@ +/* eslint-disable */ + +export default { + "id": 132, + "iid": 22, + "assignee_id": null, + "author_id": 1, + "description": "", + "lock_version": null, + "milestone_id": null, + "position": 0, + "state": "merged", + "title": "Update README.md", + "updated_by_id": null, + "created_at": "2017-04-07T12:27:26.718Z", + "updated_at": "2017-04-07T15:39:25.852Z", + "deleted_at": null, + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null, + "in_progress_merge_commit_sha": null, + "locked_at": null, + "merge_commit_sha": "53027d060246c8f47e4a9310fb332aa52f221775", + "merge_error": null, + "merge_params": { + "force_remove_source_branch": null + }, + "merge_status": "can_be_merged", + "merge_user_id": null, + "merge_when_pipeline_succeeds": false, + "source_branch": "daaaa", + "source_project_id": 19, + "target_branch": "master", + "target_project_id": 19, + "merge_event": { + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2017-04-07T15:39:25.696Z" + }, + "closed_event": null, + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "merge_user": null, + "diff_head_sha": "104096c51715e12e7ae41f9333e9fa35b73f385d", + "diff_head_commit_short_id": "104096c5", + "merge_commit_message": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "pipeline": { + "id": 172, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "active": false, + "coverage": "92.16", + "path": "/root/acets-app/pipelines/172", + "details": { + "status": { + "icon": "icon_status_success", + "favicon": "favicon_status_success", + "text": "passed", + "label": "passed", + "group": "success", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172" + }, + "duration": null, + "finished_at": "2017-04-07T14:00:14.256Z", + "stages": [ + { + "name": "build", + "title": "build: failed", + "status": { + "icon": "icon_status_failed", + "favicon": "favicon_status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172#build" + }, + "path": "/root/acets-app/pipelines/172#build", + "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=build" + }, + { + "name": "review", + "title": "review: skipped", + "status": { + "icon": "icon_status_skipped", + "favicon": "favicon_status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "has_details": true, + "details_path": "/root/acets-app/pipelines/172#review" + }, + "path": "/root/acets-app/pipelines/172#review", + "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=review" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "stop_review", + "path": "/root/acets-app/builds/1427/play", + "playable": false + } + ] + }, + "flags": { + "latest": false, + "triggered": false, + "stuck": false, + "yaml_errors": false, + "retryable": true, + "cancelable": false + }, + "ref": { + "name": "daaaa", + "path": "/root/acets-app/tree/daaaa", + "tag": false, + "branch": true + }, + "commit": { + "id": "104096c51715e12e7ae41f9333e9fa35b73f385d", + "short_id": "104096c5", + "title": "Update README.md", + "created_at": "2017-04-07T15:27:18.000+03:00", + "parent_ids": [ + "2396536178668d8930c29d904e53bd4d06228b32" + ], + "message": "Update README.md", + "author_name": "Administrator", + "author_email": "admin@example.com", + "authored_date": "2017-04-07T15:27:18.000+03:00", + "committer_name": "Administrator", + "committer_email": "admin@example.com", + "committed_date": "2017-04-07T15:27:18.000+03:00", + "author": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "author_gravatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "commit_url": "http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d", + "commit_path": "/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d" + }, + "retry_path": "/root/acets-app/pipelines/172/retry", + "created_at": "2017-04-07T12:27:19.520Z", + "updated_at": "2017-04-07T15:28:44.800Z" + }, + "work_in_progress": false, + "source_branch_exists": false, + "mergeable_discussions_state": true, + "conflicts_can_be_resolved_in_ui": false, + "branch_missing": true, + "commits_count": 1, + "has_conflicts": false, + "can_be_merged": true, + "has_ci": true, + "ci_status": "success", + "pipeline_status_path": "/root/acets-app/merge_requests/22/pipeline_status", + "issues_links": { + "closing": "", + "mentioned_but_not_closing": "" + }, + "current_user": { + "can_resolve_conflicts": true, + "can_remove_source_branch": false, + "can_revert_on_current_merge_request": true, + "can_cherry_pick_on_current_merge_request": true + }, + "target_branch_path": "/root/acets-app/branches/master", + "source_branch_path": "/root/acets-app/branches/daaaa", + "conflict_resolution_ui_path": "/root/acets-app/merge_requests/22/conflicts", + "remove_wip_path": "/root/acets-app/merge_requests/22/remove_wip", + "cancel_merge_when_pipeline_succeeds_path": "/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds", + "create_issue_to_resolve_discussions_path": "/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22", + "merge_path": "/root/acets-app/merge_requests/22/merge", + "cherry_pick_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", + "revert_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", + "email_patches_path": "/root/acets-app/merge_requests/22.patch", + "plain_diff_path": "/root/acets-app/merge_requests/22.diff", + "ci_status_path": "/root/acets-app/merge_requests/22/ci_status", + "status_path": "/root/acets-app/merge_requests/22.json", + "merge_check_path": "/root/acets-app/merge_requests/22/merge_check", + "ci_environments_status_url": "/root/acets-app/merge_requests/22/ci_environments_status", + "project_archived": false, + "merge_commit_message_with_description": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "diverged_commits_count": 0, + "only_allow_merge_if_pipeline_succeeds": false, + "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content" +} diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js new file mode 100644 index 00000000000..22ee7dcf0e7 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -0,0 +1,326 @@ +import Vue from 'vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import mockData from './mock_data'; + +const createComponent = () => { + delete mrWidgetOptions.el; // Prevent component mounting + gl.mrWidgetData = mockData; + const Component = Vue.extend(mrWidgetOptions); + return new Component(); +}; + +const returnPromise = data => new Promise((resolve) => { + resolve({ + json() { + return data; + }, + body: data, + }); +}); + +describe('mrWidgetOptions', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + describe('data', () => { + it('should instantiate Store and Service', () => { + expect(vm.mr).toBeDefined(); + expect(vm.service).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('componentName', () => { + it('should return merged component', () => { + expect(vm.componentName).toEqual('mr-widget-merged'); + }); + + it('should return conflicts component', () => { + vm.mr.state = 'conflicts'; + expect(vm.componentName).toEqual('mr-widget-conflicts'); + }); + }); + + describe('shouldRenderMergeHelp', () => { + it('should return false for the initial merged state', () => { + expect(vm.shouldRenderMergeHelp).toBeFalsy(); + }); + + it('should return true for a state which requires help widget', () => { + vm.mr.state = 'conflicts'; + expect(vm.shouldRenderMergeHelp).toBeTruthy(); + }); + }); + + describe('shouldRenderPipelines', () => { + it('should return true for the initial data', () => { + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return true when pipeline is empty but MR.hasCI is set to true', () => { + vm.mr.pipeline = {}; + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return true when pipeline available', () => { + vm.mr.hasCI = false; + expect(vm.shouldRenderPipelines).toBeTruthy(); + }); + + it('should return false when there is no pipeline', () => { + vm.mr.pipeline = {}; + vm.mr.hasCI = false; + expect(vm.shouldRenderPipelines).toBeFalsy(); + }); + }); + + describe('shouldRenderRelatedLinks', () => { + it('should return false for the initial data', () => { + expect(vm.shouldRenderRelatedLinks).toBeFalsy(); + }); + + it('should return true if there is relatedLinks in MR', () => { + vm.mr.relatedLinks = {}; + expect(vm.shouldRenderRelatedLinks).toBeTruthy(); + }); + }); + + describe('shouldRenderDeployments', () => { + it('should return false for the initial data', () => { + expect(vm.shouldRenderDeployments).toBeFalsy(); + }); + + it('should return true if there is deployments', () => { + vm.mr.deployments.push({}, {}); + expect(vm.shouldRenderDeployments).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('checkStatus', () => { + it('should tell service to check status', (done) => { + spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); + spyOn(vm.mr, 'setData'); + let isCbExecuted = false; + const cb = () => { + isCbExecuted = true; + }; + + vm.checkStatus(cb); + + setTimeout(() => { + expect(vm.service.checkStatus).toHaveBeenCalled(); + expect(vm.mr.setData).toHaveBeenCalled(); + expect(isCbExecuted).toBeTruthy(); + done(); + }, 333); + }); + }); + + describe('initPolling', () => { + it('should call SmartInterval', () => { + spyOn(gl, 'SmartInterval').and.returnValue({ + resume() {}, + stopTimer() {}, + }); + vm.initPolling(); + + expect(vm.pollingInterval).toBeDefined(); + expect(gl.SmartInterval).toHaveBeenCalled(); + }); + }); + + describe('initDeploymentsPolling', () => { + it('should call SmartInterval', () => { + spyOn(gl, 'SmartInterval'); + vm.initDeploymentsPolling(); + + expect(vm.deploymentsInterval).toBeDefined(); + expect(gl.SmartInterval).toHaveBeenCalled(); + }); + }); + + describe('fetchDeployments', () => { + it('should fetch deployments', (done) => { + spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }])); + + vm.fetchDeployments(); + + setTimeout(() => { + expect(vm.service.fetchDeployments).toHaveBeenCalled(); + expect(vm.mr.deployments.length).toEqual(1); + expect(vm.mr.deployments[0].deployment).toEqual(1); + done(); + }, 333); + }); + }); + + describe('fetchActionsContent', () => { + it('should fetch content of Cherry Pick and Revert modals', (done) => { + spyOn(vm.service, 'fetchMergeActionsContent').and.returnValue(returnPromise('hello world')); + + vm.fetchActionsContent(); + + setTimeout(() => { + expect(vm.service.fetchMergeActionsContent).toHaveBeenCalled(); + expect(document.body.textContent).toContain('hello world'); + done(); + }, 333); + }); + }); + + describe('bindEventHubListeners', () => { + it('should bind eventHub listeners', () => { + spyOn(vm, 'checkStatus').and.returnValue(() => {}); + spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); + spyOn(vm, 'fetchActionsContent'); + spyOn(vm.mr, 'setData'); + spyOn(vm, 'resumePolling'); + spyOn(vm, 'stopPolling'); + spyOn(eventHub, '$on'); + + vm.bindEventHubListeners(); + + eventHub.$emit('SetBranchRemoveFlag', ['flag']); + expect(vm.mr.isRemovingSourceBranch).toEqual('flag'); + + eventHub.$emit('FailedToMerge'); + expect(vm.mr.state).toEqual('failedToMerge'); + + eventHub.$emit('UpdateWidgetData', mockData); + expect(vm.mr.setData).toHaveBeenCalledWith(mockData); + + eventHub.$emit('EnablePolling'); + expect(vm.resumePolling).toHaveBeenCalled(); + + eventHub.$emit('DisablePolling'); + expect(vm.stopPolling).toHaveBeenCalled(); + + const listenersWithServiceRequest = { + MRWidgetUpdateRequested: true, + FetchActionsContent: true, + }; + + const allArgs = eventHub.$on.calls.allArgs(); + allArgs.forEach((params) => { + const eventName = params[0]; + const callback = params[1]; + + if (listenersWithServiceRequest[eventName]) { + listenersWithServiceRequest[eventName] = callback; + } + }); + + listenersWithServiceRequest.MRWidgetUpdateRequested(); + expect(vm.checkStatus).toHaveBeenCalled(); + + listenersWithServiceRequest.FetchActionsContent(); + expect(vm.fetchActionsContent).toHaveBeenCalled(); + }); + }); + + describe('handleMounted', () => { + it('should call required methods to do the initial kick-off', () => { + spyOn(vm, 'checkStatus'); + spyOn(vm, 'initDeploymentsPolling'); + spyOn(vm, 'setFavicon'); + + vm.handleMounted(); + + expect(vm.checkStatus).toHaveBeenCalled(); + expect(vm.setFavicon).toHaveBeenCalled(); + expect(vm.initDeploymentsPolling).toHaveBeenCalled(); + }); + }); + + describe('setFavicon', () => { + it('should call setFavicon method', () => { + spyOn(gl.utils, 'setFavicon'); + vm.setFavicon(); + + expect(gl.utils.setFavicon).toHaveBeenCalledWith(vm.mr.ciStatusFaviconPath); + }); + + it('should not call setFavicon when there is no ciStatusFaviconPath', () => { + spyOn(gl.utils, 'setFavicon'); + vm.mr.ciStatusFaviconPath = null; + vm.setFavicon(); + + expect(gl.utils.setFavicon).not.toHaveBeenCalled(); + }); + }); + + describe('resumePolling', () => { + it('should call stopTimer on pollingInterval', () => { + spyOn(vm.pollingInterval, 'resume'); + + vm.resumePolling(); + expect(vm.pollingInterval.resume).toHaveBeenCalled(); + }); + }); + + describe('stopPolling', () => { + it('should call stopTimer on pollingInterval', () => { + spyOn(vm.pollingInterval, 'stopTimer'); + + vm.stopPolling(); + expect(vm.pollingInterval.stopTimer).toHaveBeenCalled(); + }); + }); + + describe('createService', () => { + it('should instantiate a Service', () => { + const endpoints = { + mergePath: '/nice/path', + mergeCheckPath: '/nice/path', + cancelAutoMergePath: '/nice/path', + removeWIPPath: '/nice/path', + sourceBranchPath: '/nice/path', + ciEnvironmentsStatusPath: '/nice/path', + statusPath: '/nice/path', + mergeActionsContentPath: '/nice/path', + }; + + const serviceInstance = vm.createService(endpoints); + const isInstanceOfMRService = serviceInstance instanceof MRWidgetService; + expect(isInstanceOfMRService).toBe(true); + Object.keys(serviceInstance).forEach((key) => { + expect(serviceInstance[key]).toBeDefined(); + }); + }); + }); + }); + + describe('components', () => { + it('should register all components', () => { + const comps = mrWidgetOptions.components; + expect(comps['mr-widget-header']).toBeDefined(); + expect(comps['mr-widget-merge-help']).toBeDefined(); + expect(comps['mr-widget-pipeline']).toBeDefined(); + expect(comps['mr-widget-deployment']).toBeDefined(); + expect(comps['mr-widget-related-links']).toBeDefined(); + expect(comps['mr-widget-merged']).toBeDefined(); + expect(comps['mr-widget-closed']).toBeDefined(); + expect(comps['mr-widget-locked']).toBeDefined(); + expect(comps['mr-widget-failed-to-merge']).toBeDefined(); + expect(comps['mr-widget-wip']).toBeDefined(); + expect(comps['mr-widget-archived']).toBeDefined(); + expect(comps['mr-widget-conflicts']).toBeDefined(); + expect(comps['mr-widget-nothing-to-merge']).toBeDefined(); + expect(comps['mr-widget-not-allowed']).toBeDefined(); + expect(comps['mr-widget-missing-branch']).toBeDefined(); + expect(comps['mr-widget-ready-to-merge']).toBeDefined(); + expect(comps['mr-widget-checking']).toBeDefined(); + expect(comps['mr-widget-unresolved-discussions']).toBeDefined(); + expect(comps['mr-widget-pipeline-blocked']).toBeDefined(); + expect(comps['mr-widget-pipeline-failed']).toBeDefined(); + expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js new file mode 100644 index 00000000000..b63633c03b8 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/services/mr_widget_service_spec.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +Vue.use(VueResource); + +describe('MRWidgetService', () => { + const mr = { + mergePath: './', + mergeCheckPath: './', + cancelAutoMergePath: './', + removeWIPPath: './', + sourceBranchPath: './', + ciEnvironmentsStatusPath: './', + statusPath: './', + mergeActionsContentPath: './', + isServiceStore: true, + }; + + it('should have store and resources created in constructor', () => { + const service = new MRWidgetService(mr); + + expect(service.mergeResource).toBeDefined(); + expect(service.mergeCheckResource).toBeDefined(); + expect(service.cancelAutoMergeResource).toBeDefined(); + expect(service.removeWIPResource).toBeDefined(); + expect(service.removeSourceBranchResource).toBeDefined(); + expect(service.deploymentsResource).toBeDefined(); + expect(service.pollResource).toBeDefined(); + expect(service.mergeActionsContentResource).toBeDefined(); + }); + + it('should have methods defined', () => { + const service = new MRWidgetService(mr); + + expect(service.merge()).toBeDefined(); + expect(service.cancelAutomaticMerge()).toBeDefined(); + expect(service.removeWIP()).toBeDefined(); + expect(service.removeSourceBranch()).toBeDefined(); + expect(service.fetchDeployments()).toBeDefined(); + expect(service.poll()).toBeDefined(); + expect(service.checkStatus()).toBeDefined(); + expect(service.fetchMergeActionsContent()).toBeDefined(); + expect(MRWidgetService.stopEnvironment()).toBeDefined(); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js new file mode 100644 index 00000000000..ee944f4d4e5 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js @@ -0,0 +1,62 @@ +import getStateKey from '~/vue_merge_request_widget/stores/get_state_key'; + +describe('getStateKey', () => { + it('should return proper state name', () => { + const context = { + mergeStatus: 'checked', + mergeWhenPipelineSucceeds: false, + canMerge: true, + onlyAllowMergeIfPipelineSucceeds: false, + isPipelineFailed: false, + hasMergeableDiscussionsState: false, + isPipelineBlocked: false, + canBeMerged: false, + }; + const data = { + project_archived: false, + branch_missing: false, + commits_count: 2, + has_conflicts: false, + work_in_progress: false, + }; + const bound = getStateKey.bind(context, data); + expect(bound()).toEqual(null); + + context.canBeMerged = true; + expect(bound()).toEqual('readyToMerge'); + + context.isPipelineBlocked = true; + expect(bound()).toEqual('pipelineBlocked'); + + context.hasMergeableDiscussionsState = true; + expect(bound()).toEqual('unresolvedDiscussions'); + + context.onlyAllowMergeIfPipelineSucceeds = true; + context.isPipelineFailed = true; + expect(bound()).toEqual('pipelineFailed'); + + context.canMerge = false; + expect(bound()).toEqual('notAllowedToMerge'); + + context.mergeWhenPipelineSucceeds = true; + expect(bound()).toEqual('mergeWhenPipelineSucceeds'); + + data.work_in_progress = true; + expect(bound()).toEqual('workInProgress'); + + data.has_conflicts = true; + expect(bound()).toEqual('conflicts'); + + context.mergeStatus = 'unchecked'; + expect(bound()).toEqual('checking'); + + data.commits_count = 0; + expect(bound()).toEqual('nothingToMerge'); + + data.branch_missing = true; + expect(bound()).toEqual('missingBranch'); + + data.project_archived = true; + expect(bound()).toEqual('archived'); + }); +}); diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js index 2e89a07e76e..3d53a5ab24d 100644 --- a/spec/javascripts/vue_shared/ci_action_icons_spec.js +++ b/spec/javascripts/vue_shared/ci_action_icons_spec.js @@ -2,6 +2,7 @@ import getActionIcon from '~/vue_shared/ci_action_icons'; import cancelSVG from 'icons/_icon_action_cancel.svg'; import retrySVG from 'icons/_icon_action_retry.svg'; import playSVG from 'icons/_icon_action_play.svg'; +import stopSVG from 'icons/_icon_action_stop.svg'; describe('getActionIcon', () => { it('should return an empty string', () => { @@ -19,4 +20,8 @@ describe('getActionIcon', () => { it('should return play svg', () => { expect(getActionIcon('icon_action_play')).toEqual(playSVG); }); + + it('should render stop svg', () => { + expect(getActionIcon('icon_action_stop')).toEqual(stopSVG); + }); }); diff --git a/spec/javascripts/vue_shared/components/memory_graph_spec.js b/spec/javascripts/vue_shared/components/memory_graph_spec.js new file mode 100644 index 00000000000..d46a3f2328e --- /dev/null +++ b/spec/javascripts/vue_shared/components/memory_graph_spec.js @@ -0,0 +1,143 @@ +import Vue from 'vue'; +import memoryGraphComponent from '~/vue_shared/components/memory_graph'; +import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data'; + +const defaultHeight = '25'; +const defaultWidth = '100'; + +const createComponent = () => { + const Component = Vue.extend(memoryGraphComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { + metrics: [], + deploymentTime: 0, + width: '', + height: '', + pathD: '', + pathViewBox: '', + dotX: '', + dotY: '', + }, + }); +}; + +describe('MemoryGraph', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + describe('props', () => { + it('should have props with defaults', (done) => { + const { metrics, deploymentTime, width, height } = memoryGraphComponent.props; + + Vue.nextTick(() => { + const typeClassMatcher = (propItem, expectedType) => { + const PropItemTypeClass = propItem.type; + expect(new PropItemTypeClass() instanceof expectedType).toBeTruthy(); + expect(propItem.required).toBeTruthy(); + }; + + typeClassMatcher(metrics, Array); + typeClassMatcher(deploymentTime, Number); + typeClassMatcher(width, String); + typeClassMatcher(height, String); + done(); + }); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const data = memoryGraphComponent.data(); + const dataValidator = (dataItem, expectedType, defaultVal) => { + expect(typeof dataItem).toBe(expectedType); + expect(dataItem).toBe(defaultVal); + }; + + dataValidator(data.pathD, 'string', ''); + dataValidator(data.pathViewBox, 'string', ''); + dataValidator(data.dotX, 'string', ''); + dataValidator(data.dotY, 'string', ''); + }); + }); + + describe('computed', () => { + describe('getFormattedMedian', () => { + it('should show human readable median value based on provided median timestamp', () => { + vm.deploymentTime = mockMedian; + const formattedMedian = vm.getFormattedMedian; + expect(formattedMedian.indexOf('Deployed') > -1).toBeTruthy(); + expect(formattedMedian.indexOf('ago') > -1).toBeTruthy(); + }); + }); + }); + + describe('methods', () => { + describe('getMedianMetricIndex', () => { + it('should return index of closest metric timestamp to that of median', () => { + const matchingIndex = vm.getMedianMetricIndex(mockMedian, mockMetrics); + expect(matchingIndex).toBe(mockMedianIndex); + }); + }); + + describe('getGraphPlotValues', () => { + it('should return Object containing values to plot graph', () => { + const plotValues = vm.getGraphPlotValues(mockMedian, mockMetrics); + expect(plotValues.pathD).toBeDefined(); + expect(Array.isArray(plotValues.pathD)).toBeTruthy(); + + expect(plotValues.pathViewBox).toBeDefined(); + expect(typeof plotValues.pathViewBox).toBe('object'); + + expect(plotValues.dotX).toBeDefined(); + expect(typeof plotValues.dotX).toBe('number'); + + expect(plotValues.dotY).toBeDefined(); + expect(typeof plotValues.dotY).toBe('number'); + }); + }); + }); + + describe('template', () => { + it('should render template elements correctly', () => { + expect(el.classList.contains('memory-graph-container')).toBeTruthy(); + expect(el.querySelector('svg')).toBeDefined(); + }); + + it('should render graph when renderGraph is called internally', (done) => { + const { pathD, pathViewBox, dotX, dotY } = vm.getGraphPlotValues(mockMedian, mockMetrics); + vm.height = defaultHeight; + vm.width = defaultWidth; + vm.pathD = `M ${pathD}`; + vm.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; + vm.dotX = dotX; + vm.dotY = dotY; + + Vue.nextTick(() => { + const svgEl = el.querySelector('svg'); + expect(svgEl).toBeDefined(); + expect(svgEl.getAttribute('height')).toBe(defaultHeight); + expect(svgEl.getAttribute('width')).toBe(defaultWidth); + + const pathEl = el.querySelector('path'); + expect(pathEl).toBeDefined(); + expect(pathEl.getAttribute('d')).toBe(`M ${pathD}`); + expect(pathEl.getAttribute('viewBox')).toBe(`0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`); + + const circleEl = el.querySelector('circle'); + expect(circleEl).toBeDefined(); + expect(circleEl.getAttribute('r')).toBe('1.5'); + expect(circleEl.getAttribute('tranform')).toBe('translate(0 -1)'); + expect(circleEl.getAttribute('cx')).toBe(`${dotX}`); + expect(circleEl.getAttribute('cy')).toBe(`${dotY}`); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/mock_data.js b/spec/javascripts/vue_shared/components/mock_data.js new file mode 100644 index 00000000000..0d781bdca74 --- /dev/null +++ b/spec/javascripts/vue_shared/components/mock_data.js @@ -0,0 +1,69 @@ +/* eslint-disable */ + +export const mockMetrics = [ + [1493716685, '4.30859375'], + [1493716745, '4.30859375'], + [1493716805, '4.30859375'], + [1493716865, '4.30859375'], + [1493716925, '4.30859375'], + [1493716985, '4.30859375'], + [1493717045, '4.30859375'], + [1493717105, '4.30859375'], + [1493717165, '4.30859375'], + [1493717225, '4.30859375'], + [1493717285, '4.30859375'], + [1493717345, '4.30859375'], + [1493717405, '4.30859375'], + [1493717465, '4.30859375'], + [1493717525, '4.30859375'], + [1493717585, '4.30859375'], + [1493717645, '4.30859375'], + [1493717705, '4.30859375'], + [1493717765, '4.30859375'], + [1493717825, '4.30859375'], + [1493717885, '4.30859375'], + [1493717945, '4.30859375'], + [1493718005, '4.30859375'], + [1493718065, '4.30859375'], + [1493718125, '4.30859375'], + [1493718185, '4.30859375'], + [1493718245, '4.30859375'], + [1493718305, '4.234375'], + [1493718365, '4.234375'], + [1493718425, '4.234375'], + [1493718485, '4.234375'], + [1493718545, '4.243489583333333'], + [1493718605, '4.2109375'], + [1493718665, '4.2109375'], + [1493718725, '4.2109375'], + [1493718785, '4.26171875'], + [1493718845, '4.26171875'], + [1493718905, '4.26171875'], + [1493718965, '4.26171875'], + [1493719025, '4.26171875'], + [1493719085, '4.26171875'], + [1493719145, '4.26171875'], + [1493719205, '4.26171875'], + [1493719265, '4.26171875'], + [1493719325, '4.26171875'], + [1493719385, '4.26171875'], + [1493719445, '4.26171875'], + [1493719505, '4.26171875'], + [1493719565, '4.26171875'], + [1493719625, '4.26171875'], + [1493719685, '4.26171875'], + [1493719745, '4.26171875'], + [1493719805, '4.26171875'], + [1493719865, '4.26171875'], + [1493719925, '4.26171875'], + [1493719985, '4.26171875'], + [1493720045, '4.26171875'], + [1493720105, '4.26171875'], + [1493720165, '4.26171875'], + [1493720225, '4.26171875'], + [1493720285, '4.26171875'], +]; + +export const mockMedian = 1493718485; + +export const mockMedianIndex = 30; diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js index 96038718191..895e1c585b4 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js +++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import paginationComp from '~/vue_shared/components/table_pagination'; +import paginationComp from '~/vue_shared/components/table_pagination.vue'; import '~/lib/utils/common_utils'; describe('Pagination component', () => { diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb index 9d2ba481919..3610a0354e8 100644 --- a/spec/lib/gitlab/cycle_analytics/events_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -11,8 +11,6 @@ describe 'cycle analytics events' do end before do - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context]) - setup(context) end @@ -132,6 +130,8 @@ describe 'cycle analytics events' do end before do + merge_request.update(head_pipeline: pipeline) + create(:ci_build, pipeline: pipeline, status: :success, author: user) create(:ci_build, pipeline: pipeline, status: :success, author: user) @@ -228,6 +228,8 @@ describe 'cycle analytics events' do end before do + merge_request.update(head_pipeline: pipeline) + create(:ci_build, pipeline: pipeline, status: :success, author: user) create(:ci_build, pipeline: pipeline, status: :success, author: user) @@ -332,7 +334,7 @@ describe 'cycle analytics events' do def setup(context) milestone = create(:milestone, project: project) context.update(milestone: milestone) - mr = create_merge_request_closing_issue(context) + mr = create_merge_request_closing_issue(context, commit_message: "References #{context.to_reference}") ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 688e731bf15..34f617e23a5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -85,6 +85,7 @@ merge_requests: - merge_requests_closing_issues - metrics - timelogs +- head_pipeline merge_request_diff: - merge_request pipelines: @@ -102,6 +103,7 @@ pipelines: - manual_actions - artifacts - pipeline_schedule +- merge_requests statuses: - project - pipeline diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 3af2a172e6d..d2ceb1cf9ae 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -158,6 +158,7 @@ MergeRequest: - time_estimate - last_edited_at - last_edited_by_id +- head_pipeline_id MergeRequestDiff: - id - state diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index fc453a2704b..9d67e3d2f37 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -81,7 +81,11 @@ describe Gitlab::Prometheus, lib: true do describe '#query' do let(:prometheus_query) { prometheus_cpu_query('env-slug') } - let(:query_url) { prometheus_query_url(prometheus_query) } + let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) } + + around do |example| + Timecop.freeze { example.run } + end context 'when request returns vector results' do it 'returns data from the API call' do @@ -123,6 +127,20 @@ describe Gitlab::Prometheus, lib: true do Timecop.freeze { example.run } end + context 'when non utc time is passed' do + let(:time_stop) { Time.now.in_time_zone("Warsaw") } + let(:time_start) { time_stop - 8.hours } + + let(:query_url) { prometheus_query_range_url(prometheus_query, start: time_start.utc.to_f, stop: time_stop.utc.to_f) } + + it 'passed dates are properly converted to utc' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + subject.query_range(prometheus_query, start: time_start, stop: time_stop) + expect(req_stub).to have_been_requested + end + end + context 'when a start time is passed' do let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) } diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb new file mode 100644 index 00000000000..bd5f85b901d --- /dev/null +++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb') + +describe AddHeadPipelineForEachMergeRequest do + let(:migration) { described_class.new } + + let!(:project) { create(:empty_project) } + let!(:forked_project_link) { create(:forked_project_link, forked_from_project: project) } + let!(:other_project) { forked_project_link.forked_to_project } + + let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") } + let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_3) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_4) { create(:ci_pipeline, project: project, ref: "branch_2") } + + let!(:mr_1) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_1", target_branch: "target_1") } + let!(:mr_2) { create(:merge_request, source_project: other_project, target_project: project, source_branch: "branch_1", target_branch: "target_2") } + let!(:mr_3) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_2", target_branch: "master") } + let!(:mr_4) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_3", target_branch: "master") } + + context "#up" do + context "when source_project and source_branch of pipeline are the same of merge request" do + it "sets head_pipeline_id of given merge requests" do + migration.up + + expect(mr_1.reload.head_pipeline_id).to eq(pipeline_1.id) + expect(mr_2.reload.head_pipeline_id).to eq(pipeline_3.id) + expect(mr_3.reload.head_pipeline_id).to eq(pipeline_4.id) + expect(mr_4.reload.head_pipeline_id).to be_nil + end + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 208c8cb1c3d..06e990a0574 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1044,8 +1044,8 @@ describe Ci::Pipeline, models: true do let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') } it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do - merge_request = create(:merge_request, source_project: project, source_branch: pipeline.ref) allow_any_instance_of(MergeRequest).to receive(:diff_head_sha) { 'a288a022a53a5a944fae87bcec6efc87b7061808' } + merge_request = create(:merge_request, source_project: project, head_pipeline: pipeline, source_branch: pipeline.ref) expect(pipeline.merge_requests).to eq([merge_request]) end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 2092576e981..e382c7120de 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -163,3 +163,52 @@ describe Issue, "Mentionable" do end end end + +describe Commit, 'Mentionable' do + let(:project) { create(:project, :public, :repository) } + let(:commit) { project.commit } + + describe '#matches_cross_reference_regex?' do + it "is false when message doesn't reference anything" do + allow(commit.raw).to receive(:message).and_return "WIP: Do something" + + expect(commit.matches_cross_reference_regex?).to be false + end + + it 'is true if issue #number mentioned in title' do + allow(commit.raw).to receive(:message).and_return "#1" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references an MR' do + allow(commit.raw).to receive(:message).and_return "See merge request !12" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references a commit' do + allow(commit.raw).to receive(:message).and_return "a1b2c3d4" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if issue referenced by url' do + issue = create(:issue, project: project) + + allow(commit.raw).to receive(:message).and_return Gitlab::UrlBuilder.build(issue) + + expect(commit.matches_cross_reference_regex?).to be true + end + + context 'with external issue tracker' do + let(:project) { create(:jira_project) } + + it 'is true if external issues referenced' do + allow(commit.raw).to receive(:message).and_return 'JIRA-123' + + expect(commit.matches_cross_reference_regex?).to be true + end + end + end +end diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index c2ba012a0e6..d0b919efcf9 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -14,6 +14,7 @@ describe 'CycleAnalytics#test', feature: true do issue = context.create(:issue, project: context.project) merge_request = context.create_merge_request_closing_issue(issue) pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project) + merge_request.update(head_pipeline: pipeline) { pipeline: pipeline, issue: issue } end, start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 080ff2f3f43..212fcd884a8 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -49,6 +49,33 @@ describe Deployment, models: true do end end + describe '#metrics' do + let(:deployment) { create(:deployment) } + + subject { deployment.metrics(1.hour) } + + context 'metrics are disabled' do + it { is_expected.to eq({}) } + end + + context 'metrics are enabled' do + let(:simple_metrics) do + { + success: true, + metrics: {}, + last_update: 42 + } + end + + before do + allow(deployment.project).to receive_message_chain(:monitoring_service, :metrics) + .with(any_args).and_return(simple_metrics) + end + + it { is_expected.to eq(simple_metrics.merge(deployment_time: deployment.created_at.utc.to_i)) } + end + end + describe '#stop_action' do let(:build) { create(:ci_build) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 3d60e52f23f..6ca1eb0374d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -178,16 +178,20 @@ describe Group, models: true do describe '#avatar_url' do let!(:group) { create(:group, :access_requestable, :with_avatar) } let(:user) { create(:user) } - subject { group.avatar_url } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } context 'when avatar file is uploaded' do - before do - group.add_master(user) - end + before { group.add_master(user) } - let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } + it 'shows correct avatar url' do + expect(group.avatar_url).to eq(avatar_path) + expect(group.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(group.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 6cf3dd30ead..ef349530761 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -760,13 +760,8 @@ describe MergeRequest, models: true do describe '#head_pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do - pipeline = double(:ci_pipeline, ref: 'master') - - allow(subject).to receive(:diff_head_sha).and_return('123abc') - - expect(subject.source_project).to receive(:pipeline_for). - with('master', '123abc'). - and_return(pipeline) + pipeline = create(:ci_empty_pipeline, project: subject.source_project, ref: 'master', status: 'running', sha: "123abc") + subject.update(head_pipeline: pipeline) expect(subject.head_pipeline).to eq(pipeline) end @@ -1504,11 +1499,15 @@ describe MergeRequest, models: true do describe '#mergeable_with_slash_command?' do def create_pipeline(status) - create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline_with_one_job, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, status: status) + + merge_request.update(head_pipeline: pipeline) + + pipeline end let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) } diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index f3126bc1e57..82a3e2698c1 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -47,15 +47,30 @@ describe PrometheusService, models: true, caching: true do describe '#metrics' do let(:environment) { build_stubbed(:environment, slug: 'env-slug') } - subject { service.metrics(environment) } around do |example| Timecop.freeze { example.run } end - context 'with valid data' do + context 'with valid data without time range' do + subject { service.metrics(environment) } + + before do + stub_reactive_cache(service, prometheus_data, 'env-slug', nil, nil) + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data) + end + end + + context 'with valid data with time range' do + let(:t_start) { 1.hour.ago.utc } + let(:t_end) { Time.now.utc } + subject { service.metrics(environment, timeframe_start: t_start, timeframe_end: t_end) } + before do - stub_reactive_cache(service, prometheus_data, 'env-slug') + stub_reactive_cache(service, prometheus_data, 'env-slug', t_start, t_end) end it 'returns reactive data' do @@ -72,7 +87,7 @@ describe PrometheusService, models: true, caching: true do end subject do - service.calculate_reactive_cache(environment.slug) + service.calculate_reactive_cache(environment.slug, nil, nil) end context 'when service is inactive' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 429b3dd83af..28aa44d8458 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -813,8 +813,16 @@ describe Project, models: true do context 'when avatar file is uploaded' do let(:project) { create(:empty_project, :with_avatar) } let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + it 'shows correct url' do + expect(project.avatar_url).to eq(avatar_path) + expect(project.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(project.avatar_url).to eq([gitlab_host, avatar_path].join) + end end context 'When avatar file in git' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 63e71f5ff2f..b845e85b295 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -344,6 +344,35 @@ describe User, models: true do end end + describe '#update_tracked_fields!', :redis do + let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") } + let(:user) { create(:user) } + + it 'writes trackable attributes' do + expect do + user.update_tracked_fields!(request) + end.to change { user.reload.current_sign_in_at } + end + + it 'does not write trackable attributes when called a second time within the hour' do + user.update_tracked_fields!(request) + + expect do + user.update_tracked_fields!(request) + end.not_to change { user.reload.current_sign_in_at } + end + + it 'writes trackable attributes for a different user' do + user2 = create(:user) + + user.update_tracked_fields!(request) + + expect do + user2.update_tracked_fields!(request) + end.to change { user2.reload.current_sign_in_at } + end + end + shared_context 'user keys' do let(:user) { create(:user) } let!(:key) { create(:key, user: user) } @@ -935,12 +964,19 @@ describe User, models: true do describe '#avatar_url' do let(:user) { create(:user, :with_avatar) } - subject { user.avatar_url } context 'when avatar file is uploaded' do + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" } - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + it 'shows correct avatar url' do + expect(user.avatar_url).to eq(avatar_path) + expect(user.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(user.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb index 0e15beaa5e8..650432520bb 100644 --- a/spec/policies/environment_policy_spec.rb +++ b/spec/policies/environment_policy_spec.rb @@ -33,7 +33,7 @@ describe EnvironmentPolicy do let(:project) { create(:project, :public) } before do - project.add_master(user) + project.add_developer(user) end context 'when team member has ability to stop environment' do diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb new file mode 100644 index 00000000000..e599ddaf943 --- /dev/null +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -0,0 +1,356 @@ +require 'spec_helper' + +describe MergeRequestPresenter do + let(:resource) { create :merge_request, source_project: project } + let(:project) { create :empty_project } + let(:user) { create(:user) } + + describe '#ci_status' do + subject { described_class.new(resource).ci_status } + + context 'when no head pipeline' do + it 'return status using CiService' do + ci_service = double(MockCiService) + ci_status = double + + allow(resource.source_project) + .to receive(:ci_service) + .and_return(ci_service) + + allow(resource).to receive(:head_pipeline).and_return(nil) + + expect(ci_service).to receive(:commit_status) + .with(resource.diff_head_sha, resource.source_branch) + .and_return(ci_status) + + is_expected.to eq(ci_status) + end + end + + context 'when head pipeline present' do + let(:pipeline) { build_stubbed(:ci_pipeline) } + + before do + allow(resource).to receive(:head_pipeline).and_return(pipeline) + end + + context 'success with warnings' do + before do + allow(pipeline).to receive(:success?) { true } + allow(pipeline).to receive(:has_warnings?) { true } + end + + it 'returns "success_with_warnings"' do + is_expected.to eq('success_with_warnings') + end + end + + context 'pipeline HAS status AND its not success with warnings' do + before do + allow(pipeline).to receive(:success?) { false } + allow(pipeline).to receive(:has_warnings?) { false } + end + + it 'returns pipeline status' do + is_expected.to eq('pending') + end + end + + context 'pipeline has NO status AND its not success with warnings' do + before do + allow(pipeline).to receive(:status) { nil } + allow(pipeline).to receive(:success?) { false } + allow(pipeline).to receive(:has_warnings?) { false } + end + + it 'returns "preparing"' do + is_expected.to eq('preparing') + end + end + end + end + + describe '#conflict_resolution_path' do + let(:project) { create :empty_project } + let(:user) { create :user } + let(:path) { described_class.new(resource, current_user: user).conflict_resolution_path } + + context 'when MR cannot be resolved in UI' do + it 'does not return conflict resolution path' do + allow(resource).to receive(:conflicts_can_be_resolved_in_ui?) { true } + allow(resource).to receive(:conflicts_can_be_resolved_by?).with(user) { false } + + expect(path).to be_nil + end + end + + context 'when conflicts cannot be resolved by user' do + it 'does not return conflict resolution path' do + allow(resource).to receive(:conflicts_can_be_resolved_in_ui?) { false } + allow(resource).to receive(:conflicts_can_be_resolved_by?).with(user) { true } + + expect(path).to be_nil + end + end + + context 'when able to access conflict resolution UI' do + it 'does return conflict resolution path' do + allow(resource).to receive(:conflicts_can_be_resolved_in_ui?) { true } + allow(resource).to receive(:conflicts_can_be_resolved_by?).with(user) { true } + + expect(path) + .to eq("/#{project.full_path}/merge_requests/#{resource.iid}/conflicts") + end + end + end + + context 'issues links' do + let(:project) { create(:project, :private, creator: user, namespace: user.namespace) } + let(:issue_a) { create(:issue, project: project) } + let(:issue_b) { create(:issue, project: project) } + + let(:resource) do + create(:merge_request, + source_project: project, target_project: project, + description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}") + end + + before do + project.team << [user, :developer] + + allow(resource.project).to receive(:default_branch) + .and_return(resource.target_branch) + end + + describe '#closing_issues_links' do + subject { described_class.new(resource, current_user: user).closing_issues_links } + + it 'presents closing issues links' do + is_expected.to match("#{project.full_path}/issues/#{issue_a.iid}") + end + + it 'does not present related issues links' do + is_expected.not_to match("#{project.full_path}/issues/#{issue_b.iid}") + end + end + + describe '#mentioned_issues_links' do + subject do + described_class.new(resource, current_user: user) + .mentioned_issues_links + end + + it 'presents related issues links' do + is_expected.to match("#{project.full_path}/issues/#{issue_b.iid}") + end + + it 'does not present closing issues links' do + is_expected.not_to match("#{project.full_path}/issues/#{issue_a.iid}") + end + end + + describe '#assign_to_closing_issues_link' do + subject do + described_class.new(resource, current_user: user) + .assign_to_closing_issues_link + end + + before do + assign_issues_service = double(MergeRequests::AssignIssuesService, assignable_issues: assignable_issues) + allow(MergeRequests::AssignIssuesService).to receive(:new) + .and_return(assign_issues_service) + end + + context 'single closing issue' do + let(:issue) { create(:issue) } + let(:assignable_issues) { [issue] } + + it 'returns correct link with correct text' do + is_expected + .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues") + + is_expected + .to match("Assign yourself to this issue") + end + end + + context 'multiple closing issues' do + let(:issues) { create_list(:issue, 2) } + let(:assignable_issues) { issues } + + it 'returns correct link with correct text' do + is_expected + .to match("#{project.full_path}/merge_requests/#{resource.iid}/assign_related_issues") + + is_expected + .to match("Assign yourself to these issues") + end + end + + context 'no closing issue' do + let(:assignable_issues) { [] } + + it 'returns correct link with correct text' do + is_expected.to be_nil + end + end + end + end + + describe '#cancel_merge_when_pipeline_succeeds_path' do + subject do + described_class.new(resource, current_user: user) + .cancel_merge_when_pipeline_succeeds_path + end + + context 'when can cancel mwps' do + it 'returns path' do + allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + .with(user) + .and_return(true) + + is_expected.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/cancel_merge_when_pipeline_succeeds") + end + end + + context 'when cannot cancel mwps' do + it 'returns nil' do + allow(resource).to receive(:can_cancel_merge_when_pipeline_succeeds?) + .with(user) + .and_return(false) + + is_expected.to be_nil + end + end + end + + describe '#merge_path' do + subject do + described_class.new(resource, current_user: user).merge_path + end + + context 'when can be merged by user' do + it 'returns path' do + allow(resource).to receive(:can_be_merged_by?) + .with(user) + .and_return(true) + + is_expected + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/merge") + end + end + + context 'when cannot be merged by user' do + it 'returns nil' do + allow(resource).to receive(:can_be_merged_by?) + .with(user) + .and_return(false) + + is_expected.to be_nil + end + end + end + + describe '#create_issue_to_resolve_discussions_path' do + subject do + described_class.new(resource, current_user: user) + .create_issue_to_resolve_discussions_path + end + + context 'when can create issue and issues enabled' do + it 'returns path' do + allow(project).to receive(:issues_enabled?) { true } + project.team << [user, :master] + + is_expected + .to eq("/#{resource.project.full_path}/issues/new?merge_request_to_resolve_discussions_of=#{resource.iid}") + end + end + + context 'when cannot create issue' do + it 'returns nil' do + allow(project).to receive(:issues_enabled?) { true } + + is_expected.to be_nil + end + end + + context 'when issues disabled' do + it 'returns nil' do + allow(project).to receive(:issues_enabled?) { false } + project.team << [user, :master] + + is_expected.to be_nil + end + end + end + + describe '#remove_wip_path' do + subject do + described_class.new(resource, current_user: user).remove_wip_path + end + + context 'when merge request enabled and has permission' do + it 'has remove_wip_path' do + allow(project).to receive(:merge_requests_enabled?) { true } + project.team << [user, :master] + + is_expected + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/remove_wip") + end + end + + context 'when has no permission' do + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#target_branch_commits_path' do + subject do + described_class.new(resource, current_user: user) + .target_branch_commits_path + end + + context 'when target branch exists' do + it 'returns path' do + allow(resource).to receive(:target_branch_exists?) { true } + + is_expected + .to eq("/#{resource.target_project.full_path}/commits/#{resource.target_branch}") + end + end + + context 'when target branch does not exists' do + it 'returns nil' do + allow(resource).to receive(:target_branch_exists?) { false } + + is_expected.to be_nil + end + end + end + + describe '#source_branch_path' do + subject do + described_class.new(resource, current_user: user).source_branch_path + end + + context 'when source branch exists' do + it 'returns path' do + allow(resource).to receive(:source_branch_exists?) { true } + + is_expected + .to eq("/#{resource.source_project.full_path}/branches/#{resource.source_branch}") + end + end + + context 'when source branch does not exists' do + it 'returns nil' do + allow(resource).to receive(:source_branch_exists?) { false } + + is_expected.to be_nil + end + end + end +end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 3e27a3bee77..ed93a8815d3 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -178,7 +178,7 @@ describe API::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility']).to eq(Gitlab::VisibilityLevel.string_level(group1.visibility_level)) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb index 2862580cc70..065cb7ecfe4 100644 --- a/spec/requests/api/v3/groups_spec.rb +++ b/spec/requests/api/v3/groups_spec.rb @@ -176,7 +176,7 @@ describe API::V3::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility_level']).to eq(group1.visibility_level) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index a4f85c22943..fbb69bc0920 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -98,7 +98,7 @@ describe 'OpenID Connect requests' do expect(@payload['sub']).to eq hashed_subject end - it 'includes the time of the last authentication' do + it 'includes the time of the last authentication', :redis do expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index 33940f70b1c..d92daa345b3 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -9,8 +9,6 @@ describe 'cycle analytics events', api: true do before do project.team << [user, :developer] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) - 3.times do |count| Timecop.freeze(Time.now + count.days) do create_cycle @@ -121,9 +119,10 @@ describe 'cycle analytics events', api: true do def create_cycle milestone = create(:milestone, project: project) issue.update(milestone: milestone) - mr = create_merge_request_closing_issue(issue) + mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) + mr.update(head_pipeline_id: pipeline.id) pipeline.run create(:ci_build, pipeline: pipeline, status: :success, author: user) diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index 897a28b7305..b5eb84ae43b 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -6,7 +6,7 @@ describe BuildEntity do let(:request) { double('request') } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end let(:entity) do diff --git a/spec/serializers/build_serializer_spec.rb b/spec/serializers/build_serializer_spec.rb index 7f1abecfafe..01e2cfed6f8 100644 --- a/spec/serializers/build_serializer_spec.rb +++ b/spec/serializers/build_serializer_spec.rb @@ -4,7 +4,7 @@ describe BuildSerializer do let(:user) { create(:user) } let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) end subject { serializer.represent(resource) } diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 69355bcde42..522c92ce295 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -8,7 +8,7 @@ describe DeploymentEntity do subject { entity.as_json } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end it 'exposes internal deployment id' do diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 1909e6385b5..d2ad6c44702 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -6,7 +6,7 @@ describe EnvironmentSerializer do let(:json) do described_class - .new(user: user, project: project) + .new(current_user: user, project: project) .represent(resource) end diff --git a/spec/serializers/event_entity_spec.rb b/spec/serializers/event_entity_spec.rb new file mode 100644 index 00000000000..bb54597c967 --- /dev/null +++ b/spec/serializers/event_entity_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe EventEntity do + subject { described_class.represent(create(:event)).as_json } + + it 'exposes author' do + expect(subject).to include(:author) + end + + it 'exposes core elements of event' do + expect(subject).to include(:updated_at) + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb new file mode 100644 index 00000000000..4daf5a59d0c --- /dev/null +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe MergeRequestBasicSerializer do + let(:resource) { create(:merge_request) } + let(:user) { create(:user) } + + subject { described_class.new.represent(resource) } + + it 'has important MergeRequest attributes' do + expect(subject).to include(:merge_status) + end +end diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb new file mode 100644 index 00000000000..bb6e83ae4bd --- /dev/null +++ b/spec/serializers/merge_request_entity_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +describe MergeRequestEntity do + let(:project) { create :empty_project } + let(:resource) { create(:merge_request, source_project: project, target_project: project) } + let(:user) { create(:user) } + + let(:request) { double('request', current_user: user) } + + subject do + described_class.new(resource, request: request).as_json + end + + it 'includes author' do + req = double('request') + + author_payload = UserEntity + .represent(resource.author, request: req) + .as_json + + expect(subject[:author]).to eq(author_payload) + end + + it 'includes pipeline' do + req = double('request', current_user: user) + pipeline = build_stubbed(:ci_pipeline) + allow(resource).to receive(:head_pipeline).and_return(pipeline) + + pipeline_payload = PipelineEntity + .represent(pipeline, request: req) + .as_json + + expect(subject[:pipeline]).to eq(pipeline_payload) + end + + it 'includes issues_links' do + issues_links = subject[:issues_links] + + expect(issues_links).to include(:closing, :mentioned_but_not_closing, + :assign_to_closing) + end + + it 'has important MergeRequest attributes' do + expect(subject).to include(:diff_head_sha, :merge_commit_message, + :has_conflicts, :has_ci, :merge_path, + :conflict_resolution_path, + :cancel_merge_when_pipeline_succeeds_path, + :create_issue_to_resolve_discussions_path, + :source_branch_path, :target_branch_commits_path, + :commits_count) + end + + it 'has email_patches_path' do + expect(subject[:email_patches_path]) + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.patch") + end + + it 'has plain_diff_path' do + expect(subject[:plain_diff_path]) + .to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff") + end + + it 'has merge_commit_message_with_description' do + expect(subject[:merge_commit_message_with_description]) + .to eq(resource.merge_commit_message(include_description: true)) + end + + describe 'diff_head_sha' do + before do + allow(resource).to receive(:diff_head_sha) { 'sha' } + end + + context 'when no diff head commit' do + it 'returns nil' do + allow(resource).to receive(:diff_head_commit) { nil } + + expect(subject[:diff_head_sha]).to be_nil + end + end + + context 'when diff head commit present' do + it 'returns diff head commit short id' do + allow(resource).to receive(:diff_head_commit) { double } + + expect(subject[:diff_head_sha]).to eq('sha') + end + end + end + + it 'includes merge_event' do + create(:event, :merged, author: user, project: resource.project, target: resource) + + expect(subject[:merge_event]).to include(:author, :updated_at) + end + + it 'includes closed_event' do + create(:event, :closed, author: user, project: resource.project, target: resource) + + expect(subject[:closed_event]).to include(:author, :updated_at) + end + + describe 'diverged_commits_count' do + context 'when MR open and its diverging' do + it 'returns diverged commits count' do + allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: true, + diverged_commits_count: 10) + + expect(subject[:diverged_commits_count]).to eq(10) + end + end + + context 'when MR is not open' do + it 'returns 0' do + allow(resource).to receive_messages(open?: false) + + expect(subject[:diverged_commits_count]).to be_zero + end + end + + context 'when MR is not diverging' do + it 'returns 0' do + allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: false) + + expect(subject[:diverged_commits_count]).to be_zero + end + end + end +end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb new file mode 100644 index 00000000000..73fbecc153d --- /dev/null +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe MergeRequestSerializer do + let(:user) { build_stubbed(:user) } + let(:merge_request) { build_stubbed(:merge_request) } + + let(:serializer) do + described_class.new(current_user: user) + end + + describe '#represent' do + let(:opts) { { basic: basic } } + subject { serializer.represent(merge_request, basic: basic) } + + context 'when basic param is truthy' do + let(:basic) { true } + + it 'calls super class #represent with correct params' do + expect_any_instance_of(BaseSerializer).to receive(:represent) + .with(merge_request, opts, MergeRequestBasicEntity) + + subject + end + end + + context 'when basic param is falsy' do + let(:basic) { false } + + it 'calls super class #represent with correct params' do + expect_any_instance_of(BaseSerializer).to receive(:represent) + .with(merge_request, opts, MergeRequestEntity) + + subject + end + end + end +end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index 93d5a21419d..d2482ac434b 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -5,7 +5,7 @@ describe PipelineEntity do let(:request) { double('request') } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end let(:entity) do @@ -19,7 +19,7 @@ describe PipelineEntity do let(:pipeline) { create(:ci_empty_pipeline) } it 'contains required fields' do - expect(subject).to include :id, :user, :path + expect(subject).to include :id, :user, :path, :coverage expect(subject).to include :ref, :commit expect(subject).to include :updated_at, :created_at end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index ecde45a6d44..f2426db6d81 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -4,7 +4,7 @@ describe PipelineSerializer do let(:user) { create(:user) } let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) end subject { serializer.represent(resource) } @@ -44,7 +44,7 @@ describe PipelineSerializer do end let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) .with_pagination(request, response) end @@ -113,7 +113,7 @@ describe PipelineSerializer do it "verifies number of queries" do recorded = ActiveRecord::QueryRecorder.new { subject } - expect(recorded.count).to be_within(1).of(50) + expect(recorded.count).to be_within(1).of(58) expect(recorded.cached_count).to eq(0) end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 0412b2d7741..64b3217b809 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -14,7 +14,7 @@ describe StageEntity do end before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) create(:ci_build, :success, pipeline: pipeline) end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index fa5014cee07..1ff1438ba06 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -34,6 +34,42 @@ describe Ci::CreatePipelineService, services: true do it { expect(pipeline).to have_attributes(status: 'pending') } it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } + context '#update_merge_requests_head_pipeline' do + it 'updates head pipeline of each merge request' do + merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + merge_request_2 = create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project) + + head_pipeline = pipeline + + expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline) + expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline) + end + + context 'when there is no pipeline for source branch' do + it "does not update merge request head pipeline" do + merge_request = create(:merge_request, source_branch: 'other_branch', target_branch: "branch_1", source_project: project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).not_to eq(head_pipeline) + end + end + + context 'when merge request target project is different from source project' do + let!(:target_project) { create(:empty_project) } + let!(:forked_project_link) { create(:forked_project_link, forked_to_project: project, forked_from_project: target_project) } + + it 'updates head pipeline for merge request' do + merge_request = + create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project, target_project: target_project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).to eq(head_pipeline) + end + end + end + context 'auto-cancel enabled' do before do project.update(auto_cancel_pending_pipelines: 'enabled') diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index cf773866a6f..1d0a28210fb 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -268,6 +268,24 @@ describe Ci::ProcessPipelineService, '#execute', :services do end end + context 'when there are only manual actions in stages' do + before do + create_build('image', stage_idx: 0, when: 'manual', allow_failure: true) + create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) + create_build('deploy', stage_idx: 2, when: 'manual') + create_build('check', stage_idx: 3) + + process_pipeline + end + + it 'processes all jobs until blocking actions encountered' do + expect(all_builds_statuses).to eq(%w[manual manual manual created]) + expect(all_builds_names).to eq(%w[image build deploy check]) + + expect(pipeline.reload).to be_blocked + end + end + context 'when blocking manual actions are defined' do before do create_build('code:test', stage_idx: 0) @@ -441,6 +459,10 @@ describe Ci::ProcessPipelineService, '#execute', :services do builds.pluck(:name) end + def all_builds_names + all_builds.pluck(:name) + end + def builds_statuses builds.pluck(:status) end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 0477cac6677..ab06f45dbb9 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -584,7 +584,7 @@ describe GitPushService, services: true do commit = double(:commit) diff = double(:diff, new_path: 'README.md') - expect(commit).to receive(:raw_diffs).with(deltas_only: true). + expect(commit).to receive(:raw_deltas). and_return([diff]) service.push_commits = [commit] @@ -622,12 +622,21 @@ describe GitPushService, services: true do it 'only schedules a limited number of commits' do allow(service).to receive(:push_commits). - and_return(Array.new(1000, double(:commit, to_hash: {}))) + and_return(Array.new(1000, double(:commit, to_hash: {}, matches_cross_reference_regex?: true))) expect(ProcessCommitWorker).to receive(:perform_async).exactly(100).times service.process_commit_messages end + + it "skips commits which don't include cross-references" do + allow(service).to receive(:push_commits). + and_return([double(:commit, to_hash: {}, matches_cross_reference_regex?: false)]) + + expect(ProcessCommitWorker).not_to receive(:perform_async) + + service.process_commit_messages + end end def execute_service(project, user, oldrev, newrev, ref) diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb index 769b3193275..3ef5135e6a3 100644 --- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb @@ -82,6 +82,10 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do sha: merge_request_head, status: 'success') end + before do + mr_merge_if_green_enabled.update(head_pipeline: triggering_pipeline) + end + it "merges all merge requests with merge when the pipeline succeeds enabled" do expect(MergeWorker).to receive(:perform_async) service.trigger(triggering_pipeline) @@ -124,6 +128,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do sha: mr_conflict.diff_head_sha, status: 'success') end + before { mr_conflict.update(head_pipeline: conflict_pipeline) } + it 'does not merge the merge request' do expect(MergeWorker).not_to receive(:perform_async) diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 31487c0f794..07f5440cc36 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -174,11 +174,13 @@ describe MergeRequests::UpdateService, services: true do context 'with active pipeline' do before do service_mock = double - create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline_with_one_job, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha) + merge_request.update(head_pipeline: pipeline) + expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user). and_return(service_mock) expect(service_mock).to receive(:execute).with(merge_request) diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index 063b3bd76eb..0657b7e93fe 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -6,7 +6,6 @@ describe Projects::ParticipantsService, services: true do let(:project) { create(:empty_project, :public) } let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) } let(:user) { create(:user) } - let(:base_url) { Settings.send(:build_base_gitlab_url) } let!(:group_member) { create(:group_member, group: group, user: user) } it 'should return an url for the avatar' do @@ -14,7 +13,7 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 - expect(groups.first[:avatar_url]).to eq "#{base_url}/uploads/group/avatar/#{group.id}/dk.png" + expect(groups.first[:avatar_url]).to eq("/uploads/group/avatar/#{group.id}/dk.png") end it 'should return an url for the avatar with relative url' do @@ -25,7 +24,7 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 - expect(groups.first[:avatar_url]).to eq "#{base_url}/gitlab/uploads/group/avatar/#{group.id}/dk.png" + expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/group/avatar/#{group.id}/dk.png") end end end diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 8ad042f5e3b..66545127a44 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -20,7 +20,7 @@ module CycleAnalyticsHelpers ref: 'refs/heads/master').execute end - def create_merge_request_closing_issue(issue, message: nil, source_branch: nil) + def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message') if !source_branch || project.repository.commit(source_branch).blank? source_branch = generate(:branch) project.repository.add_branch(user, source_branch, 'master') @@ -30,7 +30,7 @@ module CycleAnalyticsHelpers user, generate(:branch), 'content', - message: 'commit message', + message: commit_message, branch_name: source_branch) project.repository.commit(sha) diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index a204365431b..51987c7767d 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -7,17 +7,29 @@ module PrometheusHelpers %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} end + def prometheus_ping_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + def prometheus_query_url(prometheus_query) query = { query: prometheus_query }.to_query "https://prometheus.example.com/api/v1/query?#{query}" end - def prometheus_query_range_url(prometheus_query, start: 8.hours.ago) + def prometheus_query_with_time_url(prometheus_query, time) + query = { query: prometheus_query, time: time.to_f }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + + def prometheus_query_range_url(prometheus_query, start: 8.hours.ago, stop: Time.now.to_f) query = { query: prometheus_query, start: start.to_f, - end: Time.now.utc.to_f, + end: stop, step: 1.minute.to_i }.to_query @@ -39,7 +51,12 @@ module PrometheusHelpers def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) stub_prometheus_request( - prometheus_query_url(prometheus_memory_query(environment_slug)), + prometheus_query_with_time_url(prometheus_memory_query(environment_slug), Time.now.utc), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_with_time_url(prometheus_memory_query(environment_slug), 8.hours.ago), status: status, body: body || prometheus_value_body ) @@ -49,7 +66,12 @@ module PrometheusHelpers body: body || prometheus_values_body ) stub_prometheus_request( - prometheus_query_url(prometheus_cpu_query(environment_slug)), + prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), Time.now.utc), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_with_time_url(prometheus_cpu_query(environment_slug), 8.hours.ago), status: status, body: body || prometheus_value_body ) @@ -66,8 +88,10 @@ module PrometheusHelpers metrics: { memory_values: prometheus_values_body('matrix').dig(:data, :result), memory_current: prometheus_value_body('vector').dig(:data, :result), + memory_previous: prometheus_value_body('vector').dig(:data, :result), cpu_values: prometheus_values_body('matrix').dig(:data, :result), - cpu_current: prometheus_value_body('vector').dig(:data, :result) + cpu_current: prometheus_value_body('vector').dig(:data, :result), + cpu_previous: prometheus_value_body('vector').dig(:data, :result) }, last_update: last_update } diff --git a/spec/support/slack_mattermost_notifications_shared_examples.rb b/spec/support/slack_mattermost_notifications_shared_examples.rb index b902fe90707..7e35ebb6c97 100644 --- a/spec/support/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/slack_mattermost_notifications_shared_examples.rb @@ -328,7 +328,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do context 'only notify for the default branch' do context 'when enabled' do let(:pipeline) do - create(:ci_pipeline, project: project, status: 'failed', ref: 'not-the-default-branch') + create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch') end before do @@ -342,6 +342,18 @@ RSpec.shared_examples 'slack or mattermost notifications' do expect(result).to be_falsy end end + + context 'when disabled' do + let(:pipeline) do + create(:ci_pipeline, :failed, project: project, ref: 'not-the-default-branch') + end + + before do + chat_service.notify_only_default_branch = false + end + + it_behaves_like 'call Slack/Mattermost API' + end end end end diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb index 73da23391ee..a18c8e03aa6 100644 --- a/spec/support/wait_for_requests.rb +++ b/spec/support/wait_for_requests.rb @@ -1,20 +1,26 @@ require_relative './wait_for_ajax' +require_relative './wait_for_vue_resource' module WaitForRequests extend self include WaitForAjax + include WaitForVueResource # This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests def wait_for_requests_complete Gitlab::Testing::RequestBlockerMiddleware.block_requests! wait_for('pending AJAX requests complete') do Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? && - finished_all_ajax_requests? + finished_all_requests? end ensure Gitlab::Testing::RequestBlockerMiddleware.allow_requests! end + def finished_all_requests? + finished_all_ajax_requests? && finished_all_vue_resource_requests? + end + # Waits until the passed block returns true def wait_for(condition_name, max_wait_time: Capybara.default_max_wait_time, polling_interval: 0.01) wait_until = Time.now + max_wait_time.seconds diff --git a/spec/support/wait_for_vue_resource.rb b/spec/support/wait_for_vue_resource.rb index 4a4e2e16ee7..3bb3d9c2e51 100644 --- a/spec/support/wait_for_vue_resource.rb +++ b/spec/support/wait_for_vue_resource.rb @@ -1,7 +1,19 @@ module WaitForVueResource def wait_for_vue_resource(spinner: true) Timeout.timeout(Capybara.default_max_wait_time) do - loop until page.evaluate_script('window.activeVueResources').zero? + loop until finished_all_vue_resource_requests? end end + + private + + def finished_all_vue_resource_requests? + return true unless javascript_test? + + page.evaluate_script('window.activeVueResources || 0').zero? + end + + def javascript_test? + Capybara.current_driver == Capybara.javascript_driver + end end diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb index 5dbc0da95c2..ef71125c0b6 100644 --- a/spec/workers/pipeline_metrics_worker_spec.rb +++ b/spec/workers/pipeline_metrics_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe PipelineMetricsWorker do let(:project) { create(:project, :repository) } - let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref) } + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref, head_pipeline: pipeline) } let(:pipeline) do create(:ci_empty_pipeline, diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index 9afe2e610b9..6295856b461 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -20,6 +20,14 @@ describe ProcessCommitWorker do worker.perform(project.id, -1, commit.to_hash) end + it 'does not process the commit when no issues are referenced' do + allow(worker).to receive(:build_commit).and_return(double(matches_cross_reference_regex?: false)) + + expect(worker).not_to receive(:process_commit_message) + + worker.perform(project.id, user.id, commit.to_hash) + end + it 'processes the commit message' do expect(worker).to receive(:process_commit_message).and_call_original diff --git a/vendor/Dockerfile/OpenJDK-alpine.Dockerfile b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile new file mode 100644 index 00000000000..ee853d9cfd2 --- /dev/null +++ b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8-alpine + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/OpenJDK.Dockerfile b/vendor/Dockerfile/OpenJDK.Dockerfile new file mode 100644 index 00000000000..8a2ae62d93b --- /dev/null +++ b/vendor/Dockerfile/OpenJDK.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:9 + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/Python-alpine.Dockerfile b/vendor/Dockerfile/Python-alpine.Dockerfile new file mode 100644 index 00000000000..59ac9f504de --- /dev/null +++ b/vendor/Dockerfile/Python-alpine.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.6-alpine + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apk --no-cache add postgresql-client + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/Dockerfile/Python.Dockerfile b/vendor/Dockerfile/Python.Dockerfile new file mode 100644 index 00000000000..7c43ad99060 --- /dev/null +++ b/vendor/Dockerfile/Python.Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.6 + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/gitignore/Global/Archives.gitignore b/vendor/gitignore/Global/Archives.gitignore index e9eda68baf2..f440b808d98 100644 --- a/vendor/gitignore/Global/Archives.gitignore +++ b/vendor/gitignore/Global/Archives.gitignore @@ -5,6 +5,7 @@ *.rar *.zip *.gz +*.tgz *.bzip *.bz2 *.xz diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore index a5d4cc86d33..ff23445e2b0 100644 --- a/vendor/gitignore/Global/JetBrains.gitignore +++ b/vendor/gitignore/Global/JetBrains.gitignore @@ -19,6 +19,9 @@ .idea/**/gradle.xml .idea/**/libraries +# CMake +cmake-build-debug/ + # Mongo Explorer plugin: .idea/**/mongoSettings.xml diff --git a/vendor/gitignore/Global/MicrosoftOffice.gitignore b/vendor/gitignore/Global/MicrosoftOffice.gitignore index cb891745660..0c203662d39 100644 --- a/vendor/gitignore/Global/MicrosoftOffice.gitignore +++ b/vendor/gitignore/Global/MicrosoftOffice.gitignore @@ -13,4 +13,4 @@ ~$*.ppt* # Visio autosave temporary files -*.~vsdx +*.~vsd* diff --git a/vendor/gitignore/Magento.gitignore b/vendor/gitignore/Magento.gitignore index b282f5cf547..6f1fa223992 100644 --- a/vendor/gitignore/Magento.gitignore +++ b/vendor/gitignore/Magento.gitignore @@ -3,14 +3,41 @@ #--------------------------# /app/etc/local.xml + /media/* !/media/.htaccess + +!/media/customer +/media/customer/* !/media/customer/.htaccess + +!/media/dhl +/media/dhl/* !/media/dhl/logo.jpg + +!/media/downloadable +/media/downloadable/* !/media/downloadable/.htaccess + +!/media/xmlconnect +/media/xmlconnect/* + +!/media/xmlconnect/custom +/media/xmlconnect/custom/* !/media/xmlconnect/custom/ok.gif + +!/media/xmlconnect/original +/media/xmlconnect/original/* !/media/xmlconnect/original/ok.gif + +!/media/xmlconnect/system +/media/xmlconnect/system/* !/media/xmlconnect/system/ok.gif + /var/* !/var/.htaccess + +!/var/package +/var/package/* !/var/package/*.xml + diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore index ff65a437185..768d5f400bb 100644 --- a/vendor/gitignore/Python.gitignore +++ b/vendor/gitignore/Python.gitignore @@ -89,9 +89,13 @@ ENV/ # Spyder project settings .spyderproject +.spyproject # Rope project settings .ropeproject # mkdocs documentation /site + +# mypy +.mypy_cache/ diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore index c7659c24f38..6732e72091c 100644 --- a/vendor/gitignore/Qt.gitignore +++ b/vendor/gitignore/Qt.gitignore @@ -20,6 +20,7 @@ *.qbs.user.* *.moc moc_*.cpp +moc_*.h qrc_*.cpp ui_*.h Makefile* diff --git a/vendor/gitignore/UnrealEngine.gitignore b/vendor/gitignore/UnrealEngine.gitignore index 2f096001fec..6c6e1c327fd 100644 --- a/vendor/gitignore/UnrealEngine.gitignore +++ b/vendor/gitignore/UnrealEngine.gitignore @@ -54,6 +54,11 @@ Binaries/* # Builds Build/* +# Whitelist PakBlacklist-<BuildConfiguration>.txt files +!Build/*/ +Build/*/** +!Build/*/PakBlacklist*.txt + # Don't ignore icon files in Build !Build/**/*.ico diff --git a/vendor/licenses.csv b/vendor/licenses.csv index 6441df25fe1..a8e7f5e3ea9 100644 --- a/vendor/licenses.csv +++ b/vendor/licenses.csv @@ -3,7 +3,7 @@ abbrev,1.0.9,ISC accepts,1.3.3,MIT ace-rails-ap,4.1.2,MIT acorn,4.0.11,MIT -acorn-dynamic-import,2.0.2,MIT +acorn-dynamic-import,2.0.1,MIT acorn-jsx,3.0.1,MIT actionmailer,4.2.8,MIT actionpack,4.2.8,MIT @@ -16,7 +16,7 @@ acts-as-taggable-on,4.0.0,MIT addressable,2.3.8,Apache 2.0 after,0.8.2,MIT after_commit_queue,1.3.0,MIT -ajv,4.11.5,MIT +ajv,4.11.2,MIT ajv-keywords,1.5.1,MIT akismet,2.0.0,MIT align-text,0.1.4,MIT @@ -29,7 +29,7 @@ ansi-regex,2.1.1,MIT ansi-styles,2.2.1,MIT anymatch,1.3.0,ISC append-transform,0.4.0,MIT -aproba,1.1.1,ISC +aproba,1.1.0,ISC are-we-there-yet,1.1.2,ISC arel,6.0.4,MIT argparse,1.0.9,MIT @@ -43,7 +43,7 @@ array-uniq,1.0.3,MIT array-unique,0.2.1,MIT arraybuffer.slice,0.0.6,MIT arrify,1.0.1,MIT -asana,0.4.0,MIT +asana,0.6.0,MIT asciidoctor,1.5.3,MIT asciidoctor-plantuml,0.0.7,MIT asn1,0.2.3,MIT @@ -62,8 +62,8 @@ aws-sign2,0.6.0,Apache 2.0 aws4,1.6.0,MIT axiom-types,0.1.1,MIT babel-code-frame,6.22.0,MIT -babel-core,6.24.0,MIT -babel-generator,6.24.0,MIT +babel-core,6.23.1,MIT +babel-generator,6.23.0,MIT babel-helper-bindify-decorators,6.22.0,MIT babel-helper-builder-binary-assignment-operator-visitor,6.22.0,MIT babel-helper-call-delegate,6.22.0,MIT @@ -78,10 +78,10 @@ babel-helper-regex,6.22.0,MIT babel-helper-remap-async-to-generator,6.22.0,MIT babel-helper-replace-supers,6.23.0,MIT babel-helpers,6.23.0,MIT -babel-loader,6.4.1,MIT +babel-loader,6.2.10,MIT babel-messages,6.23.0,MIT babel-plugin-check-es2015-constants,6.22.0,MIT -babel-plugin-istanbul,4.1.1,New BSD +babel-plugin-istanbul,4.0.0,New BSD babel-plugin-syntax-async-functions,6.13.0,MIT babel-plugin-syntax-async-generators,6.13.0,MIT babel-plugin-syntax-class-properties,6.13.0,MIT @@ -127,13 +127,13 @@ babel-preset-es2017,6.22.0,MIT babel-preset-latest,6.24.0,MIT babel-preset-stage-2,6.22.0,MIT babel-preset-stage-3,6.22.0,MIT -babel-register,6.24.0,MIT -babel-runtime,6.23.0,MIT +babel-register,6.23.0,MIT +babel-runtime,6.22.0,MIT babel-template,6.23.0,MIT babel-traverse,6.23.1,MIT babel-types,6.23.0,MIT babosa,1.0.2,MIT -babylon,6.16.1,MIT +babylon,6.15.0,MIT backo2,1.0.2,MIT balanced-match,0.4.2,MIT base32,0.3.2,MIT @@ -149,20 +149,20 @@ binary-extensions,1.8.0,MIT bindata,2.3.5,ruby blob,0.0.4,unknown block-stream,0.0.9,ISC -bluebird,3.5.0,MIT +bluebird,3.4.7,MIT bn.js,4.11.6,MIT -body-parser,1.17.1,MIT +body-parser,1.16.0,MIT boom,2.10.1,New BSD bootstrap-sass,3.3.6,MIT brace-expansion,1.1.6,MIT braces,1.8.5,MIT -brorand,1.1.0,MIT +brorand,1.0.7,MIT browser,2.2.0,MIT browserify-aes,1.0.6,MIT browserify-cipher,1.0.0,MIT browserify-des,1.0.0,MIT browserify-rsa,4.0.1,MIT -browserify-sign,4.0.4,ISC +browserify-sign,4.0.0,ISC browserify-zlib,0.1.4,MIT browserslist,1.7.7,MIT buffer,4.9.1,MIT @@ -178,8 +178,8 @@ callsites,0.2.0,MIT camelcase,1.2.1,MIT caniuse-api,1.6.1,MIT caniuse-db,1.0.30000649,CC-BY-4.0 -carrierwave,0.11.2,MIT -caseless,0.12.0,Apache 2.0 +carrierwave,1.0.0,MIT +caseless,0.11.0,Apache 2.0 cause,0.1,MIT center-align,0.1.3,MIT chalk,1.1.3,MIT @@ -194,6 +194,7 @@ citrus,3.0.2,MIT clap,1.1.3,MIT cli-cursor,1.0.2,MIT cli-width,2.1.0,ISC +clipboard,1.6.1,MIT cliui,2.1.0,ISC clone,1.0.2,MIT co,4.6.0,MIT @@ -216,14 +217,14 @@ commondir,1.0.1,MIT component-bind,1.0.0,unknown component-emitter,1.2.1,MIT component-inherit,0.0.3,unknown -compressible,2.0.10,MIT +compressible,2.0.9,MIT compression,1.6.2,MIT compression-webpack-plugin,0.3.2,MIT concat-map,0.0.1,MIT concat-stream,1.6.0,MIT config-chain,1.1.11,MIT configstore,1.4.0,Simplified BSD -connect,3.6.0,MIT +connect,3.5.0,MIT connect-history-api-fallback,1.3.0,MIT connection_pool,2.2.1,MIT console-browserify,1.1.0,MIT @@ -233,7 +234,7 @@ constants-browserify,1.0.0,MIT contains-path,0.1.0,MIT content-disposition,0.5.2,MIT content-type,1.0.2,MIT -convert-source-map,1.5.0,MIT +convert-source-map,1.3.0,MIT cookie,0.3.1,MIT cookie-signature,1.0.6,MIT core-js,2.4.1,MIT @@ -254,13 +255,13 @@ cssesc,0.1.0,MIT cssnano,3.10.0,MIT csso,2.3.2,MIT custom-event,1.0.1,MIT -d,1.0.0,MIT -d3,3.5.17,New BSD +d,0.1.1,MIT +d3,3.5.11,New BSD d3_rails,3.5.11,MIT dashdash,1.14.1,MIT date-now,0.1.4,MIT de-indent,1.0.2,MIT -debug,2.6.3,MIT +debug,2.6.0,MIT decamelize,1.2.0,MIT deckar01-task_list,1.0.6,MIT deep-extend,0.4.1,MIT @@ -271,6 +272,7 @@ defaults,1.0.3,MIT defined,1.0.0,MIT del,2.2.2,MIT delayed-stream,1.0.0,MIT +delegate,3.1.2,MIT delegates,1.0.0,MIT depd,1.1.0,MIT des.js,1.0.0,MIT @@ -283,8 +285,8 @@ di,0.0.1,MIT diff-lcs,1.2.5,"MIT,Perl Artistic v2,GNU GPL v2" diffie-hellman,5.0.2,MIT diffy,3.1.0,MIT -doctrine,2.0.0,Apache 2.0 -document-register-element,1.4.1,MIT +doctrine,1.5.0,BSD +document-register-element,1.3.0,MIT dom-serialize,2.2.1,MIT dom-serializer,0.1.0,MIT domain-browser,1.1.7,MIT @@ -294,7 +296,7 @@ domhandler,2.3.0,unknown domutils,1.5.1,unknown doorkeeper,4.2.0,MIT doorkeeper-openid_connect,1.1.2,MIT -dropzone,4.3.0,MIT +dropzone,4.2.0,MIT dropzonejs-rails,0.7.2,MIT duplexer,0.1.1,MIT duplexify,3.5.0,MIT @@ -303,36 +305,36 @@ editorconfig,0.13.2,MIT ee-first,1.1.1,MIT ejs,2.5.6,Apache 2.0 electron-to-chromium,1.3.3,ISC -elliptic,6.4.0,MIT +elliptic,6.3.3,MIT email_reply_trimmer,0.1.6,MIT emoji-unicode-version,0.2.1,MIT emojis-list,2.1.0,MIT encodeurl,1.0.1,MIT encryptor,3.0.0,MIT end-of-stream,1.0.0,MIT -engine.io,1.8.3,MIT -engine.io-client,1.8.3,MIT +engine.io,1.8.2,MIT +engine.io-client,1.8.2,MIT engine.io-parser,1.3.2,MIT enhanced-resolve,3.1.0,MIT ent,2.2.0,MIT entities,1.1.1,BSD-like equalizer,0.0.11,MIT errno,0.1.4,MIT -error-ex,1.3.1,MIT +error-ex,1.3.0,MIT erubis,2.7.0,MIT -es5-ext,0.10.15,MIT -es6-iterator,2.0.1,MIT -es6-map,0.1.5,MIT +es5-ext,0.10.12,MIT +es6-iterator,2.0.0,MIT +es6-map,0.1.4,MIT es6-promise,3.0.2,MIT -es6-set,0.1.5,MIT -es6-symbol,3.1.1,MIT -es6-weak-map,2.0.2,MIT +es6-set,0.1.4,MIT +es6-symbol,3.1.0,MIT +es6-weak-map,2.0.1,MIT escape-html,1.0.3,MIT escape-string-regexp,1.0.5,MIT escape_utils,1.1.1,MIT escodegen,1.8.1,Simplified BSD escope,3.6.0,Simplified BSD -eslint,3.19.0,MIT +eslint,3.15.0,MIT eslint-config-airbnb-base,10.0.1,MIT eslint-import-resolver-node,0.2.3,MIT eslint-import-resolver-webpack,0.8.1,MIT @@ -341,37 +343,39 @@ eslint-plugin-filenames,1.1.0,MIT eslint-plugin-html,2.0.1,ISC eslint-plugin-import,2.2.0,MIT eslint-plugin-jasmine,2.2.0,MIT -espree,3.4.1,Simplified BSD -esprima,2.7.3,Simplified BSD -esquery,1.0.0,BSD +eslint-plugin-promise,3.5.0,ISC +espree,3.4.0,Simplified BSD +esprima,3.1.3,Simplified BSD esrecurse,4.1.0,Simplified BSD estraverse,4.1.1,Simplified BSD esutils,2.0.2,BSD -etag,1.8.0,MIT +etag,1.7.0,MIT eve-raphael,0.5.0,Apache 2.0 -event-emitter,0.3.5,MIT +event-emitter,0.3.4,MIT event-stream,3.3.4,MIT eventemitter3,1.2.0,MIT events,1.1.1,MIT eventsource,0.1.6,MIT evp_bytestokey,1.0.0,MIT -excon,0.52.0,MIT +excon,0.55.0,MIT execjs,2.6.0,MIT exit-hook,1.1.1,MIT expand-braces,0.1.2,MIT expand-brackets,0.1.5,MIT expand-range,1.8.2,MIT -express,4.15.2,MIT +exports-loader,0.6.4,MIT +express,4.14.1,MIT expression_parser,0.9.0,MIT extend,3.0.0,MIT extglob,0.3.2,MIT extlib,0.9.16,MIT extract-zip,1.5.0,Simplified BSD extsprintf,1.0.2,MIT -faraday,0.9.2,MIT -faraday_middleware,0.10.0,MIT +faraday,0.11.0,MIT +faraday_middleware,0.11.0.1,MIT faraday_middleware-multi_json,0.0.6,MIT fast-levenshtein,2.0.6,MIT +fast_gettext,1.4.0,"MIT,ruby" fastparse,1.1.1,MIT faye-websocket,0.7.3,MIT fd-slicer,1.0.1,MIT @@ -383,37 +387,37 @@ filename-regex,2.0.0,MIT fileset,2.0.3,MIT filesize,3.3.0,New BSD fill-range,2.2.3,MIT -finalhandler,1.0.1,MIT +finalhandler,0.5.1,MIT find-cache-dir,0.1.1,MIT find-root,0.1.2,MIT find-up,2.1.0,MIT flat-cache,1.2.2,MIT flatten,1.0.2,MIT flowdock,0.7.1,MIT -fog-aws,0.11.0,MIT -fog-core,1.42.0,MIT +fog-aws,0.13.0,MIT +fog-core,1.44.1,MIT fog-google,0.5.0,MIT fog-json,1.0.2,MIT fog-local,0.3.0,MIT fog-openstack,0.1.6,MIT fog-rackspace,0.1.1,MIT -fog-xml,0.1.2,MIT +fog-xml,0.1.3,MIT font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License" -for-in,1.0.2,MIT -for-own,0.1.5,MIT +for-in,0.1.6,MIT +for-own,0.1.4,MIT forever-agent,0.6.1,Apache 2.0 form-data,2.1.2,MIT formatador,0.2.5,MIT forwarded,0.1.0,MIT -fresh,0.5.0,MIT +fresh,0.3.0,MIT from,0.1.7,MIT fs-extra,1.0.0,MIT fs.realpath,1.0.0,ISC fsevents,,unknown -fstream,1.0.11,ISC +fstream,1.0.10,ISC fstream-ignore,1.0.5,ISC function-bind,1.1.0,MIT -gauge,2.7.3,ISC +gauge,2.7.2,ISC gemnasium-gitlab-service,0.2.6,MIT gemojione,3.0.1,MIT generate-function,2.0.0,MIT @@ -421,7 +425,9 @@ generate-object-property,1.2.0,MIT get-caller-file,1.0.2,ISC get_process_mem,0.2.0,MIT getpass,0.1.6,MIT -gitaly,0.5.0,MIT +gettext_i18n_rails,1.8.0,MIT +gettext_i18n_rails_js,1.2.0,MIT +gitaly,0.6.0,MIT github-linguist,4.7.6,MIT github-markup,1.4.0,MIT gitlab-flowdock-git-hook,1.0.1,MIT @@ -432,12 +438,13 @@ glob,7.1.1,ISC glob-base,0.3.0,MIT glob-parent,2.0.0,ISC globalid,0.3.7,MIT -globals,9.17.0,MIT +globals,9.14.0,MIT globby,5.0.0,MIT gollum-grit_adapter,1.0.1,MIT gollum-lib,4.2.1,MIT gollum-rugged_adapter,0.4.4,MIT gon,6.1.0,MIT +good-listener,1.2.2,MIT google-api-client,0.8.7,Apache 2.0 google-protobuf,3.2.0.2,New BSD googleauth,0.5.1,Apache 2.0 @@ -446,13 +453,12 @@ graceful-fs,4.1.11,ISC graceful-readlink,1.0.1,MIT grape,0.19.1,MIT grape-entity,0.6.0,MIT -grpc,1.1.2,New BSD +grpc,1.2.5,New BSD gzip-size,3.0.0,MIT hamlit,2.6.1,MIT handle-thing,1.2.5,MIT handlebars,4.0.6,MIT -har-schema,1.0.5,ISC -har-validator,4.2.1,ISC +har-validator,2.0.6,ISC has,1.0.1,MIT has-ansi,2.0.0,MIT has-binary,0.1.7,MIT @@ -463,14 +469,14 @@ hash-sum,1.0.2,MIT hash.js,1.0.3,MIT hasha,2.2.0,MIT hashie,3.5.5,MIT +hashie-forbidden_attributes,0.1.1,MIT hawk,3.1.3,New BSD he,1.1.1,MIT health_check,2.6.0,MIT hipchat,1.5.2,MIT -hmac-drbg,1.0.0,MIT hoek,2.16.3,New BSD home-or-tmp,2.0.0,MIT -hosted-git-info,2.4.1,ISC +hosted-git-info,2.2.0,ISC hpack.js,2.1.6,MIT html-comment-regex,1.1.1,MIT html-entities,1.2.0,MIT @@ -481,7 +487,7 @@ htmlparser2,3.9.2,MIT http,0.9.8,MIT http-cookie,1.0.3,MIT http-deceiver,1.2.7,MIT -http-errors,1.6.1,MIT +http-errors,1.5.1,MIT http-form_data,1.0.1,MIT http-proxy,1.16.2,MIT http-proxy-middleware,0.17.4,MIT @@ -495,7 +501,7 @@ ice_nine,0.11.2,MIT iconv-lite,0.4.15,MIT icss-replace-symbols,1.0.2,ISC ieee754,1.1.8,New BSD -ignore,3.2.6,MIT +ignore,3.2.2,MIT ignore-by-default,1.0.1,ISC immediate,3.0.6,MIT imurmurhash,0.1.4,MIT @@ -507,16 +513,16 @@ influxdb,0.2.3,MIT inherits,2.0.3,ISC ini,1.3.4,ISC inquirer,0.12.0,MIT -interpret,1.0.2,MIT +interpret,1.0.1,MIT invariant,2.2.2,New BSD invert-kv,1.0.0,MIT -ipaddr.js,1.3.0,MIT +ipaddr.js,1.2.0,MIT ipaddress,0.8.3,MIT is-absolute,0.2.6,MIT is-absolute-url,2.1.0,MIT is-arrayish,0.2.1,MIT is-binary-path,1.0.1,MIT -is-buffer,1.1.5,MIT +is-buffer,1.1.4,MIT is-builtin-module,1.0.0,MIT is-dotfile,1.0.2,MIT is-equal-shallow,0.1.3,MIT @@ -525,7 +531,7 @@ is-extglob,1.0.0,MIT is-finite,1.0.2,MIT is-fullwidth-code-point,1.0.0,MIT is-glob,2.0.1,MIT -is-my-json-valid,2.16.0,MIT +is-my-json-valid,2.15.0,MIT is-npm,1.0.0,MIT is-number,2.1.0,MIT is-path-cwd,1.0.0,MIT @@ -546,31 +552,32 @@ is-utf8,0.2.1,MIT is-windows,0.2.0,MIT isarray,1.0.0,MIT isbinaryfile,3.0.2,MIT -isexe,2.0.0,ISC +isexe,1.1.2,ISC isobject,2.1.0,MIT isstream,0.1.2,MIT istanbul,0.4.5,New BSD -istanbul-api,1.1.7,New BSD -istanbul-lib-coverage,1.0.2,New BSD -istanbul-lib-hook,1.0.5,New BSD -istanbul-lib-instrument,1.7.0,New BSD -istanbul-lib-report,1.0.0,New BSD -istanbul-lib-source-maps,1.1.1,New BSD -istanbul-reports,1.0.2,New BSD +istanbul-api,1.1.1,New BSD +istanbul-lib-coverage,1.0.1,New BSD +istanbul-lib-hook,1.0.0,New BSD +istanbul-lib-instrument,1.4.2,New BSD +istanbul-lib-report,1.0.0-alpha.3,New BSD +istanbul-lib-source-maps,1.1.0,New BSD +istanbul-reports,1.0.1,New BSD jasmine-core,2.5.2,MIT jasmine-jquery,2.1.1,MIT +jed,1.1.1,MIT jira-ruby,1.1.2,MIT jodid25519,1.0.2,MIT -jquery,2.2.4,MIT +jquery,2.2.1,MIT jquery-atwho-rails,1.3.2,MIT jquery-rails,4.1.1,MIT -jquery-ujs,1.2.2,MIT +jquery-ujs,1.2.1,MIT js-base64,2.1.9,BSD js-beautify,1.6.12,MIT -js-cookie,2.1.4,MIT +js-cookie,2.1.3,MIT js-tokens,3.0.1,MIT js-yaml,3.7.0,MIT -jsbn,0.1.1,MIT +jsbn,0.1.0,BSD jsesc,1.3.0,MIT json,1.8.6,ruby json-jwt,1.7.1,MIT @@ -583,18 +590,18 @@ json5,0.5.1,MIT jsonfile,2.4.0,MIT jsonify,0.0.0,Public Domain jsonpointer,4.0.1,MIT -jsprim,1.4.0,MIT +jsprim,1.3.1,MIT jszip,3.1.3,(MIT OR GPL-3.0) jszip-utils,0.0.2,MIT or GPLv3 jwt,1.5.6,MIT kaminari,0.17.0,MIT -karma,1.6.0,MIT -karma-coverage-istanbul-reporter,0.2.3,MIT +karma,1.4.1,MIT +karma-coverage-istanbul-reporter,0.2.0,MIT karma-jasmine,1.1.0,MIT -karma-mocha-reporter,2.2.3,MIT -karma-phantomjs-launcher,1.0.4,MIT +karma-mocha-reporter,2.2.2,MIT +karma-phantomjs-launcher,1.0.2,MIT karma-sourcemap-loader,0.3.7,MIT -karma-webpack,2.0.3,MIT +karma-webpack,2.0.2,MIT kew,0.7.0,Apache 2.0 kgio,2.10.0,LGPL-2.1+ kind-of,3.1.0,MIT @@ -610,7 +617,8 @@ lie,3.1.1,MIT little-plugger,1.1.4,MIT load-json-file,1.1.0,MIT loader-runner,2.3.0,MIT -loader-utils,0.2.17,MIT +loader-utils,0.2.16,MIT +locale,2.1.2,"ruby,LGPLv3+" locate-path,2.0.0,MIT lodash,4.17.4,MIT lodash._baseassign,3.2.0,MIT @@ -638,16 +646,17 @@ lodash.snakecase,4.0.1,MIT lodash.uniq,4.5.0,MIT lodash.words,4.2.0,MIT log4js,0.6.38,Apache 2.0 -logging,2.1.0,MIT +logging,2.2.2,MIT longest,1.0.1,MIT loofah,2.0.3,MIT loose-envify,1.3.1,MIT lowercase-keys,1.0.0,MIT lru-cache,3.2.0,ISC macaddress,0.2.8,MIT -mail,2.6.4,MIT +mail,2.6.5,MIT mail_room,0.9.1,MIT map-stream,0.1.0,unknown +marked,0.3.6,MIT math-expression-evaluator,1.2.16,MIT media-typer,0.3.0,MIT memoist,0.15.0,MIT @@ -658,17 +667,16 @@ methods,1.1.2,MIT micromatch,2.3.11,MIT miller-rabin,4.0.0,MIT mime,1.3.4,MIT -mime-db,1.27.0,MIT +mime-db,1.26.0,MIT mime-types,2.99.3,"MIT,Artistic-2.0,GPL-2.0" mimemagic,0.3.0,MIT mini_portile2,2.1.0,MIT minimalistic-assert,1.0.0,ISC -minimalistic-crypto-utils,1.0.1,MIT minimatch,3.0.3,ISC minimist,0.0.8,MIT mkdirp,0.5.1,MIT -moment,2.18.1,MIT -mousetrap,1.6.1,Apache 2.0 +moment,2.17.1,MIT +mousetrap,1.4.6,Apache 2.0 mousetrap-rails,1.4.6,"MIT,Apache" ms,0.7.2,MIT multi_json,1.12.1,MIT @@ -684,14 +692,15 @@ nested-error-stacks,1.0.2,MIT net-ldap,0.12.1,MIT net-ssh,3.0.1,MIT netrc,0.11.0,MIT +node-ensure,0.0.0,MIT node-libs-browser,2.0.0,MIT -node-pre-gyp,0.6.34,New BSD +node-pre-gyp,0.6.33,New BSD node-zopfli,2.0.2,MIT nodemon,1.11.0,MIT nokogiri,1.6.8.1,MIT -nopt,4.0.1,ISC -normalize-package-data,2.3.6,Simplified BSD -normalize-path,2.1.1,MIT +nopt,3.0.6,ISC +normalize-package-data,2.3.5,Simplified BSD +normalize-path,2.0.1,MIT normalize-range,0.1.2,MIT normalize-url,1.9.1,MIT npmlog,4.0.2,ISC @@ -700,13 +709,13 @@ number-is-nan,1.0.1,MIT numerizer,0.1.1,MIT oauth,0.5.1,MIT oauth-sign,0.8.2,Apache 2.0 -oauth2,1.2.0,MIT +oauth2,1.3.1,MIT object-assign,4.1.1,MIT object-component,0.0.3,unknown object.omit,2.0.1,MIT obuf,1.1.1,MIT octokit,4.6.2,MIT -oj,2.17.4,MIT +oj,2.17.5,MIT omniauth,1.4.2,MIT omniauth-auth0,1.4.1,MIT omniauth-authentiq,0.3.0,MIT @@ -727,7 +736,7 @@ omniauth-twitter,1.2.1,MIT omniauth_crowd,2.2.3,MIT on-finished,2.3.0,MIT on-headers,1.0.1,MIT -once,1.4.0,ISC +once,1.3.3,ISC onetime,1.1.0,MIT opener,1.4.3,(WTFPL OR MIT) opn,4.0.2,MIT @@ -748,7 +757,7 @@ p-locate,2.0.0,MIT package-json,1.2.0,MIT pako,1.0.5,(MIT AND Zlib) paranoia,2.2.0,MIT -parse-asn1,5.1.0,ISC +parse-asn1,5.0.0,ISC parse-glob,3.0.4,MIT parse-json,2.2.0,MIT parsejson,0.0.3,MIT @@ -762,10 +771,10 @@ path-is-inside,1.0.2,(WTFPL OR MIT) path-parse,1.0.5,MIT path-to-regexp,0.1.7,MIT path-type,1.1.0,MIT -pause-stream,0.0.11,"Apache2,MIT" +pause-stream,0.0.11,"MIT,Apache2" pbkdf2,3.0.9,MIT +pdfjs-dist,1.8.252,Apache 2.0 pend,1.2.0,MIT -performance-now,0.2.0,MIT pg,0.18.4,"BSD,ruby,GPL" phantomjs-prebuilt,2.1.14,Apache 2.0 pify,2.3.0,MIT @@ -775,6 +784,7 @@ pinkie-promise,2.0.1,MIT pkg-dir,1.0.0,MIT pkg-up,1.0.0,MIT pluralize,1.2.1,MIT +po_to_json,1.0.1,MIT portfinder,1.0.13,MIT posix-spawn,0.3.11,"MIT,LGPL" postcss,5.2.16,MIT @@ -818,12 +828,13 @@ premailer,1.8.6,New BSD premailer-rails,1.9.2,MIT prepend-http,1.0.4,MIT preserve,0.2.0,MIT +prismjs,1.6.0,MIT private,0.1.7,MIT process,0.11.9,MIT process-nextick-args,1.0.7,MIT progress,1.1.8,MIT proto-list,1.2.4,ISC -proxy-addr,1.1.4,MIT +proxy-addr,1.1.3,MIT prr,0.0.0,MIT ps-tree,1.1.0,MIT pseudomap,1.0.2,ISC @@ -832,7 +843,7 @@ punycode,1.4.1,MIT pyu-ruby-sasl,0.0.3.3,MIT q,1.5.0,MIT qjobs,1.1.5,MIT -qs,6.4.0,New BSD +qs,6.2.0,New BSD query-string,4.3.2,MIT querystring,0.2.0,MIT querystring-es3,0.2.1,MIT @@ -857,15 +868,16 @@ randomatic,1.1.6,MIT randombytes,2.0.3,MIT range-parser,1.2.0,MIT raphael,2.2.7,MIT +raven-js,3.15.0,Simplified BSD raw-body,2.2.0,MIT raw-loader,0.5.1,MIT -rc,1.2.1,(BSD-2-Clause OR MIT OR Apache-2.0) +rc,1.1.6,(BSD-2-Clause OR MIT OR Apache-2.0) rdoc,4.2.2,ruby react-dev-utils,0.5.2,New BSD read-all-stream,3.1.0,MIT read-pkg,1.1.0,MIT read-pkg-up,1.0.1,MIT -readable-stream,2.0.6,MIT +readable-stream,2.2.2,MIT readdirp,2.1.0,MIT readline2,1.0.1,MIT recaptcha,3.0.0,MIT @@ -873,7 +885,7 @@ rechoir,0.6.2,MIT recursive-open-struct,1.0.0,MIT recursive-readdir,2.1.1,MIT redcarpet,3.4.0,MIT -redis,3.2.2,MIT +redis,3.3.3,MIT redis-actionpack,5.0.1,MIT redis-activesupport,5.0.1,MIT redis-namespace,1.5.2,MIT @@ -883,18 +895,17 @@ redis-store,1.2.0,MIT reduce-css-calc,1.3.0,MIT reduce-function-call,1.0.2,MIT regenerate,1.3.2,MIT -regenerator-runtime,0.10.3,MIT +regenerator-runtime,0.10.1,MIT regenerator-transform,0.9.8,BSD regex-cache,0.4.3,MIT regexpu-core,2.0.0,MIT registry-url,3.1.0,MIT regjsgen,0.2.0,MIT regjsparser,0.1.5,BSD -remove-trailing-separator,1.0.1,ISC repeat-element,1.1.2,MIT repeat-string,1.6.1,MIT repeating,2.0.1,MIT -request,2.81.0,Apache 2.0 +request,2.79.0,Apache 2.0 request-progress,2.0.1,MIT request_store,1.3.1,MIT require-directory,2.1.1,MIT @@ -902,14 +913,14 @@ require-from-string,1.2.1,MIT require-main-filename,1.0.1,ISC require-uncached,1.0.3,MIT requires-port,1.0.0,MIT -resolve,1.3.2,MIT +resolve,1.2.0,MIT resolve-from,1.0.1,MIT responders,2.3.0,MIT rest-client,2.0.0,MIT restore-cursor,1.0.1,MIT retriable,1.4.1,MIT right-align,0.1.3,MIT -rimraf,2.6.1,ISC +rimraf,2.5.4,ISC rinku,2.0.0,ISC ripemd160,1.0.1,New BSD rotp,2.1.2,MIT @@ -919,6 +930,7 @@ rqrcode-rails3,0.1.7,MIT ruby-fogbugz,0.2.1,MIT ruby-prof,0.16.2,Simplified BSD ruby-saml,1.4.1,MIT +ruby_parser,3.8.4,MIT rubyntlm,0.5.2,MIT rubypants,0.2.0,BSD rufus-scheduler,3.1.10,MIT @@ -934,23 +946,25 @@ sawyer,0.8.1,MIT sax,1.2.2,ISC securecompare,1.0.0,MIT seed-fu,2.3.6,MIT +select,1.1.2,MIT select-hose,2.0.0,MIT select2,3.5.2-browserify,unknown select2-rails,3.5.9.3,MIT semver,5.3.0,ISC semver-diff,2.1.0,MIT -send,0.15.1,MIT +send,0.14.2,MIT sentry-raven,2.4.0,Apache 2.0 serve-index,1.8.0,MIT -serve-static,1.12.1,MIT +serve-static,1.11.2,MIT set-blocking,2.0.0,ISC set-immediate-shim,1.0.1,MIT setimmediate,1.0.5,MIT -setprototypeof,1.0.3,ISC +setprototypeof,1.0.2,ISC settingslogic,2.0.9,MIT +sexp_processor,4.8.0,MIT sha.js,2.4.8,MIT -shelljs,0.7.7,New BSD -sidekiq,4.2.7,LGPL +shelljs,0.7.6,New BSD +sidekiq,5.0.0,LGPL sidekiq-cron,0.4.4,MIT sidekiq-limit_fetch,3.4.0,MIT sigmund,1.0.1,ISC @@ -961,16 +975,16 @@ slash,1.0.0,MIT slice-ansi,0.0.4,MIT slide,1.1.6,ISC sntp,1.0.9,BSD -socket.io,1.7.3,MIT +socket.io,1.7.2,MIT socket.io-adapter,0.5.0,MIT -socket.io-client,1.7.3,MIT +socket.io-client,1.7.2,MIT socket.io-parser,2.3.1,MIT sockjs,0.3.18,MIT sockjs-client,1.0.1,MIT sort-keys,1.1.2,MIT source-list-map,0.1.8,MIT source-map,0.5.6,New BSD -source-map-support,0.4.14,MIT +source-map-support,0.4.11,MIT spdx-correct,1.0.2,Apache 2.0 spdx-expression-parse,1.0.4,(MIT AND CC-BY-3.0) spdx-license-ids,1.2.2,Unlicense @@ -980,7 +994,8 @@ split,0.3.3,MIT sprintf-js,1.0.3,New BSD sprockets,3.7.1,MIT sprockets-rails,3.2.0,MIT -sshpk,1.11.0,MIT +sql.js,0.4.0,MIT +sshpk,1.10.2,MIT state_machines,0.4.0,MIT state_machines-activemodel,0.4.0,MIT state_machines-activerecord,0.4.0,MIT @@ -988,7 +1003,7 @@ stats-webpack-plugin,0.4.3,MIT statuses,1.3.1,MIT stream-browserify,2.0.1,MIT stream-combiner,0.0.4,MIT -stream-http,2.7.0,MIT +stream-http,2.6.3,MIT stream-shift,1.0.0,MIT strict-uri-encode,1.1.0,MIT string-length,1.0.1,MIT @@ -998,16 +1013,17 @@ stringex,2.5.2,MIT stringstream,0.0.5,MIT strip-ansi,3.0.1,MIT strip-bom,2.0.0,MIT -strip-json-comments,2.0.1,MIT -supports-color,3.2.3,MIT +strip-json-comments,1.0.4,MIT +supports-color,0.2.0,MIT svgo,0.7.2,MIT sys-filesystem,1.1.6,Artistic 2.0 table,3.8.3,New BSD tapable,0.2.6,MIT tar,2.2.1,ISC -tar-pack,3.4.0,Simplified BSD +tar-pack,3.3.0,Simplified BSD temple,0.7.7,MIT -test-exclude,4.0.3,ISC +test-exclude,4.0.0,ISC +text,1.3.1,MIT text-table,0.2.0,MIT thor,0.19.4,MIT thread_safe,0.3.6,Apache 2.0 @@ -1021,7 +1037,8 @@ timeago.js,2.0.5,MIT timed-out,2.0.0,MIT timers-browserify,2.0.2,MIT timfel-krb5-auth,0.8.3,LGPL -tmp,0.0.31,MIT +tiny-emitter,1.1.0,MIT +tmp,0.0.28,MIT to-array,0.1.4,MIT to-arraybuffer,1.0.1,MIT to-fast-properties,1.0.2,MIT @@ -1034,10 +1051,10 @@ trim-right,1.0.1,MIT truncato,0.7.8,MIT tryit,1.0.3,MIT tty-browserify,0.0.0,MIT -tunnel-agent,0.6.0,Apache 2.0 +tunnel-agent,0.4.3,Apache 2.0 tweetnacl,0.14.5,Unlicense type-check,0.3.2,MIT -type-is,1.6.15,MIT +type-is,1.6.14,MIT typedarray,0.0.6,MIT tzinfo,1.2.2,MIT u2f,0.2.1,MIT @@ -1060,17 +1077,18 @@ uniqs,2.0.0,MIT unpipe,1.0.0,MIT update-notifier,0.5.0,Simplified BSD url,0.11.0,MIT +url-loader,0.5.8,MIT url-parse,1.0.5,MIT url_safe_base64,0.2.2,MIT user-home,2.0.0,MIT -useragent,2.1.13,MIT +useragent,2.1.12,MIT util,0.10.3,MIT util-deprecate,1.0.2,MIT utils-merge,1.0.0,MIT uuid,3.0.1,MIT validate-npm-package-license,3.0.1,Apache 2.0 validates_hostname,1.0.6,MIT -vary,1.1.1,MIT +vary,1.1.0,MIT vendors,1.0.1,MIT verror,1.3.6,MIT version_sorter,2.1.0,MIT @@ -1085,30 +1103,31 @@ vue-loader,11.3.4,MIT vue-resource,0.9.3,MIT vue-style-loader,2.0.5,MIT vue-template-compiler,2.2.6,MIT -vue-template-es2015-compiler,1.5.2,MIT +vue-template-es2015-compiler,1.5.1,MIT warden,1.2.6,MIT watchpack,1.3.1,MIT wbuf,1.7.2,MIT webpack,2.3.3,MIT -webpack-bundle-analyzer,2.3.1,MIT -webpack-dev-middleware,1.10.1,MIT +webpack-bundle-analyzer,2.3.0,MIT +webpack-dev-middleware,1.10.0,MIT webpack-dev-server,2.4.2,MIT webpack-rails,0.9.10,MIT -webpack-sources,0.1.5,MIT +webpack-sources,0.1.4,MIT websocket-driver,0.6.5,MIT websocket-extensions,0.1.1,MIT whet.extend,0.9.9,MIT -which,1.2.14,ISC +which,1.2.12,ISC which-module,1.0.0,ISC wide-align,1.1.0,ISC wikicloth,0.8.1,MIT window-size,0.1.0,MIT -wordwrap,1.0.0,MIT +wordwrap,0.0.2,MIT/X11 +worker-loader,0.8.0,MIT wrap-ansi,2.1.0,MIT wrappy,1.0.2,ISC write,0.2.1,MIT write-file-atomic,1.3.1,ISC -ws,1.1.2,MIT +ws,1.1.1,MIT wtf-8,1.0.0,MIT xdg-basedir,2.0.0,MIT xmlhttprequest-ssl,1.5.3,MIT |