diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 312f38ce241e2858475746296a5367a01dcc3a23..e7f7299bccb785542efe2010c6608ec7a3d0ec40 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -85,6 +85,7 @@ import Vue from 'vue'; CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); } else { new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); } diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index bfa4fc9037a2027e6f16905966ed5ae5f0f1d739..a9c41d6ca531405fb6cceac344f69c7fca69d786 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -51,6 +51,7 @@ Vue.use(VueResource); } discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); } else { new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); } 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 0000000000000000000000000000000000000000..25ca98afbe7f6dbbc8d229a4d3e6f07e70aff315 --- /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/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index f32d77c1648858bc6e2d8b0423fee3685b48fd93..ced82d9e738f70a9aef22f91e61320acc11ead2b 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -43,6 +43,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; // ci_status_url - String, URL to use to check CI status // this.opts = opts; + this.$widgetBody = $('.mr-widget-body:eq(0)'); $('#modal_merge_info').modal({ show: false }); @@ -110,7 +111,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; return window.location.href = window.location.pathname + urlSuffix; } else if (data.merge_error) { - return $('.mr-widget-body').html("

" + data.merge_error + "

"); + return $('.mr-widget-body:eq(0)').html("

" + data.merge_error + "

"); } else { callback = function() { return merge_request_widget.mergeInProgress(deleteSourceBranch); @@ -146,16 +147,13 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; }; MergeRequestWidget.prototype.getMergeStatus = function() { - return $.get(this.opts.merge_check_url, (data) => { + var that = this; + return $.get(this.opts.merge_check_url, function(data) { var $html = $(data); - this.updateMergeButton(this.status, this.hasCi, $html); - $('.mr-widget-body').replaceWith($html.find('.mr-widget-body')); - $('.mr-widget-footer:not(.mr-approvals-footer)').replaceWith($html.find('.mr-widget-footer:not(.mr-approvals-footer)')); - $('.approvals-components').replaceWith($html.find('.approvals-components')); - if (gl.compileApprovalsWidget) { - gl.compileApprovalsWidget(); - } + that.updateMergeButton(this.status, this.hasCi, $html); + $('.mr-widget-body:eq(0)').replaceWith($html.find('.mr-widget-body')); + $('.mr-widget-footer:eq(0)').replaceWith($html.find('.mr-widget-footer')); }); }; @@ -180,15 +178,15 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; _this.status = data.status; _this.hasCi = data.has_ci; _this.updateMergeButton(_this.status, _this.hasCi); + if (data.coverage) { + _this.showCICoverage(data.coverage); + } if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); if (data.status !== _this.opts.ci_status || data.sha !== _this.opts.ci_sha || data.pipeline !== _this.opts.ci_pipeline) { _this.opts.ci_status = data.status; _this.showCIStatus(data.status); - if (data.coverage) { - _this.showCICoverage(data.coverage); - } if (data.pipeline) { _this.opts.ci_pipeline = data.pipeline; _this.updatePipelineUrls(data.pipeline); @@ -254,8 +252,8 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; if (state == null) { return; } - $('.ci_widget').hide(); - $('.ci_widget.ci-' + state).show(); + $('.ci_widget:eq(0)').hide(); + $('.ci_widget.ci-' + state).eq(0).show(); this.initMiniPipelineGraph(); }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1d563c63f39437d3328ecbf00e0c6587ae8ff92f..3317ba13fb5d29d3f4b080aabdd1e7000a878d7b 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -377,6 +377,7 @@ require('./task_list'); } gl.utils.localTimeAgo($('.js-timeago'), false); + gl.mrWidget.checkStatus(); return this.updateNotesCount(1); }; @@ -683,6 +684,9 @@ require('./task_list'); return note.remove(); }; })(this)); + + gl.mrWidget.checkStatus(); + // Decrement the "Discussions" counter only once return this.updateNotesCount(-1); }; 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 0000000000000000000000000000000000000000..caa50e0474fb548186f3bda1e143e844f69501b5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js @@ -0,0 +1,12 @@ +export default { + name: 'MRWidgetAuthor', + props: { + author: { type: Object, required: true }, + }, + template: ` + + + {{author.name}} + + `, +}; 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 0000000000000000000000000000000000000000..e9c07220d96769eb3f0790d071c3adfeca456604 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js @@ -0,0 +1,23 @@ +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: ` +

+ {{actionText}} + + +

+ `, +}; 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 0000000000000000000000000000000000000000..9b353c480201c258ae1270707a703e1dac0ed5b4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,84 @@ +import '~/lib/utils/datetime_utility'; +import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; + +export default { + name: 'MRWidgetDeployment', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + 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) { + this.service.stopEnvironment(deployment.stop_url) + .then(res => res.json()) + .then((res) => { + if (res.redirect_url) { + document.location.href = res.redirect_url; + } + }); + } + }, + }, + template: ` +
+
+
+ + + +
+ + Deployed to + + {{deployment.name}} + + on + + + {{deployment.external_url_formatted}} + + + {{formatDate(deployment.deployed_at)}} + + + +
+
+ `, +}; + 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 0000000000000000000000000000000000000000..0d327ddd120854edcc36d94271d9554e1504fe0a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -0,0 +1,49 @@ +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); + }, + }, + template: ` +
+
+ Check out branch + + + Download as + + + +
+
+ Request to merge + {{mr.sourceBranch}} + into + + {{mr.targetBranch}} + + + ({{mr.divergedCommitsCount}} {{commitsText}} behind) + +
+
+ `, +}; 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 0000000000000000000000000000000000000000..4d4bed58e834dea06b6789760460cb714d930aa5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js @@ -0,0 +1,14 @@ +export default { + name: 'MRWidgetMergeHelp', + props: { + missingBranch: { type: String, required: false, default: '' }, + }, + template: ` +
+ + + can merge this merge request manually using the + command line +
+ `, +}; 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 0000000000000000000000000000000000000000..10fbde56d72e3cf93a6a81e4c29583956953c67b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -0,0 +1,68 @@ +import PipelineStage from '../../vue_pipelines_index/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; + }, + }, + template: ` +
+
+ + +
+
+ `, +}; + 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 0000000000000000000000000000000000000000..f1f42d6ad07d1b994213f138ccd71b24d610c5ef --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js @@ -0,0 +1,35 @@ +export default { + name: 'MRWidgetRelatedLinks', + props: { + relatedLinks: { type: Object, required: true }, + }, + computed: { + hasLinks() { + return this.relatedLinks.closing || this.relatedLinks.mentioned; + }, + }, + methods: { + hasMultipleIssues(text) { + return !text ? false : text.match(/<\/a> and +
+

+ Closes {{issueLabel('closing')}} . +

+

+ {{issueLabel('mentioned')}} + + {{verbLabel('mentioned')}} mentioned but will not be closed. +

+ + `, +}; 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 0000000000000000000000000000000000000000..9c680bc9845c010c410e6a9b67e222e22d62864d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js @@ -0,0 +1,9 @@ +export default { + name: 'MRWidgetArchived', + template: ` +
+ + This project is archived, write access has been disabled. +
+ `, +}; 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 0000000000000000000000000000000000000000..69aedee4fc1ed0fe9aa70feeac789c3907453a82 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -0,0 +1,12 @@ +export default { + name: 'MRWidgetChecking', + template: ` +
+ + + Checking ability to merge automatically. + + +
+ `, +}; 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 0000000000000000000000000000000000000000..58633e1bd769e46808407b5c89448e4105c8bcf0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js @@ -0,0 +1,28 @@ +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: ` +
+ +
+

The changes were not merged into + + {{mr.targetBranch}} + . +

+
+
+ `, +}; 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 0000000000000000000000000000000000000000..9f1aad06491d9359788ec1b421d9309824107886 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -0,0 +1,34 @@ +export default { + name: 'MRWidgetConflicts', + props: { + mr: { type: Object, required: true }, + }, + computed: { + showResolveConflictsButton() { + const { canMerge, canResolveConflicts, canResolveConflictsInUI } = this.mr; + return canMerge && canResolveConflicts && canResolveConflictsInUI; + }, + }, + template: ` +
+ + + There are merge conflicts. + Resolve these conflicts or ask someone with write access to this repository to merge it locally. + +
+ Resolve conflicts + Merge locally +
+
+ `, +}; 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 0000000000000000000000000000000000000000..819257db5e836e298340de91071a61546a4bb73a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -0,0 +1,43 @@ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetFailedToMerge', + data() { + return { + timer: 10, + isRefreshing: false, + } + }, + mounted() { + setInterval(() => { + this.timer = this.timer - 1; + if (this.timer === 0) { + this.refresh(); + } + }, 1000); + }, + computed: { + timerText() { + return this.timer > 1 ? `${this.timer} seconds` : 'a second'; + }, + }, + methods: { + refresh() { + this.isRefreshing = true; + eventHub.$emit('MRWidgetUpdateRequested'); + }, + }, + template: ` +
+ + + Merge failed. Refreshing in {{timerText}} to show the updated status... + + + Refreshing now... +
+ `, +}; 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 0000000000000000000000000000000000000000..d7b4284ce4b1c28e30c65541244654beeac5b618 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js @@ -0,0 +1,19 @@ +export default { + name: 'MRWidgetLocked', + props: { + mr: { type: Object, required: true }, + }, + template: ` +
+ Locked This merge request is in the process of being merged, during which time it is locked and cannot be closed. + +
+

The changes will be merged into + + {{mr.targetBranch}} + +

+
+
+ `, +}; 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 0000000000000000000000000000000000000000..7a9df4a04696d5ff688e921d804697c55b85b1d4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,85 @@ +import MRWidgetAuthor from '../../components/mr_widget_author'; + +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; + // TODO: Error handling + this.service.cancelAutomaticMerge() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); // TODO: Should find a better way to update store. + }); + }, + 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); // TODO: Response and error handling, widget update + }, + }, + template: ` +
+

+ Set by + + to be merged automatically when the pipeline succeeds. + +

+ +
+ `, +}; 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 0000000000000000000000000000000000000000..db37900d83c48fe5daa063acd723cfc901b51348 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,90 @@ +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 { isRemovingSourceBranch, canRemoveSourceBranch } = this.mr; + + return canRemoveSourceBranch && !this.isMakingRequest && !isRemovingSourceBranch; + }, + shouldShowSourceBranchRemoving() { + return this.isMakingRequest || this.mr.isRemovingSourceBranch; + }, + }, + methods: { + removeSourceBranch() { + this.isMakingRequest = true; + // TODO: Error handling + this.service.removeSourceBranch() + .then(res => res.json()) + .then((res) => { + if (res.message === 'Branch was removed') { + eventHub.$emit('MRWidgetUpdateRequested', () => { + this.isMakingRequest = false; + }); + } + }); + }, + }, + template: ` +
+ +
+
+

+ The changes were merged into + + {{mr.targetBranch}} + +

+

The source branch has been removed.

+

+ You can remove source branch now. + +

+

+ The source branch is being removed. + +

+
+ +
+ `, +}; 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 0000000000000000000000000000000000000000..c44783583a892bc6cdb130be5bdafa9dce683f71 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -0,0 +1,26 @@ +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: ` +
+ + + {{missingBranchName}} branch does not exist. + Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch. + + +
+ `, +}; 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 0000000000000000000000000000000000000000..550624cb1a2381234cfc60797ac892ea631dc106 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js @@ -0,0 +1,12 @@ +export default { + name: 'MRWidgetNotAllowed', + template: ` +
+ + + Ready to be merged automatically. + Ask someone with write access to this repository to merge this request. + +
+ `, +}; 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 0000000000000000000000000000000000000000..643a9f5d4dab9ee4a91266f1e6c837c7ffcfd7c9 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -0,0 +1,12 @@ +export default { + name: 'MRWidgetNothingToMerge', + template: ` +
+ + + There is nothing to merge from source branch into target branch. + Please push new commits or use a different branch. + +
+ `, +}; 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 0000000000000000000000000000000000000000..ee26268e2a7a994d899e47e3f87d0ae680b66e87 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js @@ -0,0 +1,9 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` +
+ + Pipeline blocked. The pipeline for this merge request requires a manual action to proceed. +
+ `, +}; 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 0000000000000000000000000000000000000000..b5bf9593f589877be615baffb15787f4298ca055 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -0,0 +1,9 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` +
+ + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. +
+ `, +}; 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 0000000000000000000000000000000000000000..c2dad7166cfd1b98fa8912495ce37e379f50729c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,230 @@ +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, + isWorking: false, + isMergingImmediately: false, + commitMessage: this.mr.commitMessage, + }; + }, + 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'; + 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 !commitMessage.length || !this.isMergeAllowed() || this.isWorking; + }, + }, + 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, + }; + + this.isWorking = true; + + // TODO: Error handling + this.service.merge(options) + .then(res => res.json()) + .then((res) => { + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } else if (res.status === 'success') { + this.initiateMergePolling(); + } else if (res.status === 'failed') { + eventHub.$emit('FailedToMerge'); + } + }); + }, + initiateMergePolling() { + simplePoll((continuePolling, stopPolling) => { + this.service.pollResource.get() + .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'); + 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 { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }); + }); + }, + initiateRemoveSourceBranchPolling() { + // We need to show source branch is being removed spinner in another component + eventHub.$emit('SetBranchRemoveFlag', [true]); + + simplePoll((continuePolling, stopPolling) => { + this.service.pollResource.get() + .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(); + } + }); + }); + }, + }, + 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 0000000000000000000000000000000000000000..711f568df73074ee6be76bd0cba773a305014cc4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -0,0 +1,20 @@ +export default { + name: 'MRWidgetUnresolvedDiscussions', + props: { + mr: { type: Object, required: true }, + }, + template: ` +
+ + + There are unresolved discussions. Please resolve these discussions + or + . + + Create an issue to resolve them later +
+ `, +}; 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 0000000000000000000000000000000000000000..600ddf70bac56a917a496f335534bf025767c76d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -0,0 +1,34 @@ +/* global Flash */ + +export default { + name: 'MRWidgetWIP', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + methods: { + removeWIP() { + // TODO: Error handling + this.service.removeWIP() + .then(res => res.json()) + .then((res) => { + // TODO: Update store better + this.mr.setData(res); + new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line + $('.merge-request .detail-page-description .title').text(this.mr.title); + }); + }, + }, + template: ` +
+ + This merge request is currently Work In Progress and therefore unable to merge + +
+ `, +}; 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 0000000000000000000000000000000000000000..ae13adea28ad918071bc63d33a621d95121f7b50 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -0,0 +1,29 @@ +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 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 deviseState } from './stores/devise_state'; +export { default as mrWidgetOptions } from './mr_widget_options'; +export { statesToShowHelpWidget, stateToComponentMap } from './stores/state_maps'; 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 0000000000000000000000000000000000000000..0948c2e53524a736a55c060600868ce89ee7687a --- /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 0000000000000000000000000000000000000000..cd65ac069c5c4324498606826c503f4dac8665de --- /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 0000000000000000000000000000000000000000..c7d9aade1215213c8bdbd9dd320396ff4a5db360 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -0,0 +1,166 @@ +import { + WidgetHeader, + WidgetMergeHelp, + WidgetPipeline, + WidgetDeployment, + WidgetRelatedLinks, + MergedState, + ClosedState, + LockedState, + WipState, + ArchivedState, + ConflictsState, + NothingToMergeState, + MissingBranchState, + NotAllowedState, + ReadyToMergeState, + UnresolvedDiscussionsState, + PipelineBlockedState, + PipelineFailedState, + FailedToMerge, + MergeWhenPipelineSucceedsState, + CheckingState, + MRWidgetStore, + MRWidgetService, + eventHub, + stateToComponentMap, + statesToShowHelpWidget, +} from './dependencies'; + +export default { + el: '#js-vue-mr-widget', + name: 'MRWidget', + data() { + const store = new MRWidgetStore(gl.mrWidgetData); + const service = new MRWidgetService(store); + return { + mr: store, + service, + }; + }, + computed: { + componentName() { + return stateToComponentMap[this.mr.state]; + }, + shouldRenderMergeHelp() { + return 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: { + checkStatus(cb) { + // TODO: Error handling + this.service.checkStatus() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); + if (cb) { + cb.call(null, res); + } + }); + }, + initCIPolling() { + this.ciStatusInterval = new gl.SmartInterval({ + callback: this.fetchCIStatus, + 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, + }); + }, + fetchCIStatus() { + // TODO: Error handling + this.service.ciStatusResorce.get() + .then(res => res.json()) + .then((res) => { + if (res.has_ci) { + this.mr.updatePipelineData(res); + } + }); + }, + fetchDeployments() { + this.service.fetchDeployments() + .then(res => res.json()) + .then((res) => { + if (res.length) { + this.mr.deployments = res; + } + }); + }, + }, + created() { + 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', () => { + this.mr.state = 'failedToMerge'; + }); + }, + mounted() { + this.checkStatus(); + this.fetchCIStatus(); + + if (this.mr.hasCI) { + this.initCIPolling(); + } + this.initDeploymentsPolling(); + }, + 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-checking': CheckingState, + 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, + 'mr-widget-pipeline-blocked': PipelineBlockedState, + 'mr-widget-pipeline-failed': PipelineFailedState, + 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, + }, + template: ` +
+ + + + + + +
+ `, +}; 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 0000000000000000000000000000000000000000..37cd8b8de996fc4ec5537f6c511f147b89cee79d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class MRWidgetService { + constructor(mr) { + this.store = mr; + + this.mergeResource = Vue.resource(mr.mergePath); + this.mergeCheckResource = Vue.resource(mr.mergeCheckPath); + this.cancelAutoMergeResource = Vue.resource(mr.cancelAutoMergePath); + this.removeWIPResource = Vue.resource(mr.removeWIPPath); + this.removeSourceBranchResource = Vue.resource(mr.sourceBranchPath); + this.ciStatusResorce = Vue.resource(mr.ciStatusPath); + this.deploymentsResource = Vue.resource(mr.ciEnvironmentsStatusPath); + this.pollResource = Vue.resource(`${mr.statusPath}?basic=true`); + } + + 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(); + } + + fetchCIStatus() { + return this.ciStatusResorce.get(); + } + + checkStatus() { + return this.mergeCheckResource.get(); + } + + stopEnvironment(url) { + return Vue.http.post(url); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/devise_state.js b/app/assets/javascripts/vue_merge_request_widget/stores/devise_state.js new file mode 100644 index 0000000000000000000000000000000000000000..c1de7a72852e2b6e42827c6f8f7b693c9e8ff5cf --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/devise_state.js @@ -0,0 +1,29 @@ +export default function deviseState(data) { + if (data.project_archived) { + return 'archived'; + } else if (data.branch_missing) { + return 'missingBranch'; + } else if (data.has_no_commits) { + 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 '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 0000000000000000000000000000000000000000..54078fb019cd8140d4d962013036cccc11ab9f5c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,145 @@ +import Timeago from 'timeago.js'; +import eventHub from '../event_hub'; +import { deviseState } from '../dependencies'; + +export default class MergeRequestStore { + + constructor(data) { + this.setData(data); + } + + setData(data) { + // TODO: Remove this + this.rawData = data || {}; + + const currentUser = data.current_user; + + 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.divergedCommitsCount = data.diverged_commits_count; + this.pipeline = data.pipeline || {}; + this.deployments = this.deployments || data.deployments || []; + + if (data.issues_links) { + const { closing, mentioned_but_not_closing } = data.issues_links; + + this.relatedLinks = { + closing, + mentioned: mentioned_but_not_closing, + }; + } + + 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.targetBranchPath = data.target_branch_path; + this.conflictResolutionPath = data.conflict_resolution_ui_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.ciEnvironmentsStatusPath = data.ci_environments_status_url; + this.ciStatusPath = data.ci_status_path; + this.mergeCheckPath = data.merge_check_path; + this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; + + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canRevert = currentUser.can_revert || false; + this.canResolveConflicts = currentUser.can_resolve_conflicts || false; + this.canMerge = currentUser.can_merge || false; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCancelAutomaticMerge = currentUser.can_cancel_automatic_merge || false; + this.canUpdateMergeRequest = currentUser.can_update_merge_request || false; + this.canResolveConflictsInUI = data.conflicts_can_be_resolved_in_ui || false; + this.canBeCherryPicked = data.can_be_cherry_picked || false; + this.canBeMerged = data.can_be_merged || false; + + this.isPipelineActive = data.pipeline ? data.pipeline.active : false; + this.isPipelineFailed = data.pipeline ? data.pipeline.details.status.group === 'failed' : false; + this.isPipelineBlocked = data.pipeline ? data.pipeline.details.status.group === 'manual' : false; + this.isOpen = data.state === 'opened' || data.state === 'reopened' || false; + this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; + this.hasCI = data.has_ci; + this.ciStatus = data.ci_status; + + this.setState(data); + } + + setState(data) { + if (this.isOpen) { + this.state = deviseState.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; + } + } + } + + updatePipelineData(data) { + const newStatus = data.status; + + if (newStatus) { + if (newStatus !== this.pipeline.details.status.group) { + eventHub.$emit('MRWidgetUpdateRequested'); + } else { + // TODO: Make sure `this.pipeline.details.status` always exists before access it. + this.pipeline.coverage = data.coverage; + this.pipeline.details.status.group = newStatus; + this.pipeline.details.stages = data.stages; + } + } + } + + 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 0000000000000000000000000000000000000000..f116a0bd9f2d1eec757963cd0e5fbbd895b5e86a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,29 @@ +export 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', +}; + +export const statesToShowHelpWidget = [ + 'locked', + 'conflicts', + 'workInProgress', + 'readyToMerge', + 'checking', + 'unresolvedDiscussions', + 'pipelineFailed', + 'pipelineBlocked', +]; diff --git a/app/assets/javascripts/vue_pipelines_index/components/stage.js b/app/assets/javascripts/vue_pipelines_index/components/stage.js index a2c29002707005a2fd35c5c5f1767e02645c9767..37bb795010728ac7a48a0e4429e81adce1493989 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/stage.js +++ b/app/assets/javascripts/vue_pipelines_index/components/stage.js @@ -1,32 +1,11 @@ /* global Flash */ -import canceledSvg from 'icons/_icon_status_canceled_borderless.svg'; -import createdSvg from 'icons/_icon_status_created_borderless.svg'; -import failedSvg from 'icons/_icon_status_failed_borderless.svg'; -import manualSvg from 'icons/_icon_status_manual_borderless.svg'; -import pendingSvg from 'icons/_icon_status_pending_borderless.svg'; -import runningSvg from 'icons/_icon_status_running_borderless.svg'; -import skippedSvg from 'icons/_icon_status_skipped_borderless.svg'; -import successSvg from 'icons/_icon_status_success_borderless.svg'; -import warningSvg from 'icons/_icon_status_warning_borderless.svg'; +import { statusClassToBorderlessSvgMap } from '../../vue_shared/pipeline_svg_icons'; export default { data() { - const svgsDictionary = { - 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, - }; - return { builds: '', spinner: '', - svg: svgsDictionary[this.stage.status.icon], }; }, @@ -89,6 +68,9 @@ export default { triggerButtonClass() { return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; }, + svg() { + return statusClassToBorderlessSvgMap[this.stage.status.icon]; + }, }, template: `
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 0000000000000000000000000000000000000000..ae246ada01b3652c237b7f131961d413a896ffa5 --- /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: ` +
+ + + +
+ `, +}; 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 0000000000000000000000000000000000000000..5af30ae74f0edc6c1bf17c01a8041198fc7d34ac --- /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/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab88c5d3d3c1d076770af14faebd9840..1b7d4e4225813a89dc07272791233cbefbeadebe 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/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 9507fae301c41d86d0293cd1649e258501b835fd..fe69558d1f2718fe2541f4f2b5948a17a437648e 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 { @@ -89,12 +83,12 @@ } .ci_widget { - border-bottom: 1px solid $well-inner-border; color: $gl-text-color; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; + padding: $gl-padding-top $gl-padding 0 $gl-padding; 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 { @@ -168,10 +161,23 @@ color: $gl-text-color; } + .capitalize { + text-transform: capitalize; + } + .js-deployment-link { display: inline-block; } + .mr-widget-help { + margin: $gl-padding; + color: $ci-skipped-color; + } + + .mr-links.mr-info-list { + margin: 0 0 $gl-padding 26px; + } + .mr-widget-body { h4 { font-weight: 600; @@ -189,6 +195,33 @@ margin-right: 7px; } + label { + font-weight: normal; + } + + .spacing { + margin: 0 $gl-padding; + } + + .bold { + font-weight: bold; + color: #5c5c5c; + } + + .danger { + color: $gl-danger; + } + + .mr-widget-help { + margin: $gl-padding 0; + } + + .dropdown-toggle { + .fa { + color: inherit; + } + } + @media (max-width: $screen-xs-max) { h4 { font-size: 14px; @@ -220,6 +253,12 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } } .mr-widget-footer { @@ -345,61 +384,50 @@ } } -.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; + overflow: hidden; + margin: 10px 0 $gl-padding 12px; - li { + p { + margin: 6px 0; position: relative; - margin: 0; - padding: 0; - display: block; - - span { - margin-left: 15px; - max-height: 20px; - } - } - - li::before { - content: ''; - position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; - } + padding-left: 15px; - li:last-child { &::before { - top: 18px; + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 8px; + width: 8px; + left: 0px; } - span { - display: block; - position: relative; - top: 5px; - margin-top: 5px; + &:last-child { + margin-bottom: 0; + &::before { + top: 14px; + } } } + + .legend { + height: 100%; + width: 2px; + background: $border-color; + position: absolute; + top: -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; } .panel-new-merge-request { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 31eca8f3442de858c9f12e2a420afcaa7b819938..589ad4649ee16a659166e98ff36f1d6662fa0eaa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -102,7 +102,10 @@ def can?(object, action, subject = :global) end def access_denied! - render "errors/access_denied", layout: "errors", status: 404 + respond_to do |format| + format.json { head :unauthorized } + format.any { render "errors/access_denied", layout: "errors", status: 404 } + end end def git_not_found! diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 840405f38cbbbc5631edbf6f993167bb626232f7..d78b71c783fdb77eebc24b4d516a79371cc7d99f 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -71,7 +71,9 @@ def destroy redirect_to namespace_project_branches_path(@project.namespace, @project), status: 303 end + # TODO: @oswaldo - Handle only JSON and HTML after deleting existing MR widget. format.js { render nothing: true, status: status[:return_code] } + format.json { render json: { message: status[:message] }, status: status[:return_code] } end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index cc67f688d51ddb20c9d0ab2bbe72e5dea4bef5eb..a6d442004ea337356a4c2fefc76ac91f17c6c5f8 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -36,7 +36,7 @@ def pipelines format.html format.json do 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/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 8a4ef4d213ea2b09cb31a8cd70cffb9d125125ee..b08849d0ca845e159ea10e39bd3d182739d03182 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -81,11 +81,13 @@ def stop stop_action = @environment.stop_with_action!(current_user) - if stop_action - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) + action_or_env_url = if stop_action + polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) else - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + namespace_project_environment_url(project.namespace, project, @environment) end + + render json: { redirect_url: action_or_env_url } end def terminal diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index aa40a2bfb1858b0c15a546f0f786c3de7f98820e..e07fa3443b00bd42082b9df0728f6bef51f7c5ea 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -77,10 +77,12 @@ def index 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, type: :full) + render json: @merge_request_json end format.patch do @@ -236,7 +238,7 @@ def pipelines format.json do render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -250,7 +252,7 @@ def new render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -327,17 +329,37 @@ def update 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) + + # TODO: @oswaldo - Handle only JSON after deleting existing MR widget. + respond_to do |format| + format.json do + render json: serializer.represent(@merge_request).to_json + end - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + format.html do + redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), + notice: "The merge request can now be merged." + + end + end 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 + respond_to do |format| + format.js do + render partial: "projects/merge_requests/widget/show.html.haml", layout: false + end + + format.json do + render json: serializer.represent(@merge_request).to_json + end + end end def cancel_merge_when_pipeline_succeeds @@ -348,56 +370,63 @@ def cancel_merge_when_pipeline_succeeds MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) + + # TODO: @oswaldo - Handle only JSON after deleting existing MR widget. + respond_to do |format| + format.json do + render json: serializer.represent(@merge_request.reload).to_json + end + + format.js + end end def merge return access_denied! unless @merge_request.can_be_merged_by?(current_user) return render_404 unless @merge_request.approved? + @status = merge! + + # TODO: @oswaldo - Handle only JSON after deleting existing MR widget. + respond_to do |format| + format.json { render json: { status: @status } } + format.js + end + end + + 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?) - @status = :failed - return + return :failed end - merge_request_service = MergeRequests::MergeService.new(@project, current_user, merge_params) - - unless merge_request_service.hooks_validation_pass?(@merge_request) - @status = :hook_validation_error - return - end - - if params[:sha] != @merge_request.diff_head_sha - @status = :sha_mismatch - return - end + return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha @merge_request.update(merge_error: nil, squash: merge_params[:squash]) if params[:merge_when_pipeline_succeeds].present? - unless @merge_request.head_pipeline - @status = :failed - return - end + return :failed unless @merge_request.head_pipeline if @merge_request.head_pipeline.active? MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user, merge_params) .execute(@merge_request) - @status = :merge_when_pipeline_succeeds + :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 + + :success else - @status = :failed + :failed end else MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success + + :success end end @@ -471,6 +500,7 @@ def assign_related_issues end end + # TODO: @oswaldo - remove it when deleting old widget parts def ci_status pipeline = @merge_request.head_pipeline @pipelines = @merge_request.all_pipelines @@ -492,8 +522,11 @@ def ci_status sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha), status: status, coverage: coverage, + has_ci: @merge_request.has_ci?, pipeline: pipeline.try(:id), - has_ci: @merge_request.has_ci? + stages: PipelineSerializer + .new(project: @project, current_user: @current_user) + .represent_stages(pipeline) } render json: response @@ -501,7 +534,7 @@ def ci_status 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 @@ -621,6 +654,8 @@ def define_show_vars labels define_pipelines_vars + + @merge_request_json = serializer.represent(@merge_request).to_json end # Discussion tab data is rendered on html responses of actions @@ -803,4 +838,12 @@ def close_merge_request_without_source_project @merge_request.close end end + + def serializer + if params[:basic] + MergeRequestBasicSerializer.new + else + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + end + end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 43a1abaa662f757cd949050035b182b47151dba7..7b853a5d874ec16eac7717b95766809abeef17fd 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -31,7 +31,7 @@ def index format.json do render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@pipelines), count: { diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 762ff85d9ceeb5000745aac90ccdba1d9b3adfff..e5600953b96393fced33d1d5c3485f8d16996627 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,7 @@ def serialize_issuable(issuable) when Issue IssueSerializer.new.represent(issuable).to_json when MergeRequest - MergeRequestSerializer.new.represent(issuable).to_json + @merge_request_json end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index f14438ef598ea6e2190346ea85b6a8e2ca3e17dd..9a905d81b5aa203209f02189ab2c4279b6b6b2a1 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -55,6 +55,7 @@ def merge_path_description(merge_request, separator) end end + # TODO: @oswaldo - Delete when removing old widget parts def issues_sentence(issues) # Sorting based on the `#123` or `group/project#123` reference will sort # local issues first. @@ -63,10 +64,12 @@ def issues_sentence(issues) end.sort.to_sentence end + # TODO: @oswaldo - Delete when removing old widget parts def mr_closes_issues @mr_closes_issues ||= @merge_request.closes_issues(current_user) end + # TODO: @oswaldo - Delete when removing old widget parts def mr_issues_mentioned_but_not_closing @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a9eedf6edf2f16633baf49b1475d11f55889b54a..0e2a06b871cc3b14c5fe94791c0bcc66520e7838 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -466,7 +466,8 @@ def can_cancel_merge_when_pipeline_succeeds?(current_user) end def can_remove_source_branch?(current_user) - !source_project.protected_branch?(source_branch) && + source_project && + !source_project.protected_branch?(source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && diff_head_commit == source_branch_head @@ -958,6 +959,8 @@ def conflicts 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/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..935d67a4f37fb6fc86e9c8336f0e81f950fc5c0a --- /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/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..146081b14ea0e573b822317cf4d42d8359f1da31 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,5 @@ +class MergeRequestBasicEntity < Grape::Entity + expose :merge_status + expose :state + expose :source_branch_exists?, as: :source_branch_exists +end diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..ad880270d500274a10ab0d7eeabed6bba7e8781f --- /dev/null +++ b/app/serializers/merge_request_basic_serializer.rb @@ -0,0 +1,4 @@ +class MergeRequestBasicSerializer < BaseSerializer + entity MergeRequestBasicEntity +end + diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 59c94c613f6c097f0e8a7a55cc74c0c719e21121..8fe3c19b8c5b3bdab0467378b15a4a7a480c8cbf 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,5 +1,7 @@ class MergeRequestEntity < IssuableEntity - expose :approvals_before_merge + include RequestAwareEntity + include GitlabMarkdownHelper + expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha @@ -14,4 +16,224 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # EE specific + expose :approvals_before_merge + + # 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 + expose :diff_head_commit_short_id do |merge_request| + merge_request.diff_head_commit.try(:short_id) + 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 :conflicts_can_be_resolved_in_ui?, as: :conflicts_can_be_resolved_in_ui + expose :branch_missing?, as: :branch_missing + expose :has_no_commits?, as: :has_no_commits + expose :can_be_cherry_picked?, as: :can_be_cherry_picked + expose :cannot_be_merged?, as: :has_conflicts + expose :can_be_merged?, as: :can_be_merged + + # CI related + expose :has_ci?, as: :has_ci + expose :ci_status do |merge_request| + pipeline = merge_request.head_pipeline + + if pipeline + status = pipeline.status + status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? + + status || "preparing" + else + ci_service = merge_request.source_project.try(:ci_service) + ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service + end + end + + expose :issues_links do + expose :closing do |merge_request| + closes_issues = merge_request.closes_issues(current_user) + + markdown issues_sentence(merge_request.project, closes_issues), + pipeline: :gfm, + author: merge_request.author, + project: merge_request.project + end + + expose :mentioned_but_not_closing do |merge_request| + mentioned_but_not_closing_issues = merge_request + .issues_mentioned_but_not_closing(current_user) + + markdown issues_sentence(merge_request.project, mentioned_but_not_closing_issues), + pipeline: :gfm, + author: merge_request.author, + project: merge_request.project + end + end + + expose :current_user do + expose :can_create_issue do |merge_request| + merge_request.project.issues_enabled? && + can?(request.current_user, :create_issue, merge_request.project) + end + + expose :can_update_merge_request do |merge_request| + merge_request.project.merge_requests_enabled? && + can?(request.current_user, :update_merge_request, merge_request.project) + end + + expose :can_resolve_conflicts do |merge_request| + merge_request.conflicts_can_be_resolved_by?(request.current_user) + end + + expose :can_remove_source_branch do |merge_request| + merge_request.can_remove_source_branch?(request.current_user) + end + + expose :can_merge do |merge_request| + merge_request.can_be_merged_by?(request.current_user) + end + + expose :can_merge_via_cli do |merge_request| + merge_request.can_be_merged_via_command_line_by?(request.current_user) + end + + expose :can_revert do |merge_request| + merge_request.can_be_reverted?(request.current_user) + end + + expose :can_cancel_automatic_merge do |merge_request| + merge_request.can_cancel_merge_when_pipeline_succeeds?(request.current_user) + end + end + + expose :target_branch_path do |merge_request| + namespace_project_branch_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request.target_branch) + end + + expose :source_branch_path do |merge_request| + namespace_project_branch_path(merge_request.source_project.namespace, + merge_request.source_project, + merge_request.source_branch) + end + + expose :project_archived do |merge_request| + merge_request.project.archived? + end + + expose :conflict_resolution_ui_path do |merge_request| + conflicts_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :remove_wip_path do |merge_request| + remove_wip_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :merge_path do |merge_request| + merge_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| + cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path( + merge_request.target_project.namespace, + merge_request.target_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| + merge_request.open? && + merge_request.diverged_from_target_branch? ? + merge_request.diverged_commits_count : 0 + end + + expose :email_patches_path do |merge_request| + namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :patch) + end + + expose :plain_diff_path do |merge_request| + namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :diff) + end + + expose :ci_status_path do |merge_request| + ci_status_namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request) + end + + # FIXME: @oswaldo, please implement this + expose :status_path do |merge_request| + path = namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :diff) + path.sub! 'diff', 'json' + end + + # TODO: @oswaldo, please verify this + expose :merge_check_path do |merge_request| + merge_check_namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request) + end + + expose :only_allow_merge_if_pipeline_succeeds do |merge_request| + merge_request.project.only_allow_merge_if_pipeline_succeeds? + end + + expose :create_issue_to_resolve_discussions_path do |merge_request| + new_namespace_project_issue_path(merge_request.project.namespace, + merge_request.project, + merge_request_for_resolving_discussions_of: merge_request.iid) + end + + expose :ci_environments_status_url do |merge_request| + ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + private + + delegate :current_user, to: :request + + 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 end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 3f16dd66d54b4c6f235ed64d0ad85e304823a604..27d37466d82ea43e7e7180539551e01f3278b9fb 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( @@ -70,15 +72,15 @@ class PipelineEntity < Grape::Entity def can_retry? pipeline.retryable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.current_user, :update_pipeline, pipeline) end def can_cancel? pipeline.cancelable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.current_user, :update_pipeline, pipeline) 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 7829df9fada1a623bb62addefeea2bd6121af80e..0895dd7382224cc2c64c48d15e1ff889bcc18fcb 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -29,4 +29,11 @@ def represent_status(resource) 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 7a047bdc712388a5d6f96899d467ecad13c84d04..157ab1776a70d6441b89d5e39735eb2f7c22d7a5 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -33,6 +33,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/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml index eab5be488b5d32faed0037aa1b9004e5901dc88f..1f803f4001a87a12fecd87332eec0d1f0b64163f 100644 --- 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 @@ -1,2 +1,2 @@ :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}"); + $('.mr-widget-body:eq(0)').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 index 84c90ddf9552fe62e0cb5b03d8bdd013a0e70a77..27fb3ae0dd5a1590302a9b1ae7b2c6a12143f862 100644 --- a/app/views/projects/merge_requests/merge.js.haml +++ b/app/views/projects/merge_requests/merge.js.haml @@ -11,7 +11,7 @@ $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/error'))}"); - when :sha_mismatch :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); + $('.mr-widget-body:eq(0)').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'))}"); + $('.mr-widget-body:eq(0)').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}"); diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml index 0b0fb7854c2171316aef143a1fc1ffd41349f80f..ce6759945b839d87478b83745d7af6fc0c19a35b 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -7,6 +7,9 @@ - elsif @merge_request.locked? = render 'projects/merge_requests/widget/locked' +:javascript + window.gl.mrWidgetData = #{@merge_request_json} + :javascript var opts = { merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", @@ -37,3 +40,8 @@ } merge_request_widget = new window.gl.MergeRequestWidget(opts); + +#js-vue-mr-widget.mr-widget + +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('vue_merge_request_widget') diff --git a/config/webpack.config.js b/config/webpack.config.js index 09c62bbe25b131f81ba683454389dffc19f86bda..d5b1f8d8ffec0fd058429ee1cb6902fa60342c25 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -47,6 +47,7 @@ var config = { u2f: ['vendor/u2f'], users: './users/users_bundle.js', vue_pipelines: './vue_pipelines_index/index.js', + vue_merge_request_widget: './vue_merge_request_widget/index.js', }, output: { diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index d20e7368086aadec04067a7279626ff919e8af73..23f55f05d1555ccb011526980bd90aa2596eca5f 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -177,33 +177,98 @@ 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/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 2c3cc45f2008ff0172d3f047a212b3cc2790f538..05341c86dd7df1f092532c595ef483b0d17cfe6d 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -151,6 +151,48 @@ 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 9f615a2a70a2cb708080fa20e69859ce27df0cae..7724b277f3558e5547a29892e4219cafbb1cd6e3 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -236,63 +236,51 @@ def json_response 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(json_response) + .to eql(MergeRequestBasicSerializer.new.represent(merge_request).as_json) + end end - it "does not escape Html" do - allow_any_instance_of(MergeRequest).to receive(:"to_#{format}"). - and_return('HTML entities &<>" ') - - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + context 'without basic param' do + it 'renders the merge request in the json format' do + go(format: :json) - 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(json_response).to eql( + MergeRequestSerializer + .new(current_user: user, project: project) + .represent(merge_request).as_json) + 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 @@ -300,11 +288,7 @@ def json_response 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 @@ -556,39 +540,166 @@ def update_merge_request(params = {}) project_id: project, id: merge_request.iid, squash: false, - format: 'raw' + format: format } end - context 'when the user does not have access' do - before do - project.team.truncate - project.team << [user, :reporter] - post :merge, base_params - end + context 'as JSON' do + let(:format) { 'json' } + + context 'when the user does not have access' do + before do + project.team.truncate + project.team << [user, :reporter] + xhr :post, :merge, base_params + end - it 'returns not found' do - expect(response).to be_not_found + it 'returns access denied' do + expect(response).to have_http_status(401) + end end - end - context 'when the merge request is not mergeable' do - before do - merge_request.update_attributes(title: "WIP: #{merge_request.title}") + context 'when the merge request is not mergeable' do + before do + merge_request.update_attributes(title: "WIP: #{merge_request.title}") + + post :merge, base_params + end - post :merge, base_params + it 'returns :failed' do + expect(json_response).to eq('status' => 'failed') + end end - it 'returns :failed' do - expect(assigns(:status)).to eq(:failed) + context 'when the sha parameter does not match the source SHA' do + before { post :merge, base_params.merge(sha: 'foo') } + + it 'returns :sha_mismatch' do + expect(json_response).to eq('status' => 'sha_mismatch') + end end - end - context 'when the sha parameter does not match the source SHA' do - before { post :merge, base_params.merge(sha: 'foo') } + context 'when the sha parameter matches the source SHA' do + def merge_with_sha + post :merge, base_params.merge(sha: merge_request.diff_head_sha) + end + + it 'returns :success' do + merge_with_sha + + expect(json_response).to eq('status' => 'success') + end + + it 'starts the merge immediately' do + expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, anything) + + merge_with_sha + end + + context 'when the pipeline succeeds is passed' do + def merge_when_pipeline_succeeds + post :merge, base_params.merge(sha: merge_request.diff_head_sha, merge_when_pipeline_succeeds: '1') + end + + before do + create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + end + + it 'returns :merge_when_pipeline_succeeds' do + 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 + service = double(:merge_when_pipeline_succeeds_service) + + expect(MergeRequests::MergeWhenPipelineSucceedsService) + .to receive(:new).with(project, anything, anything) + .and_return(service) + expect(service).to receive(:execute).with(merge_request) + + merge_when_pipeline_succeeds + end + + context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do + before do + project.update_column(:only_allow_merge_if_pipeline_succeeds, true) + end + + it 'returns :merge_when_pipeline_succeeds' do + merge_when_pipeline_succeeds + + expect(json_response).to eq('status' => 'merge_when_pipeline_succeeds') + end + end + end + + describe 'only_allow_merge_if_all_discussions_are_resolved? setting' do + let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) } + + context 'when enabled' do + before do + project.update_column(:only_allow_merge_if_all_discussions_are_resolved, true) + end + + context 'with unresolved discussion' do + before do + expect(merge_request).not_to be_discussions_resolved + end + + it 'returns :failed' do + merge_with_sha + + expect(json_response).to eq('status' => 'failed') + end + end + + context 'with all discussions resolved' do + before do + merge_request.discussions.each { |d| d.resolve!(user) } + expect(merge_request).to be_discussions_resolved + end + + it 'returns :success' do + merge_with_sha + + expect(json_response).to eq('status' => 'success') + end + end + end + + context 'when disabled' do + before do + project.update_column(:only_allow_merge_if_all_discussions_are_resolved, false) + end + + context 'with unresolved discussion' do + before do + expect(merge_request).not_to be_discussions_resolved + end + + it 'returns :success' do + merge_with_sha + + expect(json_response).to eq('status' => 'success') + end + end + + context 'with all discussions resolved' do + before do + merge_request.discussions.each { |d| d.resolve!(user) } + expect(merge_request).to be_discussions_resolved + end + + it 'returns :success' do + merge_with_sha - it 'returns :sha_mismatch' do - expect(assigns(:status)).to eq(:sha_mismatch) + expect(json_response).to eq('status' => 'success') + end + end + end + end end end @@ -615,47 +726,84 @@ def merge_with_sha(params = {}) end end - it 'returns :success' do - merge_with_sha + context 'as any other format' do + let(:format) { 'js' } - expect(assigns(:status)).to eq(:success) - end + context 'when squash is passed as 1' do + it 'updates the squash attribute on the MR to true' do + merge_request.update(squash: false) + merge_with_sha(squash: '1') + + expect(merge_request.reload.squash).to be_truthy + end + end - it 'starts the merge immediately' do - expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, anything) + context 'when squash is passed as 1' do + it 'updates the squash attribute on the MR to false' do + merge_request.update(squash: true) + merge_with_sha(squash: '0') - merge_with_sha + expect(merge_request.reload.squash).to be_falsey + end + end end - context 'when the pipeline succeeds is passed' do - def merge_when_pipeline_succeeds - post :merge, base_params.merge(sha: merge_request.diff_head_sha, merge_when_pipeline_succeeds: '1') + context 'when the user does not have access' do + before do + project.team.truncate + project.team << [user, :reporter] + post :merge, base_params + end + + it 'returns not found' do + expect(response).to be_not_found end + end + context 'when the merge request is not mergeable' do before do - create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + merge_request.update_attributes(title: "WIP: #{merge_request.title}") + + post :merge, base_params end - it 'returns :merge_when_pipeline_succeeds' do - merge_when_pipeline_succeeds + it 'returns :failed' do + expect(assigns(:status)).to eq(:failed) + end + end - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + context 'when the sha parameter does not match the source SHA' do + before { post :merge, base_params.merge(sha: 'foo') } + + it 'returns :sha_mismatch' do + expect(assigns(:status)).to eq(:sha_mismatch) + end + end + + context 'when the sha parameter matches the source SHA' do + def merge_with_sha + post :merge, base_params.merge(sha: merge_request.diff_head_sha) end - it 'sets the MR to merge when the pipeline succeeds' do - service = double(:merge_when_pipeline_succeeds_service) + it 'returns :success' do + merge_with_sha + + expect(assigns(:status)).to eq(:success) + end - expect(MergeRequests::MergeWhenPipelineSucceedsService) - .to receive(:new).with(project, anything, anything) - .and_return(service) - expect(service).to receive(:execute).with(merge_request) + it 'starts the merge immediately' do + expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, anything) - merge_when_pipeline_succeeds + merge_with_sha end - context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do + context 'when the pipeline succeeds is passed' do + def merge_when_pipeline_succeeds + post :merge, base_params.merge(sha: merge_request.diff_head_sha, merge_when_pipeline_succeeds: '1') + end + before do - project.update_column(:only_allow_merge_if_pipeline_succeeds, true) + create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) end it 'returns :merge_when_pipeline_succeeds' do @@ -663,70 +811,93 @@ def merge_when_pipeline_succeeds expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) end - end - end - describe 'only_allow_merge_if_all_discussions_are_resolved? setting' do - let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) } + it 'sets the MR to merge when the pipeline succeeds' do + service = double(:merge_when_pipeline_succeeds_service) - context 'when enabled' do - before do - project.update_column(:only_allow_merge_if_all_discussions_are_resolved, true) + expect(MergeRequests::MergeWhenPipelineSucceedsService) + .to receive(:new).with(project, anything, anything) + .and_return(service) + expect(service).to receive(:execute).with(merge_request) + + merge_when_pipeline_succeeds end - context 'with unresolved discussion' do + context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do before do - expect(merge_request).not_to be_discussions_resolved + project.update_column(:only_allow_merge_if_pipeline_succeeds, true) end - it 'returns :failed' do - merge_with_sha + it 'returns :merge_when_pipeline_succeeds' do + merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:failed) + expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) end end + end + + describe 'only_allow_merge_if_all_discussions_are_resolved? setting' do + let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) } - context 'with all discussions resolved' do + context 'when enabled' do before do - merge_request.discussions.each { |d| d.resolve!(user) } - expect(merge_request).to be_discussions_resolved + project.update_column(:only_allow_merge_if_all_discussions_are_resolved, true) end - it 'returns :success' do - merge_with_sha + context 'with unresolved discussion' do + before do + expect(merge_request).not_to be_discussions_resolved + end + + it 'returns :failed' do + merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(assigns(:status)).to eq(:failed) + end end - end - end - context 'when disabled' do - before do - project.update_column(:only_allow_merge_if_all_discussions_are_resolved, false) + context 'with all discussions resolved' do + before do + merge_request.discussions.each { |d| d.resolve!(user) } + expect(merge_request).to be_discussions_resolved + end + + it 'returns :success' do + merge_with_sha + + expect(assigns(:status)).to eq(:success) + end + end end - context 'with unresolved discussion' do + context 'when disabled' do before do - expect(merge_request).not_to be_discussions_resolved + project.update_column(:only_allow_merge_if_all_discussions_are_resolved, false) end - it 'returns :success' do - merge_with_sha + context 'with unresolved discussion' do + before do + expect(merge_request).not_to be_discussions_resolved + end - expect(assigns(:status)).to eq(:success) - end - end + it 'returns :success' do + merge_with_sha - context 'with all discussions resolved' do - before do - merge_request.discussions.each { |d| d.resolve!(user) } - expect(merge_request).to be_discussions_resolved + expect(assigns(:status)).to eq(:success) + end end - it 'returns :success' do - merge_with_sha + context 'with all discussions resolved' do + before do + merge_request.discussions.each { |d| d.resolve!(user) } + expect(merge_request).to be_discussions_resolved + end + + it 'returns :success' do + merge_with_sha - expect(assigns(:status)).to eq(:success) + expect(assigns(:status)).to eq(:success) + end end end end @@ -1111,16 +1282,102 @@ def go(format: 'html') end context 'POST remove_wip' do - it 'removes the wip status' do + before do merge_request.title = merge_request.wip_title merge_request.save + end - post :remove_wip, - namespace_id: merge_request.project.namespace.to_param, - project_id: merge_request.project, - id: merge_request.iid + context 'as HTML' do + before do + post :remove_wip, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project, + id: merge_request.iid + end + + it 'removes the wip status' do + expect(merge_request.reload.title).to eq(merge_request.wipless_title) + end + + it 'redirect to merge request show page' do + expect(response).to redirect_to( + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request)) + end + end + + context 'as JSON' do + before do + 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 + end + + describe 'POST cancel_merge_when_pipeline_succeeds' do + context 'as JS' 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 + 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 render_template('cancel_merge_when_pipeline_succeeds') } + end + + context 'as JSON' 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(merge_request.reload.title).to eq(merge_request.wipless_title) + 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 end diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js index 82b00b4c1ec614c9a4fe8abeefb82abd3275bba5..10a60620f499a37700871d215e92812483eda519 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/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 0000000000000000000000000000000000000000..6776c36c766a6e967da8d0fd495bffc6fa2a25dc --- /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).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 0000000000000000000000000000000000000000..515ddcbb8755a27b907e1743710e0817a6b782a4 --- /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 0000000000000000000000000000000000000000..745b64c0d09e70878d7d0247888d8a914626017b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js @@ -0,0 +1,139 @@ +import Vue from 'vue'; +import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment'; +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', + 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, + }; + + return new Component({ + el: document.createElement('div'), + propsData: { mr }, + }); +}; + +describe('MRWidgetDeployment', () => { + describe('props', () => { + it('should have props', () => { + const { mr } = deploymentComponent.props; + + expect(mr.type instanceof Object).toBeTruthy(); + expect(mr.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', () => { + const 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('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('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); + 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('.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 0000000000000000000000000000000000000000..734b14c688e3559df774d938c76830074ba54b4d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -0,0 +1,86 @@ +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', () => { + it('shouldShowCommitsBehindText', () => { + const vm = createComponent({ divergedCommitsCount: 12 }); + expect(vm.shouldShowCommitsBehindText).toBeTruthy(); + + vm.mr.divergedCommitsCount = 0; + expect(vm.shouldShowCommitsBehindText).toBeFalsy(); + }); + + it('commitsText', () => { + const vm = createComponent({ divergedCommitsCount: 12 }); + 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', + 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_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4da4fc82c264bd88b6d9cc3b4c19887aa9c0824c --- /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 0000000000000000000000000000000000000000..0824f3250689e014f4c09b9dd387d312730a824c --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import { statusClassToSvgMap } from '~/vue_shared/pipeline_svg_icons'; +import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline'; +import pipelineMockData from '../../commit/pipelines/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: pipelineMockData }); + + expect(vm.svg).toEqual(statusClassToSvgMap.icon_status_failed); + }); + }); + + describe('hasCIError', () => { + it('should return false when there is no CI error', () => { + const vm = createComponent({ + pipeline: pipelineMockData, + hasCI: true, + ciStatus: 'success', + }); + + expect(vm.hasCIError).toBeFalsy(); + }); + + it('should return true when there is a CI error', () => { + const vm = createComponent({ + pipeline: pipelineMockData, + hasCI: true, + ciStatus: null, + }); + + expect(vm.hasCIError).toBeTruthy(); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + const mr = { + pipeline: pipelineMockData, + hasCI: true, + ciStatus: 'failed', + }; + + 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-failed').length).toEqual(1); + expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipelineMockData.id}`); + expect(el.innerText).toContain('failed'); + expect(el.querySelector('.pipeline-id').getAttribute('href')).toEqual(pipelineMockData.path); + expect(el.querySelectorAll('.stage-container').length).toEqual(1); + expect(el.querySelector('.js-ci-error')).toEqual(null); + expect(el.querySelector('.js-commit-link').getAttribute('href')).toEqual(pipelineMockData.commit.commit_path); + expect(el.querySelector('.js-commit-link').textContent).toEqual(pipelineMockData.commit.short_id); + expect(el.querySelector('.js-mr-coverage').textContent).toContain(`Coverage ${pipelineMockData.coverage}%`); + }); + + it('should list multiple stages', (done) => { + const [stage] = pipelineMockData.details.stages; + vm.mr.pipeline.details.stages.push(stage); + vm.mr.pipeline.details.stages.push(stage); + + Vue.nextTick(() => { + expect(el.querySelectorAll('.stage-container button').length).toEqual(3); + 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 0000000000000000000000000000000000000000..c8366e87871650c1cc41fb2ac6752d5a40086dff --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -0,0 +1,93 @@ +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('methods', () => { + const data = { + relatedLinks: { + closing: '#23 and #42', + mentioned: '#7', + }, + }; + 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: '#23 and #42' } }); + + expect(vm.$el.innerText).toContain('Closes issues #23 and #42'); + expect(vm.$el.innerText).not.toContain('mentioned'); + }); + + it('should have only have mentioned issues text', () => { + const vm = createComponent({ relatedLinks: { mentioned: '#7' } }); + + 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: '#7', + mentioned: '#23 and #42', + }, + }); + + expect(vm.$el.innerText).toContain('Closes issue #7.'); + expect(vm.$el.innerText).toContain('issues #23 and #42'); + expect(vm.$el.innerText).toContain('are mentioned but will not be closed.'); + }); + }); +}); 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 0000000000000000000000000000000000000000..cac2f561a0b373b0f2786c6f738a3db2e4ec9d86 --- /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_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3be11d47227fe95fe9f20c72bf8c1c7530e57b88 --- /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 0000000000000000000000000000000000000000..47303d1e80fa620dd4ccf10c72dbdbf9bea0c96d --- /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', + targetBranchPath: '/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.targetBranchPath); + 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 0000000000000000000000000000000000000000..a637c606675b5d9935b8bfd01c8faad26b72967d --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -0,0 +1,95 @@ +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, + canResolveConflicts: true, + canResolveConflictsInUI: 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('computed', () => { + it('showResolveConflictsButton', () => { + const vm = createComponent(); + expect(vm.showResolveConflictsButton).toBeTruthy(); + + vm.mr.canMerge = false; + expect(vm.showResolveConflictsButton).toBeFalsy(); + + vm.mr.canMerge = true; + vm.mr.canResolveConflicts = false; + expect(vm.showResolveConflictsButton).toBeFalsy(); + + vm.mr.canMerge = true; + vm.mr.canResolveConflicts = true; + vm.mr.canResolveConflictsInUI = false; + expect(vm.showResolveConflictsButton).toBeFalsy(); + + vm.mr.canMerge = false; + vm.mr.canResolveConflicts = false; + vm.mr.canResolveConflictsInUI = false; + expect(vm.showResolveConflictsButton).toBeFalsy(); + }); + }); + + 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_locked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a043ee888a21add053fc8da065712e477aa4c4ec --- /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').getAttribute('href')).toEqual(mr.targetBranchPath); + expect(el.querySelector('.label-branch').textContent).toContain(mr.targetBranch); + }); + }); +}); diff --git a/spec/serializers/event_entity_spec.rb b/spec/serializers/event_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb54597c96742a1d4a4019cc1e9c9db6c3620df2 --- /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 0000000000000000000000000000000000000000..4daf5a59d0ccf9498a6e49cd3ca608d50f6c8628 --- /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_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9d7baeaabf4bd50b568f7495e2c5942d69f2f7d5 --- /dev/null +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -0,0 +1,269 @@ +require 'spec_helper' + +describe MergeRequestSerializer do + let(:project) { create :empty_project } + let(:resource) { create(:merge_request, source_project: project, target_project: project) } + let(:user) { create(:user) } + + subject { described_class.new(current_user: user).represent(resource) } + + it 'includes author' do + req = double('request') + + author_payload = UserEntity + .represent(resource.author, request: req) + .as_json + + expect(subject[:author]).to eql(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 eql(pipeline_payload) + end + + it 'has important MergeRequest attributes' do + expect(subject).to include(:diff_head_sha, :merge_commit_message, + :can_be_merged, :can_be_cherry_picked, + :has_conflicts, :has_ci) + end + + it 'has merge_path' do + expect(subject[:merge_path]) + .to eql("/#{resource.project.full_path}/merge_requests/#{resource.iid}/merge") + end + + it 'has remove_wip_path' do + expect(subject[:remove_wip_path]) + .to eql("/#{resource.project.full_path}/merge_requests/#{resource.iid}/remove_wip") + end + + it 'has conflict_resolution_ui_path' do + expect(subject[:conflict_resolution_ui_path]) + .to eql("/#{resource.project.full_path}/merge_requests/#{resource.iid}/conflicts") + end + + it 'has email_patches_path' do + expect(subject[:email_patches_path]) + .to eql("/#{resource.project.full_path}/merge_requests/#{resource.iid}.patch") + end + + it 'has plain_diff_path' do + expect(subject[:plain_diff_path]) + .to eql("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff") + end + + it 'has target_branch_path' do + expect(subject[:target_branch_path]) + .to eql("/#{resource.target_project.full_path}/branches/#{resource.target_branch}") + end + + it 'has source_branch_path' do + expect(subject[:source_branch_path]) + .to eql("/#{resource.source_project.full_path}/branches/#{resource.source_branch}") + end + + it 'has merge_commit_message_with_description' do + expect(subject[:merge_commit_message_with_description]) + .to eql(resource.merge_commit_message(include_description: true)) + end + + describe 'diff_head_commit_short_id' do + context 'when no diff head commit' do + let(:project) { create :empty_project } + + it 'returns nil' do + expect(subject[:diff_head_commit_short_id]).to be_nil + end + end + + context 'when diff head commit present' do + let(:project) { create :project } + + it 'returns diff head commit short id' do + expect(subject[:diff_head_commit_short_id]).to eql(resource.diff_head_commit.short_id) + end + end + end + + describe 'ci_status' do + let(:project) { create :project } + + 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) + + expect(subject[:ci_status]).to eql(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 + expect(subject[:ci_status]).to eql('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 + expect(subject[:ci_status]).to eql('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 + expect(subject[:ci_status]).to eql('preparing') + end + end + end + end + + it 'includes merge_event' do + event = create(:event, :merged, author: user, project: resource.project, target: resource) + + event_payload = EventEntity + .represent(event) + .as_json + + expect(subject[:merge_event]).to eql(event_payload) + end + + it 'includes closed_event' do + event = create(:event, :closed, author: user, project: resource.project, target: resource) + + event_payload = EventEntity + .represent(event) + .as_json + + expect(subject[:closed_event]).to eql(event_payload) + 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 eql(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 + + context 'current_user' do + describe 'can_update_merge_request' do + context 'user can update issue' do + it 'returns true' do + resource.project.team << [user, :developer] + + expect(subject[:current_user][:can_update_merge_request]).to eql(true) + end + end + + context 'user cannot update issue' do + it 'returns false' do + expect(subject[:current_user][:can_update_merge_request]).to eql(false) + end + 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' do + let(:sentence) { subject[:issues_links][:closing] } + + it 'presents closing issues links' do + expect(sentence).to match("#{project.full_path}/issues/#{issue_a.iid}") + end + + it 'does not present related issues links' do + expect(sentence).not_to match("#{project.full_path}/issues/#{issue_b.iid}") + end + end + + describe 'mentioned_but_not_closing' do + let(:sentence) { subject[:issues_links][:mentioned_but_not_closing] } + + it 'presents related issues links' do + expect(sentence).to match("#{project.full_path}/issues/#{issue_b.iid}") + end + + it 'does not present closing issues links' do + expect(sentence).not_to match("#{project.full_path}/issues/#{issue_a.iid}") + end + end + end +end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index 93d5a21419d80c275919dcebd7fc591b6a5f3bde..d2482ac434b48ba0881f3039fc1bd95c88d140f8 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -5,7 +5,7 @@ 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 @@ 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 8642b8038448ba11d88aea434bee082887f0fd2f..10a657e07e41f1b1079385f2e4a8767de9ba44e2 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -4,7 +4,7 @@ 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 @@ end let(:serializer) do - described_class.new(user: user) + described_class.new(current_user: user) .with_pagination(request, response) end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 4ab40d08432a79e098ade6de1dc10964c26dfae3..bd7b706bb8d643b68199d218b0f294e82c2b0c04 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -14,7 +14,7 @@ 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