diff options
author | Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> | 2017-02-15 13:34:03 +0200 |
---|---|---|
committer | Bryce Johnson <bryce@gitlab.com> | 2017-03-30 13:39:34 -0400 |
commit | 6e969c53a06b949927b325e57392b6037c2db81d (patch) | |
tree | 42ee53f0683cd42551c053b310152754ed1f389a | |
parent | 8f93280e8f71b73a5e5d5a82ab1d029a74c57477 (diff) | |
download | gitlab-ce-mr-widget-redesign-tmp-squash.tar.gz |
Tmp MR Widget base for approvals.mr-widget-redesign-tmp-squash
74 files changed, 3202 insertions, 289 deletions
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 312f38ce241..e7f7299bccb 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 bfa4fc9037a..a9c41d6ca53 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/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 0e2af3df071..b0220737612 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -43,7 +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'); + this.$widgetBody = $('.mr-widget-body:eq(0)'); $('#modal_merge_info').modal({ show: false }); @@ -111,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("<h4>" + data.merge_error + "</h4>"); + return $('.mr-widget-body:eq(0)').html("<h4>" + data.merge_error + "</h4>"); } else { callback = function() { return merge_request_widget.mergeInProgress(deleteSourceBranch); @@ -130,11 +130,12 @@ 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').replaceWith($html.find('.mr-widget-footer')); + 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')); }); }; @@ -159,15 +160,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); @@ -233,8 +234,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 1d563c63f39..3317ba13fb5 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 00000000000..caa50e0474f --- /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: ` + <a class="author_link" :href="author.webUrl"> + <img :src="author.avatarUrl" class="avatar avatar-inline s16" /> + <span class="author">{{author.name}}</span> + </a> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js new file mode 100644 index 00000000000..e9c07220d96 --- /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: ` + <h4> + {{actionText}} + <mr-widget-author :author="author" /> + <time :title='dateTitle' data-toggle="tooltip" data-placement="top" data-container="body"> + {{dateReadable}} + </time> + </h4> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js new file mode 100644 index 00000000000..0a3e9e73da2 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,76 @@ +import '~/lib/utils/datetime_utility'; +import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; + +export default { + name: 'MRWidgetDeployment', + props: { + mr: { 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) { + // TODO: Handle deployment cancel when backend is implemented. + } + }, + }, + template: ` + <div class="mr-widget-heading"> + <div class="ci_widget" v-for="deployment in mr.deployments"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <span v-html="svg" aria-hidden="true"></span> + </span> + </div> + <span> + <span v-if="hasDeploymentMeta(deployment)">Deployed to</span> + <a + v-if="hasDeploymentMeta(deployment)" + :href="deployment.url" + target="_blank" rel="noopener noreferrer nofollow" class="js-deploy-meta"> + {{deployment.name}} + </a> + <span v-if="hasExternalUrls(deployment)">on</span> + <a + v-if="hasExternalUrls(deployment)" + :href="deployment.external_url" + target="_blank" rel="noopener noreferrer nofollow" class="js-deploy-url"> + {{deployment.external_url_formatted}} + </a> + <span + v-if="hasDeploymentTime(deployment)" + :data-title="deployment.deployed_at_formatted" + class="js-deploy-time" data-toggle="tooltip" data-placement="top"> + {{formatDate(deployment.deployed_at)}} + </span> + <button + v-if="deployment.stop_url" + @click="stopEnvironment(deployment)" + class="btn btn-default btn-xs" type="button"> + Stop environment + </button> + </span> + </div> + </div> + `, +}; + diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js new file mode 100644 index 00000000000..de81f5377a0 --- /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: ` + <div class="mr-source-target"> + <div class="pull-right" v-if="mr.isOpen"> + <a href="#modal_merge_info" data-toggle="modal" class="btn inline btn-grouped btn-sm">Check out branch</a> + <span class="dropdown inline prepend-left-5"> + <a class="btn btn-sm dropdown-toggle" data-toggle="dropdown"> + Download as <i class="fa fa-caret-down" aria-hidden="true"></i> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li> + <a :href="mr.emailPatchesPath">Email patches</a> + </li> + <li> + <a :href="mr.plainDiffPath">Plain diff</a> + </li> + </ul> + </span> + </div> + <div class="normal"> + <span>Request to merge</span> + <span class="label-branch">{{mr.sourceBranch}}</span> + <span>into</span> + <span class="label-branch"> + <a href="#">{{mr.targetBranch}}</a> + </span> + <span + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count"> + ({{mr.divergedCommitsCount}} {{commitsText}} behind) + </span> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js new file mode 100644 index 00000000000..4d4bed58e83 --- /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: ` + <section class="mr-widget-help"> + <template v-if="missingBranch">If the {{missingBranch}} branch exists in your local repository, you</template> + <template v-else>You</template> + can merge this merge request manually using the + <a data-toggle="modal" href="#modal_merge_info">command line</a> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js new file mode 100644 index 00000000000..10fbde56d72 --- /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: ` + <div class="mr-widget-heading"> + <div class="ci_widget"> + <template v-if="hasCIError"> + <div class="ci-status-icon ci-status-icon-failed js-ci-error"> + <span class="js-icon-link icon-link"> + <span v-html="svg" aria-hidden="true"></span> + </span> + </div> + <span>Could not connect to the CI server. Please check your settings and try again.</span> + </template> + <template v-else> + <pipeline-status-icon :pipelineStatus="mr.pipeline.details.status" /> + <span> + Pipeline + <a + :href="mr.pipeline.path" + class="pipeline-id">#{{mr.pipeline.id}}</a> + {{mr.pipeline.details.status.label}} + </span> + <div class="mr-widget-pipeline-graph"> + <div class="stage-cell"> + <div class="stage-container dropdown js-mini-pipeline-graph" + v-if="mr.pipeline.details.stages.length > 0" + v-for="stage in mr.pipeline.details.stages"> + <pipeline-stage :stage="stage" /> + </div> + </div> + </div> + <span> + for + <a class="monospace js-commit-link" + :href="mr.pipeline.commit.commit_path">{{mr.pipeline.commit.short_id}}</a>. + </span> + <span + v-if="mr.pipeline.coverage" + class="js-mr-coverage"> + Coverage {{mr.pipeline.coverage}}% + </span> + </template> + </div> + </div> + `, +}; + diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js new file mode 100644 index 00000000000..f1f42d6ad07 --- /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 <a/); + }, + issueLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue'; + }, + verbLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is'; + }, + }, + template: ` + <section class="mr-info-list mr-links" v-if="hasLinks"> + <div class="legend"></div> + <p v-if="relatedLinks.closing"> + Closes {{issueLabel('closing')}} <span v-html="relatedLinks.closing"></span>. + </p> + <p v-if="relatedLinks.mentioned"> + <span class="capitalize">{{issueLabel('mentioned')}}</span> + <span v-html="relatedLinks.mentioned"></span> + {{verbLabel('mentioned')}} mentioned but will not be closed. + </p> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js new file mode 100644 index 00000000000..9c680bc9845 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold">This project is archived, write access has been disabled.</span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js new file mode 100644 index 00000000000..69aedee4fc1 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + Checking ability to merge automatically. + <i class="fa fa-spinner fa-spin" aria-hidden="true"></i> + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js new file mode 100644 index 00000000000..58633e1bd76 --- /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: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Closed by" + :author="mr.closedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.closedAt" + /> + <section> + <p>The changes were not merged into + <a :href="mr.targetBranchPath" class="label-branch"> + {{mr.targetBranch}} + </a>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js new file mode 100644 index 00000000000..9f1aad06491 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + There are merge conflicts. + <span v-if="!mr.canMerge">Resolve these conflicts or ask someone with write access to this repository to merge it locally.</span> + </span> + <div class="btn-group"> + <a + :href="mr.conflictResolutionPath" + v-if="showResolveConflictsButton" + class="btn btn-default btn-xs js-resolve-conflicts-button" + >Resolve conflicts</a> + <a + v-if="mr.canMerge" + class="btn btn-default btn-xs js-merge-locally-button" + data-toggle="modal" + href="#modal_merge_info" + >Merge locally</a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js new file mode 100644 index 00000000000..d7b4284ce4b --- /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: ` + <div class="mr-widget-body"> + <span class="bold">Locked</span> This merge request is in the process of being merged, during which time it is locked and cannot be closed. + <i class="fa fa-spinner fa-spin" aria-hidden="true"></i> + <section> + <p>The changes will be merged into + <a :href="mr.targetBranchPath" class="label-branch"> + {{mr.targetBranch}} + </a> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js new file mode 100644 index 00000000000..0ad4403e928 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,84 @@ +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; + this.service.cancelAutomaticMerge() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); // TODO: Should find a better way to update store. + }); + // TODO: Handle catch here. + }, + 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: ` + <div class="mr-widget-body"> + <h4> + Set by + <mr-widget-author :author="mr.setToMWPSBy" /> + to be merged automatically when the pipeline succeeds. + <button + v-if="mr.canCancelAutomaticMerge" + @click="cancelAutomaticMerge" + :disabled="isCancellingAutoMerge" + type="button" class="btn btn-xs btn-default"> + <i + v-if="isCancellingAutoMerge" + class="fa fa-spinner fa-spin" aria-hidden="true"></i> + Cancel automatic merge</button> + </h4> + <section> + <p>The changes will be merged into + <a :href="mr.targetBranchPath" class="label-branch"> + {{mr.targetBranch}} + </a> + </p> + <p v-if="mr.shouldRemoveSourceBranch">The source branch will be removed.</p> + <p v-else> + The source branch will not be removed. + <button + v-if="canRemoveSourceBranch" + @click="removeSourceBranch" + type="button" class="btn btn-xs btn-default"> + <i + v-if="isRemovingSourceBranch" + class="fa fa-spinner fa-spin" aria-hidden="true"></i> + Remove source branch</button> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js new file mode 100644 index 00000000000..617e4a0bd0f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,71 @@ +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; + +export default { + name: 'MRWidgetMerged', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + data() { + return { + isRemovingSourceBranch: false, + }; + }, + methods: { + removeSourceBranch() { + this.isRemovingSourceBranch = true; + this.service.removeSourceBranch() + .then(res => res.json()); // TODO: Update widget, handle error + }, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Merged by" + :author="mr.mergedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.mergedAt" + /> + <section class="mr-info-list"> + <div class="legend"></div> + <p> + The changes were merged into + <a :href="mr.targetBranchPath" class="label-branch"> + {{mr.targetBranch}} + </a> + </p> + <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p> + <p v-if="mr.canRemoveSourceBranch"> + You can remove source branch now. + <button + @click="removeSourceBranch" + :class="{ disabled: isRemovingSourceBranch }" + type="button" class="btn btn-xs btn-default">Remove Source Branch</button> + </p> + <p v-if="isRemovingSourceBranch"> + The source branch is being removed. + <i class="fa fa-spinner fa-spin" aria-hidden="true"></i> + </p> + </section> + <div class="merged-buttons clearfix"> + <a + v-if="mr.canRevert" + class="btn btn-close btn-sm has-tooltip" + href="#modal-revert-commit" + data-toggle="modal" + data-container="body" + data-original-title="Revert this merge request in a new merge request">Revert</a> + <a + v-if="mr.canBeCherryPicked" + class="btn btn-default btn-sm has-tooltip" + href="#modal-cherry-pick-commit" + data-toggle="modal" + data-container="body" + data-original-title="Cherry-pick this merge request in a new merge request">Cherry-pick</a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js new file mode 100644 index 00000000000..c44783583a8 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + <span class="capitalize">{{missingBranchName}}</span> branch does not exist. + Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch. + </span> + <mr-widget-merge-help :missing-branch="missingBranchName" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js new file mode 100644 index 00000000000..550624cb1a2 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + Ready to be merged automatically. + Ask someone with write access to this repository to merge this request. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js new file mode 100644 index 00000000000..643a9f5d4da --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + There is nothing to merge from source branch into target branch. + Please push new commits or use a different branch. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js new file mode 100644 index 00000000000..ee26268e2a7 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold">Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.</span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js new file mode 100644 index 00000000000..b5bf9593f58 --- /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: ` + <div class="mr-widget-body"> + <button class="btn btn-success btn-small" disabled="true" type="button">Merge</button> + <span class="bold">The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.</span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js new file mode 100644 index 00000000000..05b8d832728 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,152 @@ +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, + 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.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(); + }, + }, + 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) { + if (mergeWhenBuildSucceeds === undefined) { + mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign + } + + 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, + }; + + // TODO: Response handling and widget update + this.service.merge(options); + }, + }, + template: ` + <div class="mr-widget-body"> + <span class="btn-group"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button">{{mergeButtonText}}</button> + <button + v-if="shouldShowMergeOptionsDropdown" + type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown"> + <i class="fa fa-caret-down" aria-hidden="true"></i> + <span class="sr-only">Select Merge Moment</span> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" href="#"> + <i class="fa fa-check fa-fw" aria-hidden="true"></i> Merge when pipeline succeeds + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false)" + class="accept-merge-request" href="#"> + <i class="fa fa-warning fa-fw" aria-hidden="true"></i> Merge immediately + </a> + </li> + </ul> + </span> + <template v-if="isMergeAllowed()"> + <label class="spacing"> + <input type="checkbox" v-model="removeSourceBranch" /> Remove source branch + </label> + <a + @click.prevent="toggleCommitMessageEditor" + class="btn btn-default btn-xs" href="#">Modify commit message</a> + <div class="prepend-top-default commit-message-editor" v-if="showCommitMessageEditor"> + <div class="form-group clearfix"> + <label class="control-label" for="commit-message">Commit message</label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + v-model="commitMessage" + class="form-control js-commit-message" required="required" rows="14"></textarea> + </div> + <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p> + <div class="hint"> + <a @click.prevent="updateCommitMessage" href="#">{{commitMessageLinkTitle}}</a> + </div> + </div> + </div> + </div> + </template> + <template v-else> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js new file mode 100644 index 00000000000..711f568df73 --- /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: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + <span v-if="mr.canCreateIssue">or</span> + <span v-else>.</span> + </span> + <a + v-if="mr.canCreateIssue" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs">Create an issue to resolve them later</a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js new file mode 100644 index 00000000000..41fa7e6cbe0 --- /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() { + 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); + }); + // TODO: Catch error state + }, + }, + template: ` + <div class="mr-widget-body"> + <button type="button" class="btn btn-success btn-small" disabled="true">Merge</button> + <span class="bold">This merge request is currently Work In Progress and therefore unable to merge</span> + <template v-if="mr.canUpdateMergeRequest"> + <i class="fa fa-question-circle has-tooltip" title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged."></i> + <button + @click="removeWIP" + type="button" class="btn btn-default btn-xs">Resolve WIP status</button> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js new file mode 100644 index 00000000000..f4191e2e454 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -0,0 +1,116 @@ +import Vue from 'vue'; +import WidgetHeader from './components/mr_widget_header'; +import WidgetMergeHelp from './components/mr_widget_merge_help'; +import WidgetPipeline from './components/mr_widget_pipeline'; +import WidgetDeployment from './components/mr_widget_deployment'; +import WidgetRelatedLinks from './components/mr_widget_related_links'; +import MergedState from './components/states/mr_widget_merged'; +import ClosedState from './components/states/mr_widget_closed'; +import LockedState from './components/states/mr_widget_locked'; +import WipState from './components/states/mr_widget_wip'; +import ArchivedState from './components/states/mr_widget_archived'; +import ConflictsState from './components/states/mr_widget_conflicts'; +import NothingToMergeState from './components/states/mr_widget_nothing_to_merge'; +import MissingBranchState from './components/states/mr_widget_missing_branch'; +import NotAllowedState from './components/states/mr_widget_not_allowed'; +import ReadyToMergeState from './components/states/mr_widget_ready_to_merge'; +import UnresolvedDiscussionsState from './components/states/mr_widget_unresolved_discussions'; +import PipelineBlockedState from './components/states/mr_widget_pipeline_blocked'; +import PipelineFailedState from './components/states/mr_widget_pipeline_failed'; +import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds'; +import CheckingState from './components/states/mr_widget_checking'; +import MRWidgetStore from './stores/mr_widget_store'; +import MRWidgetService from './services/mr_widget_service'; +import { stateToComponentMap, statesToShowHelpWidget } from './stores/state_maps'; + +const mrWidgetOptions = () => ({ + 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 this.mr.pipeline || this.mr.hasCI; + }, + shouldRenderRelatedLinks() { + return this.mr.relatedLinks; + }, + shouldRenderDeployments() { + return this.mr.deployments.length; + }, + }, + methods: { + checkStatus() { + this.service.checkStatus() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); + }); + }, + }, + mounted() { + this.service.fetchDeployments() + .then(res => res.json()) + .then((res) => { + if (res.length) { + this.mr.deployments = res; + } + }); + + if (this.mr.state === 'checking') { + this.checkStatus(); + } + }, + 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-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: ` + <div class="mr-state-widget prepend-top-default"> + <mr-widget-header :mr="mr" /> + <mr-widget-pipeline v-if="shouldRenderPipelines" :mr="mr" /> + <mr-widget-deployment v-if="shouldRenderDeployments" :mr="mr" /> + <component :is="componentName" :mr="mr" :service="service" /> + <mr-widget-related-links v-if="shouldRenderRelatedLinks" :related-links="mr.relatedLinks" /> + <mr-widget-merge-help v-if="shouldRenderMergeHelp" /> + </div> + `, +}); + +document.addEventListener('DOMContentLoaded', () => { + const vm = new Vue(mrWidgetOptions()); + + window.gl.mrWidget = { + checkStatus: vm.checkStatus, + }; +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js new file mode 100644 index 00000000000..efc483d3f76 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,41 @@ +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.deploymentsResource = Vue.resource(mr.ciEnvironmentsStatusPath); + } + + 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(); + } + + checkStatus() { + return this.mergeCheckResource.get(); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js new file mode 100644 index 00000000000..4fd8d198bdb --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,149 @@ +import Timeago from 'timeago.js'; + +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.mergeCheckPath = data.merge_check_path; + + 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) { + if (data.project_archived) { + this.state = 'archived'; + } else if (data.branch_missing) { + this.state = 'missingBranch'; + } else if (data.has_no_commits) { + this.state = 'nothingToMerge'; + } else if (this.mergeStatus === 'unchecked') { + this.state = 'checking'; + } else if (data.has_conflicts) { + this.state = 'conflicts'; + } else if (data.work_in_progress) { + this.state = 'workInProgress'; + } else if (this.mergeWhenPipelineSucceeds) { + this.state = 'mergeWhenPipelineSucceeds'; + } else if (!this.canMerge) { + this.state = 'notAllowedToMerge'; + } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { + this.state = 'pipelineFailed'; + } else if (this.hasMergeableDiscussionsState) { + this.state = 'unresolvedDiscussions'; + } else if (this.isPipelineBlocked) { + this.state = 'pipelineBlocked'; + } else if (this.canBeMerged) { + this.state = 'readyToMerge'; + } + } else { + switch (data.state) { + case 'merged': + this.state = 'merged'; + break; + case 'closed': + this.state = 'closed'; + break; + case 'locked': + this.state = 'locked'; + break; + default: + this.state = null; + } + } + } + + static getAuthorObject(event) { + if (!event) { + return {}; + } + + return { + name: event.author.name || '', + username: event.author.username || '', + webUrl: event.author.web_url || '', + avatarUrl: event.author.avatar_url || '', + }; + } + + static getEventDate(event) { + const timeagoInstance = new Timeago(); + + if (!event) { + return ''; + } + + return timeagoInstance.format(event.updated_at); + } + +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js new file mode 100644 index 00000000000..5f7c6984c4d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,28 @@ +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', +}; + +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 a2c29002707..df15961a926 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/stage.js +++ b/app/assets/javascripts/vue_pipelines_index/components/stage.js @@ -1,32 +1,12 @@ /* 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: '<span class="fa fa-spinner fa-spin"></span>', - svg: svgsDictionary[this.stage.status.icon], + svg: statusClassToBorderlessSvgMap[this.stage.status.icon], }; }, diff --git a/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js new file mode 100644 index 00000000000..ae246ada01b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js @@ -0,0 +1,23 @@ +import { statusClassToSvgMap } from '../pipeline_svg_icons'; + +export default { + name: 'PipelineStatusIcon', + props: { + pipelineStatus: { type: Object, required: true, default: () => ({}) }, + }, + computed: { + svg() { + return statusClassToSvgMap[this.pipelineStatus.icon]; + }, + statusClass() { + return `ci-status-icon ci-status-icon-${this.pipelineStatus.group}`; + }, + }, + template: ` + <div :class="statusClass"> + <a class="icon-link" :href="pipelineStatus.details_path"> + <span v-html="svg" aria-hidden="true"></span> + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/pipeline_svg_icons.js b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js new file mode 100644 index 00000000000..5af30ae74f0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js @@ -0,0 +1,43 @@ +import canceledSvg from 'icons/_icon_status_canceled.svg'; +import createdSvg from 'icons/_icon_status_created.svg'; +import failedSvg from 'icons/_icon_status_failed.svg'; +import manualSvg from 'icons/_icon_status_manual.svg'; +import pendingSvg from 'icons/_icon_status_pending.svg'; +import runningSvg from 'icons/_icon_status_running.svg'; +import skippedSvg from 'icons/_icon_status_skipped.svg'; +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; + +import canceledBorderlessSvg from 'icons/_icon_status_canceled_borderless.svg'; +import createdBorderlessSvg from 'icons/_icon_status_created_borderless.svg'; +import failedBorderlessSvg from 'icons/_icon_status_failed_borderless.svg'; +import manualBorderlessSvg from 'icons/_icon_status_manual_borderless.svg'; +import pendingBorderlessSvg from 'icons/_icon_status_pending_borderless.svg'; +import runningBorderlessSvg from 'icons/_icon_status_running_borderless.svg'; +import skippedBorderlessSvg from 'icons/_icon_status_skipped_borderless.svg'; +import successBorderlessSvg from 'icons/_icon_status_success_borderless.svg'; +import warningBorderlessSvg from 'icons/_icon_status_warning_borderless.svg'; + +export const statusClassToSvgMap = { + icon_status_canceled: canceledSvg, + icon_status_created: createdSvg, + icon_status_failed: failedSvg, + icon_status_manual: manualSvg, + icon_status_pending: pendingSvg, + icon_status_running: runningSvg, + icon_status_skipped: skippedSvg, + icon_status_success: successSvg, + icon_status_warning: warningSvg, +}; + +export const statusClassToBorderlessSvgMap = { + icon_status_canceled: canceledBorderlessSvg, + icon_status_created: createdBorderlessSvg, + icon_status_failed: failedBorderlessSvg, + icon_status_manual: manualBorderlessSvg, + icon_status_pending: pendingBorderlessSvg, + icon_status_running: runningBorderlessSvg, + icon_status_skipped: skippedBorderlessSvg, + icon_status_success: successBorderlessSvg, + icon_status_warning: warningBorderlessSvg, +}; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab8..1b7d4e42258 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,4 +1,5 @@ -.ci-status-icon-success { +.ci-status-icon-success, +.ci-status-icon-passed { color: $green-500; svg { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 566dcc64802..1266da83746 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,29 @@ margin-right: 7px; } + label { + font-weight: normal; + } + + .spacing { + margin: 0 $gl-padding; + } + + .bold { + font-weight: bold; + color: #5c5c5c; + } + + .mr-widget-help { + margin: $gl-padding 0; + } + + .dropdown-toggle { + .fa { + color: inherit; + } + } + @media (max-width: $screen-xs-max) { h4 { font-size: 14px; @@ -220,6 +249,12 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } } .mr-widget-footer { @@ -345,61 +380,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; + padding-left: 15px; - span { - margin-left: 15px; - max-height: 20px; - } - } - - li::before { - content: ''; - position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; - } - - 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 6a6e335d314..65d129ea8e5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -98,7 +98,10 @@ class ApplicationController < ActionController::Base 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 840405f38cb..d78b71c783f 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -71,7 +71,9 @@ class Projects::BranchesController < Projects::ApplicationController 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 cc67f688d51..a6d442004ea 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -36,7 +36,7 @@ class Projects::CommitController < Projects::ApplicationController 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/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 9621b30b251..b6503af42ff 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -74,10 +74,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController def show respond_to do |format| - format.html { define_discussion_vars } + format.html do + define_discussion_vars + end format.json do - render json: MergeRequestSerializer.new.represent(@merge_request) + render json: @merge_request_json end format.patch do @@ -233,7 +235,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController format.json do render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -247,7 +249,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -316,17 +318,37 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def remove_wip - MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) + @merge_request = MergeRequests::UpdateService + .new(project, current_user, wip_event: 'unwip') + .execute(@merge_request) - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + # 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 + + 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 @@ -337,48 +359,62 @@ class Projects::MergeRequestsController < Projects::ApplicationController 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) + @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 - 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) 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 @@ -445,6 +481,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + # TODO: @oswaldo - remove it when deleting old widget parts def ci_status pipeline = @merge_request.head_pipeline @pipelines = @merge_request.all_pipelines @@ -560,6 +597,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController labels define_pipelines_vars + + @merge_request_json = serializer.represent(@merge_request).to_json end # Discussion tab data is rendered on html responses of actions @@ -708,4 +747,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController @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 43a1abaa662..7b853a5d874 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -31,7 +31,7 @@ class Projects::PipelinesController < Projects::ApplicationController 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 ec57fec4f99..d5b7dadb5e7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,7 @@ module IssuablesHelper 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 38be073c8dc..dfd0a4a6c96 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -55,6 +55,7 @@ module MergeRequestsHelper 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 @@ module MergeRequestsHelper 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 5ff83944d8c..aeeb14fab92 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -443,7 +443,8 @@ class MergeRequest < ActiveRecord::Base 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 @@ -891,6 +892,8 @@ class MergeRequest < ActiveRecord::Base end def conflicts_can_be_resolved_by?(user) + return false unless source_project + access = ::Gitlab::UserAccess.new(user, project: source_project) access.can_push_to_branch?(source_branch) end diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 00000000000..935d67a4f37 --- /dev/null +++ b/app/serializers/event_entity.rb @@ -0,0 +1,4 @@ +class EventEntity < Grape::Entity + expose :author, using: UserEntity + expose :updated_at +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 00000000000..89f2af395b9 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,3 @@ +class MergeRequestBasicEntity < Grape::Entity + expose :merge_status +end diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 00000000000..ad880270d50 --- /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 5f80ab397a9..a184c215b2d 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,7 @@ class MergeRequestEntity < IssuableEntity + include RequestAwareEntity + include GitlabMarkdownHelper + expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha @@ -11,4 +14,215 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # Events + expose :merge_event, using: EventEntity + expose :closed_event, using: EventEntity + + # User entities + expose :author, using: UserEntity + expose :merge_user, using: UserEntity + + # Diff sha's + expose :diff_head_sha + 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 + + # 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 3f16dd66d54..27d37466d82 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/stage_entity.rb b/app/serializers/stage_entity.rb index 7a047bdc712..157ab1776a7 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 eab5be488b5..1f803f4001a 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 e632fc681cf..3d63cd26c19 100644 --- a/app/views/projects/merge_requests/merge.js.haml +++ b/app/views/projects/merge_requests/merge.js.haml @@ -5,10 +5,10 @@ merge_request_widget.mergeInProgress(#{remove_source_branch}); - when :merge_when_pipeline_succeeds :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); + $('.mr-widget-body:eq(0)').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); - when :sha_mismatch :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); + $('.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 0b0fb7854c2..ce6759945b8 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -8,6 +8,9 @@ = 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)}", check_enable: #{@merge_request.unchecked? ? "true" : "false"}, @@ -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/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 03309722326..d23f79be2be 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,12 +5,3 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) - -.form-group - .col-sm-10.col-sm-offset-2 - - if issuable.can_remove_source_branch?(current_user) - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. diff --git a/config/webpack.config.js b/config/webpack.config.js index 70d98b022c1..1f4250014f6 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -45,6 +45,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 d20e7368086..23f55f05d15 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -177,33 +177,98 @@ describe Projects::BranchesController do sign_in(user) post :destroy, - format: :js, - id: branch, - namespace_id: project.namespace, - project_id: project + format: format, + id: branch, + namespace_id: project.namespace, + project_id: project end - context "valid branch name, valid source" do + context 'as JS' do let(:branch) { "feature" } + let(:format) { :js } - it { expect(response).to have_http_status(200) } - end + context "valid branch name, valid source" do + let(:branch) { "feature" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with unencoded slashes" do + let(:branch) { "improve/awesome" } + + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end + + context "valid branch name with encoded slashes" do + let(:branch) { "improve%2Fawesome" } - context "valid branch name with unencoded slashes" do - let(:branch) { "improve/awesome" } + it { expect(response).to have_http_status(200) } + it { expect(response.body).to be_blank } + end - it { expect(response).to have_http_status(200) } + context "invalid branch name, valid ref" do + let(:branch) { "no-branch" } + + it { expect(response).to have_http_status(404) } + it { expect(response.body).to be_blank } + end end - context "valid branch name with encoded slashes" do - let(:branch) { "improve%2Fawesome" } + context 'as JSON' do + let(:branch) { "feature" } + let(:format) { :json } + + context 'valid branch name, valid source' do + let(:branch) { "feature" } - it { expect(response).to have_http_status(200) } + it 'returns JSON response with message' do + expect(json_response).to eql("message" => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'valid branch name with unencoded slashes' do + let(:branch) { "improve/awesome" } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context "valid branch name with encoded slashes" do + let(:branch) { 'improve%2Fawesome' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'Branch was removed') + end + + it { expect(response).to have_http_status(200) } + end + + context 'invalid branch name, valid ref' do + let(:branch) { 'no-branch' } + + it 'returns JSON response with message' do + expect(json_response).to eql('message' => 'No such branch') + end + + it { expect(response).to have_http_status(404) } + end end - context "invalid branch name, valid ref" do - let(:branch) { "no-branch" } - it { expect(response).to have_http_status(404) } + context 'as HTML' do + let(:branch) { "feature" } + let(:format) { :html } + + it 'redirects to branches path' do + expect(response) + .to redirect_to(namespace_project_branches_path(project.namespace, project)) + end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 72f41f7209a..a9d5705ba2a 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -73,63 +73,51 @@ describe Projects::MergeRequestsController do end describe "GET show" do - shared_examples "export merge as" do |format| - it "does generally work" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + def go(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid + } - expect(response).to be_success - end + get :show, params.merge(extra_params) + end - it_behaves_like "loads labels", :show + it_behaves_like "loads labels", :show - it "generates it" do - expect_any_instance_of(MergeRequest).to receive(:"to_#{format}") + describe 'as html' do + it "renders merge request page" do + go(format: :html) - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + expect(response).to be_success end + end - it "renders it" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: format) + describe 'as json' do + context 'with basic param' do + it 'renders basic MR entity as json' do + go(basic: true, format: :json) - expect(response.body).to eq(merge_request.send(:"to_#{format}").to_s) + expect(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 @@ -137,11 +125,7 @@ describe Projects::MergeRequestsController do describe "as patch" do it 'triggers workhorse to serve the request' do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :patch) + go(format: :patch) expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-format-patch:") end @@ -285,88 +269,229 @@ describe Projects::MergeRequestsController do namespace_id: project.namespace, project_id: project, id: merge_request.iid, - 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 'returns :sha_mismatch' do - expect(assigns(:status)).to eq(:sha_mismatch) + 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 + + expect(json_response).to eq('status' => 'success') + end + end + end + end 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 + # TODO: Delete when removing old widget parts + context 'as any other format' do + let(:format) { 'js' } - it 'returns :success' do - merge_with_sha + context 'when the user does not have access' do + before do + project.team.truncate + project.team << [user, :reporter] + post :merge, base_params + end - expect(assigns(:status)).to eq(:success) + it 'returns not found' do + expect(response).to be_not_found + end end - it 'starts the merge immediately' do - expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, anything) + 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 - merge_with_sha + it 'returns :failed' do + expect(assigns(:status)).to eq(:failed) + 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 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 - before do - create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + 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 :merge_when_pipeline_succeeds' do - merge_when_pipeline_succeeds + it 'returns :success' do + merge_with_sha - expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) + expect(assigns(:status)).to eq(:success) 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) + 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 @@ -374,70 +499,93 @@ describe Projects::MergeRequestsController do 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 - expect(assigns(:status)).to eq(:success) + it 'returns :failed' do + merge_with_sha + + 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 @@ -822,16 +970,102 @@ describe Projects::MergeRequestsController do 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 - expect(merge_request.reload.title).to eq(merge_request.wipless_title) + 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(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 82b00b4c1ec..10a60620f49 100644 --- a/spec/javascripts/commit/pipelines/mock_data.js +++ b/spec/javascripts/commit/pipelines/mock_data.js @@ -61,6 +61,7 @@ export default { tag: false, branch: true, }, + coverage: '42.21', commit: { id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4', short_id: 'fbd79f04', diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js new file mode 100644 index 00000000000..6776c36c766 --- /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 00000000000..515ddcbb875 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time'; + +const props = { + actionText: 'Merged by', + author: { + webUrl: 'http://foo.bar', + avatarUrl: 'http://gravatar.com/foo', + name: 'fatihacet', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', +}; +const createComponent = () => { + const Component = Vue.extend(authorTimeComponent); + + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetAuthorTime', () => { + describe('props', () => { + it('should have props', () => { + const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props; + const ActionTextClass = actionText.type; + const DateTitleClass = dateTitle.type; + const DateReadableClass = dateReadable.type; + + expect(new ActionTextClass() instanceof String).toBeTruthy(); + expect(actionText.required).toBeTruthy(); + + expect(author.type instanceof Object).toBeTruthy(); + expect(author.required).toBeTruthy(); + + expect(new DateTitleClass() instanceof String).toBeTruthy(); + expect(dateTitle.required).toBeTruthy(); + + expect(new DateReadableClass() instanceof String).toBeTruthy(); + expect(dateReadable.required).toBeTruthy(); + }); + }); + + describe('components', () => { + it('should have components', () => { + expect(authorTimeComponent.components['mr-widget-author']).toBeDefined(); + }); + }); + + describe('template', () => { + it('should have correct elements', () => { + const el = createComponent().$el; + + expect(el.tagName).toEqual('H4'); + expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl); + expect(el.querySelector('time').innerText).toContain(props.dateReadable); + expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js new file mode 100644 index 00000000000..745b64c0d09 --- /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 00000000000..734b14c688e --- /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 00000000000..4da4fc82c26 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help'; + +const props = { + missingBranch: 'this-is-not-the-branch-you-are-looking-for', +}; +const text = `If the ${props.missingBranch} branch exists in your local repository`; + +const createComponent = () => { + const Component = Vue.extend(mergeHelpComponent); + return new Component({ + el: document.createElement('div'), + propsData: props, + }); +}; + +describe('MRWidgetMergeHelp', () => { + describe('props', () => { + it('should have props', () => { + const { missingBranch } = mergeHelpComponent.props; + const MissingBranchTypeClass = missingBranch.type; + + expect(new MissingBranchTypeClass() instanceof String).toBeTruthy(); + expect(missingBranch.required).toBeFalsy(); + expect(missingBranch.default).toEqual(''); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have the correct elements', () => { + expect(el.classList.contains('mr-widget-help')).toBeTruthy(); + expect(el.textContent).toContain(text); + }); + + it('should not show missing branch name if missingBranch props is not provided', (done) => { + vm.missingBranch = null; + Vue.nextTick(() => { + expect(el.textContent).not.toContain(text); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js new file mode 100644 index 00000000000..0824f325068 --- /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 00000000000..c8366e87871 --- /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: '<a href="#">#23</a> and <a>#42</a>', + mentioned: '<a href="#">#7</a>', + }, + }; + const vm = createComponent(data); + + describe('hasMultipleIssues', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.closing)).toBeTruthy(); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.hasMultipleIssues(data.relatedLinks.mentioned)).toBeFalsy(); + }); + }); + + describe('issueLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.issueLabel('closing')).toEqual('issues'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.issueLabel('mentioned')).toEqual('issue'); + }); + }); + + describe('verbLabel', () => { + it('should return true if the given text has multiple issues', () => { + expect(vm.verbLabel('closing')).toEqual('are'); + }); + + it('should return false if the given text has one issue', () => { + expect(vm.verbLabel('mentioned')).toEqual('is'); + }); + }); + }); + + describe('template', () => { + it('should have only have closing issues text', () => { + const vm = createComponent({ relatedLinks: { closing: '<a href="#">#23</a> and <a>#42</a>' } }); + + 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: '<a href="#">#7</a>' } }); + + expect(vm.$el.innerText).toContain('issue #7'); + expect(vm.$el.innerText).toContain('is mentioned but will not be closed.'); + expect(vm.$el.innerText).not.toContain('Closes'); + }); + + it('should have closing and mentioned issues at the same time', () => { + const vm = createComponent({ + relatedLinks: { + closing: '<a href="#">#7</a>', + mentioned: '<a href="#">#23</a> and <a>#42</a>', + }, + }); + + 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 00000000000..cac2f561a0b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived'; + +describe('MRWidgetArchived', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(archivedComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('This project is archived, write access has been disabled.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js new file mode 100644 index 00000000000..3be11d47227 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking'; + +describe('MRWidgetChecking', () => { + describe('template', () => { + it('should have correct elements', () => { + const Component = Vue.extend(checkingComponent); + const el = new Component({ + el: document.createElement('div'), + }).$el; + + expect(el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(el.querySelector('button').classList.contains('btn-success')).toBeTruthy(); + expect(el.querySelector('button').disabled).toBeTruthy(); + expect(el.innerText).toContain('Checking ability to merge automatically.'); + expect(el.querySelector('i')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js new file mode 100644 index 00000000000..47303d1e80f --- /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 00000000000..a637c606675 --- /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 00000000000..a043ee888a2 --- /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 00000000000..bb54597c967 --- /dev/null +++ b/spec/serializers/event_entity_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe EventEntity do + subject { described_class.represent(create(:event)).as_json } + + it 'exposes author' do + expect(subject).to include(:author) + end + + it 'exposes core elements of event' do + expect(subject).to include(:updated_at) + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb new file mode 100644 index 00000000000..4daf5a59d0c --- /dev/null +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe MergeRequestBasicSerializer do + let(:resource) { create(:merge_request) } + let(:user) { create(:user) } + + subject { described_class.new.represent(resource) } + + it 'has important MergeRequest attributes' do + expect(subject).to include(:merge_status) + end +end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb new file mode 100644 index 00000000000..9d7baeaabf4 --- /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 93d5a21419d..d2482ac434b 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -5,7 +5,7 @@ describe PipelineEntity do let(:request) { double('request') } before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) end let(:entity) do @@ -19,7 +19,7 @@ describe PipelineEntity do let(:pipeline) { create(:ci_empty_pipeline) } it 'contains required fields' do - expect(subject).to include :id, :user, :path + expect(subject).to include :id, :user, :path, :coverage expect(subject).to include :ref, :commit expect(subject).to include :updated_at, :created_at end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 4ab40d08432..bd7b706bb8d 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -14,7 +14,7 @@ describe StageEntity do end before do - allow(request).to receive(:user).and_return(user) + allow(request).to receive(:current_user).and_return(user) create(:ci_build, :success, pipeline: pipeline) end |