summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2017-02-15 13:34:03 +0200
committerBryce Johnson <bryce@gitlab.com>2017-03-30 13:39:34 -0400
commit6e969c53a06b949927b325e57392b6037c2db81d (patch)
tree42ee53f0683cd42551c053b310152754ed1f389a
parent8f93280e8f71b73a5e5d5a82ab1d029a74c57477 (diff)
downloadgitlab-ce-mr-widget-redesign-tmp-squash.tar.gz
Tmp MR Widget base for approvals.mr-widget-redesign-tmp-squash
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js1
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js1
-rw-r--r--app/assets/javascripts/merge_request_widget.js23
-rw-r--r--app/assets/javascripts/notes.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js76
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js49
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js68
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js35
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js84
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js71
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js152
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js149
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js28
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/stage.js24
-rw-r--r--app/assets/javascripts/vue_shared/components/pipeline_status_icon.js23
-rw-r--r--app/assets/javascripts/vue_shared/pipeline_svg_icons.js43
-rw-r--r--app/assets/stylesheets/framework/icons.scss3
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss130
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/projects/branches_controller.rb2
-rw-r--r--app/controllers/projects/commit_controller.rb2
-rwxr-xr-xapp/controllers/projects/merge_requests_controller.rb91
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb3
-rw-r--r--app/models/merge_request.rb5
-rw-r--r--app/serializers/event_entity.rb4
-rw-r--r--app/serializers/merge_request_basic_entity.rb3
-rw-r--r--app/serializers/merge_request_basic_serializer.rb4
-rw-r--r--app/serializers/merge_request_entity.rb214
-rw-r--r--app/serializers/pipeline_entity.rb8
-rw-r--r--app/serializers/stage_entity.rb2
-rw-r--r--app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml2
-rw-r--r--app/views/projects/merge_requests/merge.js.haml6
-rw-r--r--app/views/projects/merge_requests/widget/_show.html.haml8
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml9
-rw-r--r--config/webpack.config.js1
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb97
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb512
-rw-r--r--spec/javascripts/commit/pipelines/mock_data.js1
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js39
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js61
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js139
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js86
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js51
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js129
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js93
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js18
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js19
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js51
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js95
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js33
-rw-r--r--spec/serializers/event_entity_spec.rb13
-rw-r--r--spec/serializers/merge_request_basic_serializer_spec.rb12
-rw-r--r--spec/serializers/merge_request_serializer_spec.rb269
-rw-r--r--spec/serializers/pipeline_entity_spec.rb4
-rw-r--r--spec/serializers/stage_entity_spec.rb2
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('&amp;')
- expect(response.body).not_to include('&gt;')
- expect(response.body).not_to include('&lt;')
- expect(response.body).not_to include('&quot;')
+ 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