summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/issue_templates/Feature Proposal.md10
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/boards/components/board.js5
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js119
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js9
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_service.js44
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js146
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js1
-rw-r--r--app/assets/javascripts/environments/components/environment.js14
-rw-r--r--app/assets/javascripts/environments/components/environment_item.js8
-rw-r--r--app/assets/javascripts/environments/components/environments_table.js4
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.js15
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js3
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js1
-rw-r--r--app/assets/javascripts/merge_request_widget.js2
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/async_button.js92
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js56
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js71
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js32
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/stage.js116
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/status.js60
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/time_ago.js71
-rw-r--r--app/assets/javascripts/vue_pipelines_index/event_hub.js3
-rw-r--r--app/assets/javascripts/vue_pipelines_index/index.js23
-rw-r--r--app/assets/javascripts/vue_pipelines_index/pipeline_actions.js123
-rw-r--r--app/assets/javascripts/vue_pipelines_index/pipeline_url.js63
-rw-r--r--app/assets/javascripts/vue_pipelines_index/pipelines.js190
-rw-r--r--app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js44
-rw-r--r--app/assets/javascripts/vue_pipelines_index/stage.js119
-rw-r--r--app/assets/javascripts/vue_pipelines_index/status.js64
-rw-r--r--app/assets/javascripts/vue_pipelines_index/store.js31
-rw-r--r--app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js (renamed from app/assets/javascripts/commit/pipelines/pipelines_store.js)37
-rw-r--r--app/assets/javascripts/vue_pipelines_index/time_ago.js78
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js307
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table.js80
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js393
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.js254
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js10
-rw-r--r--app/assets/stylesheets/framework/emojis.scss1
-rw-r--r--app/assets/stylesheets/framework/nav.scss1
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss24
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/models/commit.rb9
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/label.rb4
-rw-r--r--app/services/merge_requests/get_urls_service.rb2
-rw-r--r--app/services/notification_recipient_service.rb293
-rw-r--r--app/services/notification_service.rb323
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml4
-rw-r--r--app/views/projects/boards/components/_blank_state.html.haml15
-rw-r--r--app/views/projects/boards/components/_board.html.haml2
-rw-r--r--app/views/projects/empty.html.haml1
-rw-r--r--changelogs/unreleased/21451-allow-disable-mr-link.yml4
-rw-r--r--changelogs/unreleased/26470-branch-names-with-reference-prefixes-results-in-buggy-branches.yml4
-rw-r--r--changelogs/unreleased/29604-v3-fix-branch-creation.yml4
-rw-r--r--changelogs/unreleased/add-labels-to-issue-hook.yml4
-rw-r--r--changelogs/unreleased/feature-use-gitaly-for-commit-show.yml4
-rw-r--r--changelogs/unreleased/fl-remove-ujs-pipelines.yml4
-rw-r--r--config/initializers/sidekiq.rb2
-rw-r--r--db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb18
-rw-r--r--db/schema.rb1
-rw-r--r--doc/development/frontend.md21
-rw-r--r--doc/user/award_emojis.md51
-rw-r--r--doc/user/img/award_emoji_comment_awarded.png (renamed from doc/workflow/img/award_emoji_comment_awarded.png)bin19159 -> 19159 bytes
-rw-r--r--doc/user/img/award_emoji_comment_picker.png (renamed from doc/workflow/img/award_emoji_comment_picker.png)bin72883 -> 72883 bytes
-rw-r--r--doc/user/img/award_emoji_select.pngbin0 -> 17827 bytes
-rw-r--r--doc/user/img/award_emoji_votes_sort_options.pngbin0 -> 99941 bytes
-rw-r--r--doc/user/project/integrations/webhooks.md14
-rw-r--r--doc/workflow/award_emoji.md66
-rw-r--r--doc/workflow/img/award_emoji_select.pngbin23779 -> 0 bytes
-rw-r--r--doc/workflow/img/award_emoji_votes_least_popular.pngbin50191 -> 0 bytes
-rw-r--r--doc/workflow/img/award_emoji_votes_most_popular.pngbin48342 -> 0 bytes
-rw-r--r--doc/workflow/img/award_emoji_votes_sort_options.pngbin57145 -> 0 bytes
-rw-r--r--lib/api/v3/branches.rb21
-rw-r--r--lib/gitlab/database.rb6
-rw-r--r--lib/gitlab/git/diff.rb21
-rw-r--r--lib/gitlab/git/diff_collection.rb4
-rw-r--r--lib/gitlab/git_ref_validator.rb3
-rw-r--r--lib/gitlab/gitaly_client.rb14
-rw-r--r--lib/gitlab/gitaly_client/commit.rb25
-rw-r--r--qa/qa/page/main/entry.rb10
-rw-r--r--spec/features/merge_requests/created_from_fork_spec.rb3
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb32
-rw-r--r--spec/features/projects/settings/merge_requests_settings_spec.rb23
-rw-r--r--spec/helpers/milestones_helper_spec.rb18
-rw-r--r--spec/javascripts/boards/board_blank_state_spec.js93
-rw-r--r--spec/javascripts/commit/pipelines/mock_data.js5
-rw-r--r--spec/javascripts/commit/pipelines/pipelines_spec.js17
-rw-r--r--spec/javascripts/commit/pipelines/pipelines_store_spec.js33
-rw-r--r--spec/javascripts/vue_pipelines_index/async_button_spec.js93
-rw-r--r--spec/javascripts/vue_pipelines_index/pipeline_url_spec.js100
-rw-r--r--spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js62
-rw-r--r--spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js40
-rw-r--r--spec/javascripts/vue_pipelines_index/pipelines_store_spec.js72
-rw-r--r--spec/javascripts/vue_shared/components/commit_spec.js27
-rw-r--r--spec/javascripts/vue_shared/components/pipelines_table_row_spec.js14
-rw-r--r--spec/javascripts/vue_shared/components/pipelines_table_spec.js31
-rw-r--r--spec/javascripts/vue_shared/components/table_pagination_spec.js52
-rw-r--r--spec/lib/git_ref_validator_spec.rb5
-rw-r--r--spec/lib/gitlab/database_spec.rb14
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb37
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_spec.rb58
-rw-r--r--spec/mailers/notify_spec.rb63
-rw-r--r--spec/models/commit_spec.rb28
-rw-r--r--spec/models/concerns/issuable_spec.rb10
-rw-r--r--spec/models/user_spec.rb4
-rw-r--r--spec/requests/api/internal_spec.rb11
-rw-r--r--spec/requests/api/v3/branches_spec.rb52
-rw-r--r--spec/services/merge_requests/get_urls_service_spec.rb10
-rw-r--r--spec/services/notification_service_spec.rb71
-rw-r--r--spec/support/matchers/email_matchers.rb5
112 files changed, 2892 insertions, 1877 deletions
diff --git a/.gitlab/issue_templates/Feature Proposal.md b/.gitlab/issue_templates/Feature Proposal.md
index ea895ee6275..2636010e2fb 100644
--- a/.gitlab/issue_templates/Feature Proposal.md
+++ b/.gitlab/issue_templates/Feature Proposal.md
@@ -5,3 +5,13 @@
### Proposal
### Links / references
+
+### Documentation blurb
+
+(Write the start of the documentation of this feature here, include:
+
+1. Why should someone use it; what's the underlying problem.
+2. What is the solution.
+3. How does someone use this
+
+During implementation, this can then be copied and used as a starter for the documentation.)
diff --git a/Gemfile b/Gemfile
index 2f813324a35..6af27ce0f3e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -352,4 +352,4 @@ gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
# Gitaly GRPC client
-gem 'gitaly', '~> 0.2.1'
+gem 'gitaly', '~> 0.3.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index c60c045a4c2..734911baf3f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -250,7 +250,7 @@ GEM
json
get_process_mem (0.2.0)
gherkin-ruby (0.3.2)
- gitaly (0.2.1)
+ gitaly (0.3.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@@ -896,7 +896,7 @@ DEPENDENCIES
fuubar (~> 2.0.0)
gemnasium-gitlab-service (~> 0.2)
gemojione (~> 3.0)
- gitaly (~> 0.2.1)
+ gitaly (~> 0.3.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 30d3be453be..67c0c419713 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -2,7 +2,8 @@
/* global Vue */
/* global Sortable */
-require('./board_blank_state');
+import boardBlankState from './board_blank_state';
+
require('./board_delete');
require('./board_list');
@@ -17,7 +18,7 @@ require('./board_list');
components: {
'board-list': gl.issueBoards.BoardList,
'board-delete': gl.issueBoards.BoardDelete,
- 'board-blank-state': gl.issueBoards.BoardBlankState
+ boardBlankState,
},
props: {
list: Object,
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js
index d76314c1892..52893d4642b 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.js
@@ -1,53 +1,84 @@
-/* eslint-disable space-before-function-paren, comma-dangle */
-/* global Vue */
/* global ListLabel */
+/* global Cookies */
+const Store = gl.issueBoards.BoardsStore;
-(() => {
- const Store = gl.issueBoards.BoardsStore;
+export default {
+ template: `
+ <div class="board-blank-state">
+ <p>
+ Add the following default lists to your Issue Board with one click:
+ </p>
+ <ul class="board-blank-state-list">
+ <li v-for="label in predefinedLabels">
+ <span
+ class="label-color"
+ :style="{ backgroundColor: label.color }">
+ </span>
+ {{ label.title }}
+ </li>
+ </ul>
+ <p>
+ Starting out with the default set of lists will get you right on the way to making the most of your board.
+ </p>
+ <button
+ class="btn btn-create btn-inverted btn-block"
+ type="button"
+ @click.stop="addDefaultLists">
+ Add default lists
+ </button>
+ <button
+ class="btn btn-default btn-block"
+ type="button"
+ @click.stop="clearBlankState">
+ Nevermind, I'll use my own
+ </button>
+ </div>
+ `,
+ data() {
+ return {
+ predefinedLabels: [
+ new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
+ new ListLabel({ title: 'Doing', color: '#5CB85C' }),
+ ],
+ };
+ },
+ methods: {
+ addDefaultLists() {
+ this.clearBlankState();
- window.gl = window.gl || {};
- window.gl.issueBoards = window.gl.issueBoards || {};
-
- gl.issueBoards.BoardBlankState = Vue.extend({
- data () {
- return {
- predefinedLabels: [
- new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
- new ListLabel({ title: 'Doing', color: '#5CB85C' })
- ]
- };
- },
- methods: {
- addDefaultLists () {
- this.clearBlankState();
-
- this.predefinedLabels.forEach((label, i) => {
- Store.addList({
+ this.predefinedLabels.forEach((label, i) => {
+ Store.addList({
+ title: label.title,
+ position: i,
+ list_type: 'label',
+ label: {
title: label.title,
- position: i,
- list_type: 'label',
- label: {
- title: label.title,
- color: label.color
- }
- });
+ color: label.color,
+ },
});
+ });
- Store.state.lists = _.sortBy(Store.state.lists, 'position');
+ Store.state.lists = _.sortBy(Store.state.lists, 'position');
- // Save the labels
- gl.boardService.generateDefaultLists()
- .then((resp) => {
- resp.json().forEach((listObj) => {
- const list = Store.findList('title', listObj.title);
+ // Save the labels
+ gl.boardService.generateDefaultLists()
+ .then((resp) => {
+ resp.json().forEach((listObj) => {
+ const list = Store.findList('title', listObj.title);
- list.id = listObj.id;
- list.label.id = listObj.label.id;
- list.getIssues();
- });
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ list.getIssues();
});
- },
- clearBlankState: Store.removeBlankState.bind(Store)
- }
- });
-})();
+ })
+ .catch(() => {
+ Store.removeList(undefined, 'label');
+ Cookies.remove('issue_board_welcome_hidden', {
+ path: '',
+ });
+ Store.addBlankState();
+ });
+ },
+ clearBlankState: Store.removeBlankState.bind(Store),
+ },
+};
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index b5a988df897..a9f2d462c31 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,8 +1,9 @@
-/* eslint-disable no-new, no-param-reassign */
-/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
+/* eslint-disable no-param-reassign */
+import CommitPipelinesTable from './pipelines_table';
window.Vue = require('vue');
-require('./pipelines_table');
+window.Vue.use(require('vue-resource'));
+
/**
* Commits View > Pipelines Tab > Pipelines Table.
* Merge Request View > Pipelines Tab > Pipelines Table.
@@ -21,7 +22,7 @@ $(() => {
}
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView();
+ gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable();
if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl);
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js b/app/assets/javascripts/commit/pipelines/pipelines_service.js
deleted file mode 100644
index 8ae98f9bf97..00000000000
--- a/app/assets/javascripts/commit/pipelines/pipelines_service.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/* globals Vue */
-/* eslint-disable no-unused-vars, no-param-reassign */
-
-/**
- * Pipelines service.
- *
- * Used to fetch the data used to render the pipelines table.
- * Uses Vue.Resource
- */
-class PipelinesService {
-
- /**
- * FIXME: The url provided to request the pipelines in the new merge request
- * page already has `.json`.
- * This should be fixed when the endpoint is improved.
- *
- * @param {String} root
- */
- constructor(root) {
- let endpoint;
-
- if (root.indexOf('.json') === -1) {
- endpoint = `${root}.json`;
- } else {
- endpoint = root;
- }
- this.pipelines = Vue.resource(endpoint);
- }
-
- /**
- * Given the root param provided when the class is initialized, will
- * make a GET request.
- *
- * @return {Promise}
- */
- all() {
- return this.pipelines.get();
- }
-}
-
-window.gl = window.gl || {};
-gl.commits = gl.commits || {};
-gl.commits.pipelines = gl.commits.pipelines || {};
-gl.commits.pipelines.PipelinesService = PipelinesService;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index 631ed34851c..832c4b1bd2a 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -1,13 +1,12 @@
-/* eslint-disable no-new, no-param-reassign */
-/* global Vue, CommitsPipelineStore, PipelinesService, Flash */
-
-window.Vue = require('vue');
-window.Vue.use(require('vue-resource'));
-require('../../lib/utils/common_utils');
-require('../../vue_shared/vue_resource_interceptor');
-require('../../vue_shared/components/pipelines_table');
-require('./pipelines_service');
-const PipelineStore = require('./pipelines_store');
+/* eslint-disable no-new*/
+/* global Flash */
+import Vue from 'vue';
+import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
+import PipelinesService from '../../vue_pipelines_index/services/pipelines_service';
+import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store';
+import eventHub from '../../vue_pipelines_index/event_hub';
+import '../../lib/utils/common_utils';
+import '../../vue_shared/vue_resource_interceptor';
/**
*
@@ -20,48 +19,59 @@ const PipelineStore = require('./pipelines_store');
* as soon as we have Webpack and can load them directly into JS files.
*/
-(() => {
- window.gl = window.gl || {};
- gl.commits = gl.commits || {};
- gl.commits.pipelines = gl.commits.pipelines || {};
+export default Vue.component('pipelines-table', {
+ components: {
+ 'pipelines-table-component': PipelinesTableComponent,
+ },
- gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', {
+ /**
+ * Accesses the DOM to provide the needed data.
+ * Returns the necessary props to render `pipelines-table-component` component.
+ *
+ * @return {Object}
+ */
+ data() {
+ const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
+ const store = new PipelineStore();
- components: {
- 'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
- },
+ return {
+ endpoint: pipelinesTableData.endpoint,
+ store,
+ state: store.state,
+ isLoading: false,
+ };
+ },
- /**
- * Accesses the DOM to provide the needed data.
- * Returns the necessary props to render `pipelines-table-component` component.
- *
- * @return {Object}
- */
- data() {
- const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
- const store = new PipelineStore();
+ /**
+ * When the component is about to be mounted, tell the service to fetch the data
+ *
+ * A request to fetch the pipelines will be made.
+ * In case of a successfull response we will store the data in the provided
+ * store, in case of a failed response we need to warn the user.
+ *
+ */
+ beforeMount() {
+ this.service = new PipelinesService(this.endpoint);
- return {
- endpoint: pipelinesTableData.endpoint,
- store,
- state: store.state,
- isLoading: false,
- };
- },
+ this.fetchPipelines();
+
+ eventHub.$on('refreshPipelines', this.fetchPipelines);
+ },
+
+ beforeUpdate() {
+ if (this.state.pipelines.length && this.$children) {
+ this.store.startTimeAgoLoops.call(this, Vue);
+ }
+ },
- /**
- * When the component is about to be mounted, tell the service to fetch the data
- *
- * A request to fetch the pipelines will be made.
- * In case of a successfull response we will store the data in the provided
- * store, in case of a failed response we need to warn the user.
- *
- */
- beforeMount() {
- const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint);
+ beforeDestroyed() {
+ eventHub.$off('refreshPipelines');
+ },
+ methods: {
+ fetchPipelines() {
this.isLoading = true;
- return pipelinesService.all()
+ return this.service.getPipelines()
.then(response => response.json())
.then((json) => {
// depending of the endpoint the response can either bring a `pipelines` key or not.
@@ -71,34 +81,30 @@ const PipelineStore = require('./pipelines_store');
})
.catch(() => {
this.isLoading = false;
- new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert');
+ new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
},
+ },
- beforeUpdate() {
- if (this.state.pipelines.length && this.$children) {
- PipelineStore.startTimeAgoLoops.call(this, Vue);
- }
- },
-
- template: `
- <div class="pipelines">
- <div class="realtime-loading" v-if="isLoading">
- <i class="fa fa-spinner fa-spin"></i>
- </div>
+ template: `
+ <div class="pipelines">
+ <div class="realtime-loading" v-if="isLoading">
+ <i class="fa fa-spinner fa-spin"></i>
+ </div>
- <div class="blank-state blank-state-no-icon"
- v-if="!isLoading && state.pipelines.length === 0">
- <h2 class="blank-state-title js-blank-state-title">
- No pipelines to show
- </h2>
- </div>
+ <div class="blank-state blank-state-no-icon"
+ v-if="!isLoading && state.pipelines.length === 0">
+ <h2 class="blank-state-title js-blank-state-title">
+ No pipelines to show
+ </h2>
+ </div>
- <div class="table-holder pipelines"
- v-if="!isLoading && state.pipelines.length > 0">
- <pipelines-table-component :pipelines="state.pipelines"/>
- </div>
+ <div class="table-holder pipelines"
+ v-if="!isLoading && state.pipelines.length > 0">
+ <pipelines-table-component
+ :pipelines="state.pipelines"
+ :service="service" />
</div>
- `,
- });
-})();
+ </div>
+ `,
+});
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index 788daa96b3d..dd7081aefb7 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -25,6 +25,7 @@ import collapseIcon from '../icons/collapse_icon.svg';
role="button"
data-container="body"
data-placement="top"
+ data-html="true"
:data-line-type="lineType"
:title="note.authorName + ': ' + note.noteTruncated"
:src="note.authorAvatar"
diff --git a/app/assets/javascripts/environments/components/environment.js b/app/assets/javascripts/environments/components/environment.js
index 0923ce6b550..51aab8460f6 100644
--- a/app/assets/javascripts/environments/components/environment.js
+++ b/app/assets/javascripts/environments/components/environment.js
@@ -1,21 +1,18 @@
-/* eslint-disable no-param-reassign, no-new */
+/* eslint-disable no-new */
/* global Flash */
+import Vue from 'vue';
import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from './environments_table';
import EnvironmentsStore from '../stores/environments_store';
+import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import '../../lib/utils/common_utils';
import eventHub from '../event_hub';
-const Vue = window.Vue = require('vue');
-window.Vue.use(require('vue-resource'));
-require('../../vue_shared/components/table_pagination');
-require('../../lib/utils/common_utils');
-require('../../vue_shared/vue_resource_interceptor');
-
export default Vue.component('environment-component', {
components: {
'environment-table': EnvironmentTable,
- 'table-pagination': gl.VueGlPagination,
+ 'table-pagination': TablePaginationComponent,
},
data() {
@@ -59,7 +56,6 @@ export default Vue.component('environment-component', {
canCreateEnvironmentParsed() {
return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment);
},
-
},
/**
diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.js
index 93919d41c60..66ed10e19d1 100644
--- a/app/assets/javascripts/environments/components/environment_item.js
+++ b/app/assets/javascripts/environments/components/environment_item.js
@@ -1,24 +1,22 @@
import Timeago from 'timeago.js';
+import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions';
import ExternalUrlComponent from './environment_external_url';
import StopComponent from './environment_stop';
import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button';
-import '../../lib/utils/text_utility';
-import '../../vue_shared/components/commit';
+import CommitComponent from '../../vue_shared/components/commit';
/**
* Envrionment Item Component
*
* Renders a table row for each environment.
*/
-
const timeagoInstance = new Timeago();
export default {
-
components: {
- 'commit-component': gl.CommitComponent,
+ 'commit-component': CommitComponent,
'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent,
'stop-component': StopComponent,
diff --git a/app/assets/javascripts/environments/components/environments_table.js b/app/assets/javascripts/environments/components/environments_table.js
index 5f07b612b91..338dff40bc9 100644
--- a/app/assets/javascripts/environments/components/environments_table.js
+++ b/app/assets/javascripts/environments/components/environments_table.js
@@ -1,11 +1,11 @@
/**
* Render environments table.
*/
-import EnvironmentItem from './environment_item';
+import EnvironmentTableRowComponent from './environment_item';
export default {
components: {
- 'environment-item': EnvironmentItem,
+ 'environment-item': EnvironmentTableRowComponent,
},
props: {
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js
index 7abcf6dbbea..8abbcf0c227 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.js
@@ -1,20 +1,17 @@
-/* eslint-disable no-param-reassign, no-new */
+/* eslint-disable no-new */
/* global Flash */
+import Vue from 'vue';
import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table';
import EnvironmentsStore from '../stores/environments_store';
-
-const Vue = window.Vue = require('vue');
-window.Vue.use(require('vue-resource'));
-require('../../vue_shared/components/table_pagination');
-require('../../lib/utils/common_utils');
-require('../../vue_shared/vue_resource_interceptor');
+import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import '../../lib/utils/common_utils';
+import '../../vue_shared/vue_resource_interceptor';
export default Vue.component('environment-folder-view', {
-
components: {
'environment-table': EnvironmentTable,
- 'table-pagination': gl.VueGlPagination,
+ 'table-pagination': TablePaginationComponent,
},
data() {
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 76296c83d11..07040bf0d73 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -1,5 +1,8 @@
/* eslint-disable class-methods-use-this */
import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
export default class EnvironmentsService {
constructor(endpoint) {
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index d3fe3872c56..3c3084f3b78 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -1,5 +1,4 @@
import '~/lib/utils/common_utils';
-
/**
* Environments Store.
*
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
index 66cc270ab4d..94a4f24f1d7 100644
--- a/app/assets/javascripts/merge_request_widget.js
+++ b/app/assets/javascripts/merge_request_widget.js
@@ -176,7 +176,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
_this.opts.ci_sha = data.sha;
_this.updateCommitUrls(data.sha);
}
- if (showNotification) {
+ if (showNotification && data.status) {
status = _this.ciLabelForStatus(data.status);
if (status === "preparing") {
title = _this.opts.ci_title.preparing;
diff --git a/app/assets/javascripts/vue_pipelines_index/components/async_button.js b/app/assets/javascripts/vue_pipelines_index/components/async_button.js
new file mode 100644
index 00000000000..aaebf29d8ae
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/async_button.js
@@ -0,0 +1,92 @@
+/* eslint-disable no-new, no-alert */
+/* global Flash */
+import '~/flash';
+import eventHub from '../event_hub';
+
+export default {
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+
+ service: {
+ type: Object,
+ required: true,
+ },
+
+ title: {
+ type: String,
+ required: true,
+ },
+
+ icon: {
+ type: String,
+ required: true,
+ },
+
+ cssClass: {
+ type: String,
+ required: true,
+ },
+
+ confirmActionMessage: {
+ type: String,
+ required: false,
+ },
+ },
+
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+
+ computed: {
+ iconClass() {
+ return `fa fa-${this.icon}`;
+ },
+
+ buttonClass() {
+ return `btn has-tooltip ${this.cssClass}`;
+ },
+ },
+
+ methods: {
+ onClick() {
+ if (this.confirmActionMessage && confirm(this.confirmActionMessage)) {
+ this.makeRequest();
+ } else if (!this.confirmActionMessage) {
+ this.makeRequest();
+ }
+ },
+
+ makeRequest() {
+ this.isLoading = true;
+
+ this.service.postAction(this.endpoint)
+ .then(() => {
+ this.isLoading = false;
+ eventHub.$emit('refreshPipelines');
+ })
+ .catch(() => {
+ this.isLoading = false;
+ new Flash('An error occured while making the request.');
+ });
+ },
+ },
+
+ template: `
+ <button
+ type="button"
+ @click="onClick"
+ :class="buttonClass"
+ :title="title"
+ :aria-label="title"
+ data-placement="top"
+ :disabled="isLoading">
+ <i :class="iconClass" aria-hidden="true"/>
+ <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" />
+ </button>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js b/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js
new file mode 100644
index 00000000000..4e183d5c8ec
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js
@@ -0,0 +1,56 @@
+export default {
+ props: [
+ 'pipeline',
+ ],
+ computed: {
+ user() {
+ return !!this.pipeline.user;
+ },
+ },
+ template: `
+ <td>
+ <a
+ :href="pipeline.path"
+ class="js-pipeline-url-link">
+ <span class="pipeline-id">#{{pipeline.id}}</span>
+ </a>
+ <span>by</span>
+ <a
+ class="js-pipeline-url-user"
+ v-if="user"
+ :href="pipeline.user.web_url">
+ <img
+ v-if="user"
+ class="avatar has-tooltip s20 "
+ :title="pipeline.user.name"
+ data-container="body"
+ :src="pipeline.user.avatar_url"
+ >
+ </a>
+ <span
+ v-if="!user"
+ class="js-pipeline-url-api api monospace">
+ API
+ </span>
+ <span
+ v-if="pipeline.flags.latest"
+ class="js-pipeline-url-lastest label label-success has-tooltip"
+ title="Latest pipeline for this branch"
+ data-original-title="Latest pipeline for this branch">
+ latest
+ </span>
+ <span
+ v-if="pipeline.flags.yaml_errors"
+ class="js-pipeline-url-yaml label label-danger has-tooltip"
+ :title="pipeline.yaml_errors"
+ :data-original-title="pipeline.yaml_errors">
+ yaml invalid
+ </span>
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck label label-warning">
+ stuck
+ </span>
+ </td>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js b/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js
new file mode 100644
index 00000000000..4bb2b048884
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js
@@ -0,0 +1,71 @@
+/* eslint-disable no-new */
+/* global Flash */
+import '~/flash';
+import playIconSvg from 'icons/_icon_play.svg';
+import eventHub from '../event_hub';
+
+export default {
+ props: {
+ actions: {
+ type: Array,
+ required: true,
+ },
+
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ playIconSvg,
+ isLoading: false,
+ };
+ },
+
+ methods: {
+ onClickAction(endpoint) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => {
+ this.isLoading = false;
+ eventHub.$emit('refreshPipelines');
+ })
+ .catch(() => {
+ this.isLoading = false;
+ new Flash('An error occured while making the request.');
+ });
+ },
+ },
+
+ template: `
+ <div class="btn-group" v-if="actions">
+ <button
+ type="button"
+ class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
+ title="Manual job"
+ data-toggle="dropdown"
+ data-placement="top"
+ aria-label="Manual job"
+ :disabled="isLoading">
+ ${playIconSvg}
+ <i class="fa fa-caret-down" aria-hidden="true"></i>
+ <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
+ </button>
+
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li v-for="action in actions">
+ <button
+ type="button"
+ class="js-pipeline-action-link no-btn"
+ @click="onClickAction(action.path)">
+ ${playIconSvg}
+ <span>{{action.name}}</span>
+ </button>
+ </li>
+ </ul>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js b/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js
new file mode 100644
index 00000000000..3555040d60f
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js
@@ -0,0 +1,32 @@
+export default {
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ template: `
+ <div class="btn-group" role="group">
+ <button
+ class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
+ title="Artifacts"
+ data-placement="top"
+ data-toggle="dropdown"
+ aria-label="Artifacts">
+ <i class="fa fa-download" aria-hidden="true"></i>
+ <i class="fa fa-caret-down" aria-hidden="true"></i>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li v-for="artifact in artifacts">
+ <a
+ rel="nofollow"
+ :href="artifact.path">
+ <i class="fa fa-download" aria-hidden="true"></i>
+ <span>Download {{artifact.name}} artifacts</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/stage.js b/app/assets/javascripts/vue_pipelines_index/components/stage.js
new file mode 100644
index 00000000000..a2c29002707
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/stage.js
@@ -0,0 +1,116 @@
+/* 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';
+
+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],
+ };
+ },
+
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ updated() {
+ if (this.builds) {
+ this.stopDropdownClickPropagation();
+ }
+ },
+
+ methods: {
+ fetchBuilds(e) {
+ const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
+
+ if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
+
+ return this.$http.get(this.stage.dropdown_path)
+ .then((response) => {
+ this.builds = JSON.parse(response.body).html;
+ }, () => {
+ const flash = new Flash('Something went wrong on our end.');
+ return flash;
+ });
+ },
+
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => {
+ e.stopPropagation();
+ });
+ },
+ },
+ computed: {
+ buildsOrSpinner() {
+ return this.builds ? this.builds : this.spinner;
+ },
+ dropdownClass() {
+ if (this.builds) return 'js-builds-dropdown-container';
+ return 'js-builds-dropdown-loading builds-dropdown-loading';
+ },
+ buildStatus() {
+ return `Build: ${this.stage.status.label}`;
+ },
+ tooltip() {
+ return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
+ },
+ triggerButtonClass() {
+ return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
+ },
+ },
+ template: `
+ <div>
+ <button
+ @click="fetchBuilds($event)"
+ :class="triggerButtonClass"
+ :title="stage.title"
+ data-placement="top"
+ data-toggle="dropdown"
+ type="button"
+ :aria-label="stage.title">
+ <span v-html="svg" aria-hidden="true"></span>
+ <i class="fa fa-caret-down" aria-hidden="true"></i>
+ </button>
+ <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
+ <div class="arrow-up" aria-hidden="true"></div>
+ <div
+ :class="dropdownClass"
+ class="js-builds-dropdown-list scrollable-menu"
+ v-html="buildsOrSpinner">
+ </div>
+ </ul>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/status.js b/app/assets/javascripts/vue_pipelines_index/components/status.js
new file mode 100644
index 00000000000..21a281af438
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/status.js
@@ -0,0 +1,60 @@
+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';
+
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ 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 {
+ svg: svgsDictionary[this.pipeline.details.status.icon],
+ };
+ },
+
+ computed: {
+ cssClasses() {
+ return `ci-status ci-${this.pipeline.details.status.group}`;
+ },
+
+ detailsPath() {
+ const { status } = this.pipeline.details;
+ return status.has_details ? status.details_path : false;
+ },
+
+ content() {
+ return `${this.svg} ${this.pipeline.details.status.text}`;
+ },
+ },
+ template: `
+ <td class="commit-link">
+ <a
+ :class="cssClasses"
+ :href="detailsPath"
+ v-html="content">
+ </a>
+ </td>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/time_ago.js b/app/assets/javascripts/vue_pipelines_index/components/time_ago.js
new file mode 100644
index 00000000000..498d0715f54
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/time_ago.js
@@ -0,0 +1,71 @@
+import iconTimerSvg from 'icons/_icon_timer.svg';
+import '../../lib/utils/datetime_utility';
+
+export default {
+ data() {
+ return {
+ currentTime: new Date(),
+ iconTimerSvg,
+ };
+ },
+ props: ['pipeline'],
+ computed: {
+ timeAgo() {
+ return gl.utils.getTimeago();
+ },
+ localTimeFinished() {
+ return gl.utils.formatDate(this.pipeline.details.finished_at);
+ },
+ timeStopped() {
+ const changeTime = this.currentTime;
+ const options = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ };
+ options.timeZoneName = 'short';
+ const finished = this.pipeline.details.finished_at;
+ if (!finished && changeTime) return false;
+ return ({ words: this.timeAgo.format(finished) });
+ },
+ duration() {
+ const { duration } = this.pipeline.details;
+ const date = new Date(duration * 1000);
+
+ let hh = date.getUTCHours();
+ let mm = date.getUTCMinutes();
+ let ss = date.getSeconds();
+
+ if (hh < 10) hh = `0${hh}`;
+ if (mm < 10) mm = `0${mm}`;
+ if (ss < 10) ss = `0${ss}`;
+
+ if (duration !== null) return `${hh}:${mm}:${ss}`;
+ return false;
+ },
+ },
+ methods: {
+ changeTime() {
+ this.currentTime = new Date();
+ },
+ },
+ template: `
+ <td class="pipelines-time-ago">
+ <p class="duration" v-if='duration'>
+ <span v-html="iconTimerSvg"></span>
+ {{duration}}
+ </p>
+ <p class="finished-at" v-if='timeStopped'>
+ <i class="fa fa-calendar"></i>
+ <time
+ data-toggle="tooltip"
+ data-placement="top"
+ data-container="body"
+ :data-original-title='localTimeFinished'>
+ {{timeStopped.words}}
+ </time>
+ </p>
+ </td>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/event_hub.js b/app/assets/javascripts/vue_pipelines_index/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/vue_pipelines_index/index.js b/app/assets/javascripts/vue_pipelines_index/index.js
index a90bd1518e9..b4e2d3a1143 100644
--- a/app/assets/javascripts/vue_pipelines_index/index.js
+++ b/app/assets/javascripts/vue_pipelines_index/index.js
@@ -1,29 +1,28 @@
-/* eslint-disable no-param-reassign */
-/* global Vue, VueResource, gl */
-window.Vue = require('vue');
+import PipelinesStore from './stores/pipelines_store';
+import PipelinesComponent from './pipelines';
+import '../vue_shared/vue_resource_interceptor';
+
+const Vue = window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
-require('../lib/utils/common_utils');
-require('../vue_shared/vue_resource_interceptor');
-require('./pipelines');
$(() => new Vue({
el: document.querySelector('.vue-pipelines-index'),
data() {
const project = document.querySelector('.pipelines');
+ const store = new PipelinesStore();
return {
- scope: project.dataset.url,
- store: new gl.PipelineStore(),
+ store,
+ endpoint: project.dataset.url,
};
},
components: {
- 'vue-pipelines': gl.VuePipelines,
+ 'vue-pipelines': PipelinesComponent,
},
template: `
<vue-pipelines
- :scope="scope"
- :store="store">
- </vue-pipelines>
+ :endpoint="endpoint"
+ :store="store" />
`,
}));
diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js
deleted file mode 100644
index 583d6915a85..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/* global Vue, Flash, gl */
-/* eslint-disable no-param-reassign, no-alert */
-const playIconSvg = require('icons/_icon_play.svg');
-
-((gl) => {
- gl.VuePipelineActions = Vue.extend({
- props: ['pipeline'],
- computed: {
- actions() {
- return this.pipeline.details.manual_actions.length > 0;
- },
- artifacts() {
- return this.pipeline.details.artifacts.length > 0;
- },
- },
- methods: {
- download(name) {
- return `Download ${name} artifacts`;
- },
-
- /**
- * Shows a dialog when the user clicks in the cancel button.
- * We need to prevent the default behavior and stop propagation because the
- * link relies on UJS.
- *
- * @param {Event} event
- */
- confirmAction(event) {
- if (!confirm('Are you sure you want to cancel this pipeline?')) {
- event.preventDefault();
- event.stopPropagation();
- }
- },
- },
-
- data() {
- return { playIconSvg };
- },
-
- template: `
- <td class="pipeline-actions">
- <div class="pull-right">
- <div class="btn-group">
- <div class="btn-group" v-if="actions">
- <button
- class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
- data-toggle="dropdown"
- title="Manual job"
- data-placement="top"
- data-container="body"
- aria-label="Manual job">
- <span v-html="playIconSvg" aria-hidden="true"></span>
- <i class="fa fa-caret-down" aria-hidden="true"></i>
- </button>
- <ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for='action in pipeline.details.manual_actions'>
- <a
- rel="nofollow"
- data-method="post"
- :href="action.path" >
- <span v-html="playIconSvg" aria-hidden="true"></span>
- <span>{{action.name}}</span>
- </a>
- </li>
- </ul>
- </div>
-
- <div class="btn-group" v-if="artifacts">
- <button
- class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
- title="Artifacts"
- data-placement="top"
- data-container="body"
- data-toggle="dropdown"
- aria-label="Artifacts">
- <i class="fa fa-download" aria-hidden="true"></i>
- <i class="fa fa-caret-down" aria-hidden="true"></i>
- </button>
- <ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for='artifact in pipeline.details.artifacts'>
- <a
- rel="nofollow"
- :href="artifact.path">
- <i class="fa fa-download" aria-hidden="true"></i>
- <span>{{download(artifact.name)}}</span>
- </a>
- </li>
- </ul>
- </div>
- <div class="btn-group" v-if="pipeline.flags.retryable">
- <a
- class="btn btn-default btn-retry has-tooltip"
- title="Retry"
- rel="nofollow"
- data-method="post"
- data-placement="top"
- data-container="body"
- data-toggle="dropdown"
- :href='pipeline.retry_path'
- aria-label="Retry">
- <i class="fa fa-repeat" aria-hidden="true"></i>
- </a>
- </div>
- <div class="btn-group" v-if="pipeline.flags.cancelable">
- <a
- class="btn btn-remove has-tooltip"
- title="Cancel"
- rel="nofollow"
- data-method="post"
- data-placement="top"
- data-container="body"
- data-toggle="dropdown"
- :href='pipeline.cancel_path'
- aria-label="Cancel">
- <i class="fa fa-remove" aria-hidden="true"></i>
- </a>
- </div>
- </div>
- </div>
- </td>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js
deleted file mode 100644
index ae5649f0519..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/* global Vue, gl */
-/* eslint-disable no-param-reassign */
-
-((gl) => {
- gl.VuePipelineUrl = Vue.extend({
- props: [
- 'pipeline',
- ],
- computed: {
- user() {
- return !!this.pipeline.user;
- },
- },
- template: `
- <td>
- <a :href='pipeline.path'>
- <span class="pipeline-id">#{{pipeline.id}}</span>
- </a>
- <span>by</span>
- <a
- v-if='user'
- :href='pipeline.user.web_url'
- >
- <img
- v-if='user'
- class="avatar has-tooltip s20 "
- :title='pipeline.user.name'
- data-container="body"
- :src='pipeline.user.avatar_url'
- >
- </a>
- <span
- v-if='!user'
- class="api monospace"
- >
- API
- </span>
- <span
- v-if='pipeline.flags.latest'
- class="label label-success has-tooltip"
- title="Latest pipeline for this branch"
- data-original-title="Latest pipeline for this branch"
- >
- latest
- </span>
- <span
- v-if='pipeline.flags.yaml_errors'
- class="label label-danger has-tooltip"
- :title='pipeline.yaml_errors'
- :data-original-title='pipeline.yaml_errors'
- >
- yaml invalid
- </span>
- <span
- v-if='pipeline.flags.stuck'
- class="label label-warning"
- >
- stuck
- </span>
- </td>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js b/app/assets/javascripts/vue_pipelines_index/pipelines.js
index 601ef41e917..f389e5e4950 100644
--- a/app/assets/javascripts/vue_pipelines_index/pipelines.js
+++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js
@@ -1,87 +1,121 @@
-/* global Vue, gl */
-/* eslint-disable no-param-reassign */
+/* global Flash */
+/* eslint-disable no-new */
+import '~/flash';
+import Vue from 'vue';
+import PipelinesService from './services/pipelines_service';
+import eventHub from './event_hub';
+import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
+import TablePaginationComponent from '../vue_shared/components/table_pagination';
-window.Vue = require('vue');
-require('../vue_shared/components/table_pagination');
-require('./store');
-require('../vue_shared/components/pipelines_table');
-const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store');
-
-((gl) => {
- gl.VuePipelines = Vue.extend({
-
- components: {
- 'gl-pagination': gl.VueGlPagination,
- 'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
+export default {
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
},
- data() {
- return {
- pipelines: [],
- timeLoopInterval: '',
- intervalId: '',
- apiScope: 'all',
- pageInfo: {},
- pagenum: 1,
- count: {},
- pageRequest: false,
- };
- },
- props: ['scope', 'store'],
- created() {
- const pagenum = gl.utils.getParameterByName('page');
- const scope = gl.utils.getParameterByName('scope');
- if (pagenum) this.pagenum = pagenum;
- if (scope) this.apiScope = scope;
-
- this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
+ store: {
+ type: Object,
+ required: true,
},
+ },
+
+ components: {
+ 'gl-pagination': TablePaginationComponent,
+ 'pipelines-table-component': PipelinesTableComponent,
+ },
+
+ data() {
+ return {
+ state: this.store.state,
+ apiScope: 'all',
+ pagenum: 1,
+ pageRequest: false,
+ };
+ },
+
+ created() {
+ this.service = new PipelinesService(this.endpoint);
+
+ this.fetchPipelines();
+
+ eventHub.$on('refreshPipelines', this.fetchPipelines);
+ },
+
+ beforeUpdate() {
+ if (this.state.pipelines.length && this.$children) {
+ this.store.startTimeAgoLoops.call(this, Vue);
+ }
+ },
- beforeUpdate() {
- if (this.pipelines.length && this.$children) {
- CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue);
- }
+ beforeDestroyed() {
+ eventHub.$off('refreshPipelines');
+ },
+
+ methods: {
+ /**
+ * Will change the page number and update the URL.
+ *
+ * @param {Number} pageNumber desired page to go to.
+ */
+ change(pageNumber) {
+ const param = gl.utils.setParamInURL('page', pageNumber);
+
+ gl.utils.visitUrl(param);
+ return param;
},
- methods: {
- /**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
- */
- change(pageNumber) {
- const param = gl.utils.setParamInURL('page', pageNumber);
-
- gl.utils.visitUrl(param);
- return param;
- },
+ fetchPipelines() {
+ const pageNumber = gl.utils.getParameterByName('page') || this.pagenum;
+ const scope = gl.utils.getParameterByName('scope') || this.apiScope;
+
+ this.pageRequest = true;
+ return this.service.getPipelines(scope, pageNumber)
+ .then(resp => ({
+ headers: resp.headers,
+ body: resp.json(),
+ }))
+ .then((response) => {
+ this.store.storeCount(response.body.count);
+ this.store.storePipelines(response.body.pipelines);
+ this.store.storePagination(response.headers);
+ })
+ .then(() => {
+ this.pageRequest = false;
+ })
+ .catch(() => {
+ this.pageRequest = false;
+ new Flash('An error occurred while fetching the pipelines, please reload the page again.');
+ });
},
- template: `
- <div>
- <div class="pipelines realtime-loading" v-if='pageRequest'>
- <i class="fa fa-spinner fa-spin"></i>
- </div>
-
- <div class="blank-state blank-state-no-icon"
- v-if="!pageRequest && pipelines.length === 0">
- <h2 class="blank-state-title js-blank-state-title">
- No pipelines to show
- </h2>
- </div>
-
- <div class="table-holder" v-if='!pageRequest && pipelines.length'>
- <pipelines-table-component :pipelines='pipelines'/>
- </div>
-
- <gl-pagination
- v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage'
- :pagenum='pagenum'
- :change='change'
- :count='count.all'
- :pageInfo='pageInfo'
- >
- </gl-pagination>
+ },
+ template: `
+ <div>
+ <div class="pipelines realtime-loading" v-if="pageRequest">
+ <i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
+ </div>
+
+ <div class="blank-state blank-state-no-icon"
+ v-if="!pageRequest && state.pipelines.length === 0">
+ <h2 class="blank-state-title js-blank-state-title">
+ No pipelines to show
+ </h2>
+ </div>
+
+ <div class="table-holder" v-if="!pageRequest && state.pipelines.length">
+ <pipelines-table-component
+ :pipelines="state.pipelines"
+ :service="service"/>
</div>
- `,
- });
-})(window.gl || (window.gl = {}));
+
+ <gl-pagination
+ v-if="!pageRequest && state.pipelines.length && state.pageInfo.total > state.pageInfo.perPage"
+ :pagenum="pagenum"
+ :change="change"
+ :count="state.count.all"
+ :pageInfo="state.pageInfo"
+ >
+ </gl-pagination>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js
new file mode 100644
index 00000000000..708f5068dd3
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js
@@ -0,0 +1,44 @@
+/* eslint-disable class-methods-use-this */
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class PipelinesService {
+
+ /**
+ * Commits and merge request endpoints need to be requested with `.json`.
+ *
+ * The url provided to request the pipelines in the new merge request
+ * page already has `.json`.
+ *
+ * @param {String} root
+ */
+ constructor(root) {
+ let endpoint;
+
+ if (root.indexOf('.json') === -1) {
+ endpoint = `${root}.json`;
+ } else {
+ endpoint = root;
+ }
+
+ this.pipelines = Vue.resource(endpoint);
+ }
+
+ getPipelines(scope, page) {
+ return this.pipelines.get({ scope, page });
+ }
+
+ /**
+ * Post request for all pipelines actions.
+ * Endpoint content type needs to be:
+ * `Content-Type:application/x-www-form-urlencoded`
+ *
+ * @param {String} endpoint
+ * @return {Promise}
+ */
+ postAction(endpoint) {
+ return Vue.http.post(endpoint, {}, { emulateJSON: true });
+ }
+}
diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js b/app/assets/javascripts/vue_pipelines_index/stage.js
deleted file mode 100644
index ae4f0b4a53b..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/stage.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/* global Vue, Flash, gl */
-/* eslint-disable no-param-reassign */
-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';
-
-((gl) => {
- gl.VueStage = Vue.extend({
- 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],
- };
- },
-
- props: {
- stage: {
- type: Object,
- required: true,
- },
- },
-
- updated() {
- if (this.builds) {
- this.stopDropdownClickPropagation();
- }
- },
-
- methods: {
- fetchBuilds(e) {
- const areaExpanded = e.currentTarget.attributes['aria-expanded'];
-
- if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
-
- return this.$http.get(this.stage.dropdown_path)
- .then((response) => {
- this.builds = JSON.parse(response.body).html;
- }, () => {
- const flash = new Flash('Something went wrong on our end.');
- return flash;
- });
- },
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(this.$el).on('click', '.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', (e) => {
- e.stopPropagation();
- });
- },
- },
- computed: {
- buildsOrSpinner() {
- return this.builds ? this.builds : this.spinner;
- },
- dropdownClass() {
- if (this.builds) return 'js-builds-dropdown-container';
- return 'js-builds-dropdown-loading builds-dropdown-loading';
- },
- buildStatus() {
- return `Build: ${this.stage.status.label}`;
- },
- tooltip() {
- return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
- },
- triggerButtonClass() {
- return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
- },
- },
- template: `
- <div>
- <button
- @click="fetchBuilds($event)"
- :class="triggerButtonClass"
- :title="stage.title"
- data-placement="top"
- data-toggle="dropdown"
- type="button"
- :aria-label="stage.title">
- <span v-html="svg" aria-hidden="true"></span>
- <i class="fa fa-caret-down" aria-hidden="true"></i>
- </button>
- <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
- <div class="arrow-up" aria-hidden="true"></div>
- <div
- :class="dropdownClass"
- class="js-builds-dropdown-list scrollable-menu"
- v-html="buildsOrSpinner">
- </div>
- </ul>
- </div>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/vue_pipelines_index/status.js b/app/assets/javascripts/vue_pipelines_index/status.js
deleted file mode 100644
index 8d9f83ac113..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/status.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/* global Vue, gl */
-/* eslint-disable no-param-reassign */
-
-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';
-
-((gl) => {
- gl.VueStatusScope = Vue.extend({
- props: [
- 'pipeline',
- ],
-
- 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 {
- svg: svgsDictionary[this.pipeline.details.status.icon],
- };
- },
-
- computed: {
- cssClasses() {
- const cssObject = { 'ci-status': true };
- cssObject[`ci-${this.pipeline.details.status.group}`] = true;
- return cssObject;
- },
-
- detailsPath() {
- const { status } = this.pipeline.details;
- return status.has_details ? status.details_path : false;
- },
-
- content() {
- return `${this.svg} ${this.pipeline.details.status.text}`;
- },
- },
- template: `
- <td class="commit-link">
- <a
- :class="cssClasses"
- :href="detailsPath"
- v-html="content">
- </a>
- </td>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/vue_pipelines_index/store.js b/app/assets/javascripts/vue_pipelines_index/store.js
deleted file mode 100644
index 909007267b9..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/store.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/* global gl, Flash */
-/* eslint-disable no-param-reassign */
-
-((gl) => {
- const pageValues = (headers) => {
- const normalized = gl.utils.normalizeHeaders(headers);
- const paginationInfo = gl.utils.parseIntPagination(normalized);
- return paginationInfo;
- };
-
- gl.PipelineStore = class {
- fetchDataLoop(Vue, pageNum, url, apiScope) {
- this.pageRequest = true;
-
- return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
- .then((response) => {
- const pageInfo = pageValues(response.headers);
- this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
-
- const res = JSON.parse(response.body);
- this.count = Object.assign({}, this.count, res.count);
- this.pipelines = Object.assign([], this.pipelines, res.pipelines);
-
- this.pageRequest = false;
- }, () => {
- this.pageRequest = false;
- return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
- });
- }
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js
index f1b80e45444..7ac10086a55 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_store.js
+++ b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js
@@ -1,31 +1,46 @@
/* eslint-disable no-underscore-dangle*/
-/**
- * Pipelines' Store for commits view.
- *
- * Used to store the Pipelines rendered in the commit view in the pipelines table.
- */
-require('../../vue_realtime_listener');
-
-class PipelinesStore {
+import '../../vue_realtime_listener';
+
+export default class PipelinesStore {
constructor() {
this.state = {};
+
this.state.pipelines = [];
+ this.state.count = {};
+ this.state.pageInfo = {};
}
storePipelines(pipelines = []) {
this.state.pipelines = pipelines;
+ }
- return pipelines;
+ storeCount(count = {}) {
+ this.state.count = count;
+ }
+
+ storePagination(pagination = {}) {
+ let paginationInfo;
+
+ if (Object.keys(pagination).length) {
+ const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
+ paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = pagination;
+ }
+
+ this.state.pageInfo = paginationInfo;
}
/**
+ * FIXME: Move this inside the component.
+ *
* Once the data is received we will start the time ago loops.
*
* Everytime a request is made like retry or cancel a pipeline, every 10 seconds we
* update the time to show how long as passed.
*
*/
- static startTimeAgoLoops() {
+ startTimeAgoLoops() {
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => {
@@ -44,5 +59,3 @@ class PipelinesStore {
gl.VueRealtimeListener(removeIntervals, startIntervals);
}
}
-
-module.exports = PipelinesStore;
diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js b/app/assets/javascripts/vue_pipelines_index/time_ago.js
deleted file mode 100644
index a383570857d..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/time_ago.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/* global Vue, gl */
-/* eslint-disable no-param-reassign */
-
-window.Vue = require('vue');
-require('../lib/utils/datetime_utility');
-
-const iconTimerSvg = require('../../../views/shared/icons/_icon_timer.svg');
-
-((gl) => {
- gl.VueTimeAgo = Vue.extend({
- data() {
- return {
- currentTime: new Date(),
- iconTimerSvg,
- };
- },
- props: ['pipeline'],
- computed: {
- timeAgo() {
- return gl.utils.getTimeago();
- },
- localTimeFinished() {
- return gl.utils.formatDate(this.pipeline.details.finished_at);
- },
- timeStopped() {
- const changeTime = this.currentTime;
- const options = {
- weekday: 'long',
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- };
- options.timeZoneName = 'short';
- const finished = this.pipeline.details.finished_at;
- if (!finished && changeTime) return false;
- return ({ words: this.timeAgo.format(finished) });
- },
- duration() {
- const { duration } = this.pipeline.details;
- const date = new Date(duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- if (hh < 10) hh = `0${hh}`;
- if (mm < 10) mm = `0${mm}`;
- if (ss < 10) ss = `0${ss}`;
-
- if (duration !== null) return `${hh}:${mm}:${ss}`;
- return false;
- },
- },
- methods: {
- changeTime() {
- this.currentTime = new Date();
- },
- },
- template: `
- <td class="pipelines-time-ago">
- <p class="duration" v-if='duration'>
- <span v-html="iconTimerSvg"></span>
- {{duration}}
- </p>
- <p class="finished-at" v-if='timeStopped'>
- <i class="fa fa-calendar"></i>
- <time
- data-toggle="tooltip"
- data-placement="top"
- data-container="body"
- :data-original-title='localTimeFinished'>
- {{timeStopped.words}}
- </time>
- </p>
- </td>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index 4381487b79e..fb68abd95a2 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -1,164 +1,157 @@
-/* global Vue */
-window.Vue = require('vue');
-const commitIconSvg = require('icons/_icon_commit.svg');
-
-(() => {
- window.gl = window.gl || {};
-
- window.gl.CommitComponent = Vue.component('commit-component', {
-
- props: {
- /**
- * Indicates the existance of a tag.
- * Used to render the correct icon, if true will render `fa-tag` icon,
- * if false will render `fa-code-fork` icon.
- */
- tag: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- /**
- * If provided is used to render the branch name and url.
- * Should contain the following properties:
- * name
- * ref_url
- */
- commitRef: {
- type: Object,
- required: false,
- default: () => ({}),
- },
-
- /**
- * Used to link to the commit sha.
- */
- commitUrl: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * Used to show the commit short sha that links to the commit url.
- */
- shortSha: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * If provided shows the commit tile.
- */
- title: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * If provided renders information about the author of the commit.
- * When provided should include:
- * `avatar_url` to render the avatar icon
- * `web_url` to link to user profile
- * `username` to render alt and title tags
- */
- author: {
- type: Object,
- required: false,
- default: () => ({}),
- },
+import commitIconSvg from 'icons/_icon_commit.svg';
+
+export default {
+ props: {
+ /**
+ * Indicates the existance of a tag.
+ * Used to render the correct icon, if true will render `fa-tag` icon,
+ * if false will render `fa-code-fork` icon.
+ */
+ tag: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- computed: {
- /**
- * Used to verify if all the properties needed to render the commit
- * ref section were provided.
- *
- * TODO: Improve this! Use lodash _.has when we have it.
- *
- * @returns {Boolean}
- */
- hasCommitRef() {
- return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
- },
-
- /**
- * Used to verify if all the properties needed to render the commit
- * author section were provided.
- *
- * TODO: Improve this! Use lodash _.has when we have it.
- *
- * @returns {Boolean}
- */
- hasAuthor() {
- return this.author &&
- this.author.avatar_url &&
- this.author.web_url &&
- this.author.username;
- },
-
- /**
- * If information about the author is provided will return a string
- * to be rendered as the alt attribute of the img tag.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- return this.author &&
- this.author.username ? `${this.author.username}'s avatar` : null;
- },
+ /**
+ * If provided is used to render the branch name and url.
+ * Should contain the following properties:
+ * name
+ * ref_url
+ */
+ commitRef: {
+ type: Object,
+ required: false,
+ default: () => ({}),
},
- data() {
- return { commitIconSvg };
+ /**
+ * Used to link to the commit sha.
+ */
+ commitUrl: {
+ type: String,
+ required: false,
+ default: '',
},
- template: `
- <div class="branch-commit">
-
- <div v-if="hasCommitRef" class="icon-container">
- <i v-if="tag" class="fa fa-tag"></i>
- <i v-if="!tag" class="fa fa-code-fork"></i>
- </div>
-
- <a v-if="hasCommitRef"
- class="monospace branch-name"
- :href="commitRef.ref_url">
- {{commitRef.name}}
- </a>
-
- <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
-
- <a class="commit-id monospace"
- :href="commitUrl">
- {{shortSha}}
- </a>
-
- <p class="commit-title">
- <span v-if="title">
- <a v-if="hasAuthor"
- class="avatar-image-container"
- :href="author.web_url">
- <img
- class="avatar has-tooltip s20"
- :src="author.avatar_url"
- :alt="userImageAltDescription"
- :title="author.username" />
- </a>
-
- <a class="commit-row-message"
- :href="commitUrl">
- {{title}}
- </a>
- </span>
- <span v-else>
- Cant find HEAD commit for this branch
- </span>
- </p>
+ /**
+ * Used to show the commit short sha that links to the commit url.
+ */
+ shortSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * If provided shows the commit tile.
+ */
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * If provided renders information about the author of the commit.
+ * When provided should include:
+ * `avatar_url` to render the avatar icon
+ * `web_url` to link to user profile
+ * `username` to render alt and title tags
+ */
+ author: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+
+ computed: {
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * ref section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasCommitRef() {
+ return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
+ },
+
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * author section were provided.
+ *
+ * TODO: Improve this! Use lodash _.has when we have it.
+ *
+ * @returns {Boolean}
+ */
+ hasAuthor() {
+ return this.author &&
+ this.author.avatar_url &&
+ this.author.web_url &&
+ this.author.username;
+ },
+
+ /**
+ * If information about the author is provided will return a string
+ * to be rendered as the alt attribute of the img tag.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ return this.author &&
+ this.author.username ? `${this.author.username}'s avatar` : null;
+ },
+ },
+
+ data() {
+ return { commitIconSvg };
+ },
+
+ template: `
+ <div class="branch-commit">
+
+ <div v-if="hasCommitRef" class="icon-container">
+ <i v-if="tag" class="fa fa-tag"></i>
+ <i v-if="!tag" class="fa fa-code-fork"></i>
</div>
- `,
- });
-})();
+
+ <a v-if="hasCommitRef"
+ class="monospace branch-name"
+ :href="commitRef.ref_url">
+ {{commitRef.name}}
+ </a>
+
+ <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
+
+ <a class="commit-id monospace"
+ :href="commitUrl">
+ {{shortSha}}
+ </a>
+
+ <p class="commit-title">
+ <span v-if="title">
+ <a v-if="hasAuthor"
+ class="avatar-image-container"
+ :href="author.web_url">
+ <img
+ class="avatar has-tooltip s20"
+ :src="author.avatar_url"
+ :alt="userImageAltDescription"
+ :title="author.username" />
+ </a>
+
+ <a class="commit-row-message"
+ :href="commitUrl">
+ {{title}}
+ </a>
+ </span>
+ <span v-else>
+ Cant find HEAD commit for this branch
+ </span>
+ </p>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js
index 0d8f85db965..afd8d7acf6b 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js
@@ -1,52 +1,48 @@
-/* eslint-disable no-param-reassign */
-/* global Vue */
+import PipelinesTableRowComponent from './pipelines_table_row';
-require('./pipelines_table_row');
/**
* Pipelines Table Component.
*
* Given an array of objects, renders a table.
*/
-
-(() => {
- window.gl = window.gl || {};
- gl.pipelines = gl.pipelines || {};
-
- gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', {
-
- props: {
- pipelines: {
- type: Array,
- required: true,
- default: () => ([]),
- },
-
+export default {
+ props: {
+ pipelines: {
+ type: Array,
+ required: true,
+ default: () => ([]),
},
- components: {
- 'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent,
+ service: {
+ type: Object,
+ required: true,
},
+ },
+
+ components: {
+ 'pipelines-table-row-component': PipelinesTableRowComponent,
+ },
- template: `
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="js-pipeline-status pipeline-status">Status</th>
- <th class="js-pipeline-info pipeline-info">Pipeline</th>
- <th class="js-pipeline-commit pipeline-commit">Commit</th>
- <th class="js-pipeline-stages pipeline-stages">Stages</th>
- <th class="js-pipeline-date pipeline-date"></th>
- <th class="js-pipeline-actions pipeline-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template v-for="model in pipelines"
- v-bind:model="model">
- <tr is="pipelines-table-row-component"
- :pipeline="model"></tr>
- </template>
- </tbody>
- </table>
- `,
- });
-})();
+ template: `
+ <table class="table ci-table">
+ <thead>
+ <tr>
+ <th class="js-pipeline-status pipeline-status">Status</th>
+ <th class="js-pipeline-info pipeline-info">Pipeline</th>
+ <th class="js-pipeline-commit pipeline-commit">Commit</th>
+ <th class="js-pipeline-stages pipeline-stages">Stages</th>
+ <th class="js-pipeline-date pipeline-date"></th>
+ <th class="js-pipeline-actions pipeline-actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <template v-for="model in pipelines"
+ v-bind:model="model">
+ <tr is="pipelines-table-row-component"
+ :pipeline="model"
+ :service="service"></tr>
+ </template>
+ </tbody>
+ </table>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
index e5e88186a85..f5b3cb9214e 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -1,199 +1,228 @@
/* eslint-disable no-param-reassign */
-/* global Vue */
-
-require('../../vue_pipelines_index/status');
-require('../../vue_pipelines_index/pipeline_url');
-require('../../vue_pipelines_index/stage');
-require('../../vue_pipelines_index/pipeline_actions');
-require('../../vue_pipelines_index/time_ago');
-require('./commit');
+
+import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button';
+import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions';
+import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts';
+import PipelinesStatusComponent from '../../vue_pipelines_index/components/status';
+import PipelinesStageComponent from '../../vue_pipelines_index/components/stage';
+import PipelinesUrlComponent from '../../vue_pipelines_index/components/pipeline_url';
+import PipelinesTimeagoComponent from '../../vue_pipelines_index/components/time_ago';
+import CommitComponent from './commit';
+
/**
* Pipeline table row.
*
* Given the received object renders a table row in the pipelines' table.
*/
-(() => {
- window.gl = window.gl || {};
- gl.pipelines = gl.pipelines || {};
-
- gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', {
-
- props: {
- pipeline: {
- type: Object,
- required: true,
- default: () => ({}),
- },
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ 'async-button-component': AsyncButtonComponent,
+ 'pipelines-actions-component': PipelinesActionsComponent,
+ 'pipelines-artifacts-component': PipelinesArtifactsComponent,
+ 'commit-component': CommitComponent,
+ 'dropdown-stage': PipelinesStageComponent,
+ 'pipeline-url': PipelinesUrlComponent,
+ 'status-scope': PipelinesStatusComponent,
+ 'time-ago': PipelinesTimeagoComponent,
+ },
+
+ computed: {
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * This field needs a lot of verification, because of different possible cases:
+ *
+ * 1. person who is an author of a commit might be a GitLab user
+ * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
+ * 3. If GitLab user does not have avatar he/she might have a Gravatar
+ * 4. If committer is not a GitLab User he/she can have a Gravatar
+ * 5. We do not have consistent API object in this case
+ * 6. We should improve API and the code
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ let commitAuthorInformation;
+
+ // 1. person who is an author of a commit might be a GitLab user
+ if (this.pipeline &&
+ this.pipeline.commit &&
+ this.pipeline.commit.author) {
+ // 2. if person who is an author of a commit is a GitLab user
+ // he/she can have a GitLab avatar
+ if (this.pipeline.commit.author.avatar_url) {
+ commitAuthorInformation = this.pipeline.commit.author;
+
+ // 3. If GitLab user does not have avatar he/she might have a Gravatar
+ } else if (this.pipeline.commit.author_gravatar_url) {
+ commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ });
+ }
+ }
+
+ // 4. If committer is not a GitLab User he/she can have a Gravatar
+ if (this.pipeline &&
+ this.pipeline.commit) {
+ commitAuthorInformation = {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ web_url: `mailto:${this.pipeline.commit.author_email}`,
+ username: this.pipeline.commit.author_name,
+ };
+ }
+
+ return commitAuthorInformation;
},
- components: {
- 'commit-component': gl.CommitComponent,
- 'pipeline-actions': gl.VuePipelineActions,
- 'dropdown-stage': gl.VueStage,
- 'pipeline-url': gl.VuePipelineUrl,
- 'status-scope': gl.VueStatusScope,
- 'time-ago': gl.VueTimeAgo,
+ /**
+ * If provided, returns the commit tag.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.pipeline.ref &&
+ this.pipeline.ref.tag) {
+ return this.pipeline.ref.tag;
+ }
+ return undefined;
},
- computed: {
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
- * 3. If GitLab user does not have avatar he/she might have a Gravatar
- * 4. If committer is not a GitLab User he/she can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline &&
- this.pipeline.commit &&
- this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // he/she can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar he/she might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- });
+ /**
+ * If provided, returns the commit ref.
+ * Needed to render the commit component column.
+ *
+ * Matches `path` prop sent in the API to `ref_url` prop needed
+ * in the commit component.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.pipeline.ref) {
+ return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
+ if (prop === 'path') {
+ accumulator.ref_url = this.pipeline.ref[prop];
+ } else {
+ accumulator[prop] = this.pipeline.ref[prop];
}
- }
+ return accumulator;
+ }, {});
+ }
- // 4. If committer is not a GitLab User he/she can have a Gravatar
- if (this.pipeline &&
- this.pipeline.commit) {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- web_url: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
+ return undefined;
+ },
- return commitAuthorInformation;
- },
-
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.pipeline.ref &&
- this.pipeline.ref.tag) {
- return this.pipeline.ref.tag;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit ref.
- * Needed to render the commit component column.
- *
- * Matches `path` prop sent in the API to `ref_url` prop needed
- * in the commit component.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.pipeline.ref) {
- return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
- if (prop === 'path') {
- accumulator.ref_url = this.pipeline.ref[prop];
- } else {
- accumulator[prop] = this.pipeline.ref[prop];
- }
- return accumulator;
- }, {});
- }
+ /**
+ * If provided, returns the commit url.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.commit_path) {
+ return this.pipeline.commit.commit_path;
+ }
+ return undefined;
+ },
- return undefined;
- },
-
- /**
- * If provided, returns the commit url.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.pipeline.commit &&
- this.pipeline.commit.commit_path) {
- return this.pipeline.commit.commit_path;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit short sha.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.pipeline.commit &&
- this.pipeline.commit.short_id) {
- return this.pipeline.commit.short_id;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit title.
- * Needed to render the commit component column.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.pipeline.commit &&
- this.pipeline.commit.title) {
- return this.pipeline.commit.title;
- }
- return undefined;
- },
+ /**
+ * If provided, returns the commit short sha.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.short_id) {
+ return this.pipeline.commit.short_id;
+ }
+ return undefined;
},
- template: `
- <tr class="commit">
- <status-scope :pipeline="pipeline"/>
-
- <pipeline-url :pipeline="pipeline"></pipeline-url>
-
- <td>
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"/>
- </td>
-
- <td class="stage-cell">
- <div class="stage-container dropdown js-mini-pipeline-graph"
- v-if="pipeline.details.stages.length > 0"
- v-for="stage in pipeline.details.stages">
- <dropdown-stage :stage="stage"/>
- </div>
- </td>
-
- <time-ago :pipeline="pipeline"/>
-
- <pipeline-actions :pipeline="pipeline" />
- </tr>
- `,
- });
-})();
+ /**
+ * If provided, returns the commit title.
+ * Needed to render the commit component column.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.pipeline.commit &&
+ this.pipeline.commit.title) {
+ return this.pipeline.commit.title;
+ }
+ return undefined;
+ },
+ },
+
+ template: `
+ <tr class="commit">
+ <status-scope :pipeline="pipeline"/>
+
+ <pipeline-url :pipeline="pipeline"></pipeline-url>
+
+ <td>
+ <commit-component
+ :tag="commitTag"
+ :commit-ref="commitRef"
+ :commit-url="commitUrl"
+ :short-sha="commitShortSha"
+ :title="commitTitle"
+ :author="commitAuthor"/>
+ </td>
+
+ <td class="stage-cell">
+ <div class="stage-container dropdown js-mini-pipeline-graph"
+ v-if="pipeline.details.stages.length > 0"
+ v-for="stage in pipeline.details.stages">
+ <dropdown-stage :stage="stage"/>
+ </div>
+ </td>
+
+ <time-ago :pipeline="pipeline"/>
+
+ <td class="pipeline-actions">
+ <div class="pull-right btn-group">
+ <pipelines-actions-component
+ v-if="pipeline.details.manual_actions.length"
+ :actions="pipeline.details.manual_actions"
+ :service="service" />
+
+ <pipelines-artifacts-component
+ v-if="pipeline.details.artifacts.length"
+ :artifacts="pipeline.details.artifacts" />
+
+ <async-button-component
+ v-if="pipeline.flags.retryable"
+ :service="service"
+ :endpoint="pipeline.retry_path"
+ css-class="js-pipelines-retry-button btn-default btn-retry"
+ title="Retry"
+ icon="repeat" />
+
+ <async-button-component
+ v-if="pipeline.flags.cancelable"
+ :service="service"
+ :endpoint="pipeline.cancel_path"
+ css-class="js-pipelines-cancel-button btn-remove"
+ title="Cancel"
+ icon="remove"
+ confirm-action-message="Are you sure you want to cancel this pipeline?" />
+ </div>
+ </td>
+ </tr>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.js
index 8943b850a72..b9cd28f6249 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.js
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.js
@@ -1,147 +1,135 @@
-/* global Vue, gl */
-/* eslint-disable no-param-reassign, no-plusplus */
-
-window.Vue = require('vue');
-
-((gl) => {
- const PAGINATION_UI_BUTTON_LIMIT = 4;
- const UI_LIMIT = 6;
- const SPREAD = '...';
- const PREV = 'Prev';
- const NEXT = 'Next';
- const FIRST = '<< First';
- const LAST = 'Last >>';
-
- gl.VueGlPagination = Vue.extend({
- props: {
-
- // TODO: Consider refactoring in light of turbolinks removal.
-
- /**
- This function will take the information given by the pagination component
-
- Here is an example `change` method:
-
- change(pagenum) {
- gl.utils.visitUrl(`?page=${pagenum}`);
- },
- */
-
- change: {
- type: Function,
- required: true,
+const PAGINATION_UI_BUTTON_LIMIT = 4;
+const UI_LIMIT = 6;
+const SPREAD = '...';
+const PREV = 'Prev';
+const NEXT = 'Next';
+const FIRST = '<< First';
+const LAST = 'Last >>';
+
+export default {
+ props: {
+ /**
+ This function will take the information given by the pagination component
+
+ Here is an example `change` method:
+
+ change(pagenum) {
+ gl.utils.visitUrl(`?page=${pagenum}`);
},
+ */
+ change: {
+ type: Function,
+ required: true,
+ },
- /**
- pageInfo will come from the headers of the API call
- in the `.then` clause of the VueResource API call
- there should be a function that contructs the pageInfo for this component
-
- This is an example:
-
- const pageInfo = headers => ({
- perPage: +headers['X-Per-Page'],
- page: +headers['X-Page'],
- total: +headers['X-Total'],
- totalPages: +headers['X-Total-Pages'],
- nextPage: +headers['X-Next-Page'],
- previousPage: +headers['X-Prev-Page'],
- });
- */
-
- pageInfo: {
- type: Object,
- required: true,
- },
+ /**
+ pageInfo will come from the headers of the API call
+ in the `.then` clause of the VueResource API call
+ there should be a function that contructs the pageInfo for this component
+
+ This is an example:
+
+ const pageInfo = headers => ({
+ perPage: +headers['X-Per-Page'],
+ page: +headers['X-Page'],
+ total: +headers['X-Total'],
+ totalPages: +headers['X-Total-Pages'],
+ nextPage: +headers['X-Next-Page'],
+ previousPage: +headers['X-Prev-Page'],
+ });
+ */
+ pageInfo: {
+ type: Object,
+ required: true,
},
- methods: {
- changePage(e) {
- const text = e.target.innerText;
- const { totalPages, nextPage, previousPage } = this.pageInfo;
-
- switch (text) {
- case SPREAD:
- break;
- case LAST:
- this.change(totalPages);
- break;
- case NEXT:
- this.change(nextPage);
- break;
- case PREV:
- this.change(previousPage);
- break;
- case FIRST:
- this.change(1);
- break;
- default:
- this.change(+text);
- break;
- }
- },
+ },
+ methods: {
+ changePage(e) {
+ const text = e.target.innerText;
+ const { totalPages, nextPage, previousPage } = this.pageInfo;
+
+ switch (text) {
+ case SPREAD:
+ break;
+ case LAST:
+ this.change(totalPages);
+ break;
+ case NEXT:
+ this.change(nextPage);
+ break;
+ case PREV:
+ this.change(previousPage);
+ break;
+ case FIRST:
+ this.change(1);
+ break;
+ default:
+ this.change(+text);
+ break;
+ }
},
- computed: {
- prev() {
- return this.pageInfo.previousPage;
- },
- next() {
- return this.pageInfo.nextPage;
- },
- getItems() {
- const total = this.pageInfo.totalPages;
- const page = this.pageInfo.page;
- const items = [];
+ },
+ computed: {
+ prev() {
+ return this.pageInfo.previousPage;
+ },
+ next() {
+ return this.pageInfo.nextPage;
+ },
+ getItems() {
+ const total = this.pageInfo.totalPages;
+ const page = this.pageInfo.page;
+ const items = [];
- if (page > 1) items.push({ title: FIRST });
+ if (page > 1) items.push({ title: FIRST });
- if (page > 1) {
- items.push({ title: PREV, prev: true });
- } else {
- items.push({ title: PREV, disabled: true, prev: true });
- }
+ if (page > 1) {
+ items.push({ title: PREV, prev: true });
+ } else {
+ items.push({ title: PREV, disabled: true, prev: true });
+ }
- if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
+ if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
+ const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
+ const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
- for (let i = start; i <= end; i++) {
- const isActive = i === page;
- items.push({ title: i, active: isActive, page: true });
- }
+ for (let i = start; i <= end; i += 1) {
+ const isActive = i === page;
+ items.push({ title: i, active: isActive, page: true });
+ }
- if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
- }
+ if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
+ items.push({ title: SPREAD, separator: true, page: true });
+ }
- if (page === total) {
- items.push({ title: NEXT, disabled: true, next: true });
- } else if (total - page >= 1) {
- items.push({ title: NEXT, next: true });
- }
+ if (page === total) {
+ items.push({ title: NEXT, disabled: true, next: true });
+ } else if (total - page >= 1) {
+ items.push({ title: NEXT, next: true });
+ }
- if (total - page >= 1) items.push({ title: LAST, last: true });
+ if (total - page >= 1) items.push({ title: LAST, last: true });
- return items;
- },
+ return items;
},
- template: `
- <div class="gl-pagination">
- <ul class="pagination clearfix">
- <li v-for='item in getItems'
- :class='{
- page: item.page,
- prev: item.prev,
- next: item.next,
- separator: item.separator,
- active: item.active,
- disabled: item.disabled
- }'
- >
- <a @click="changePage($event)">{{item.title}}</a>
- </li>
- </ul>
- </div>
- `,
- });
-})(window.gl || (window.gl = {}));
+ },
+ template: `
+ <div class="gl-pagination">
+ <ul class="pagination clearfix">
+ <li v-for='item in getItems'
+ :class='{
+ page: item.page,
+ prev: item.prev,
+ next: item.next,
+ separator: item.separator,
+ active: item.active,
+ disabled: item.disabled
+ }'
+ >
+ <a @click="changePage($event)">{{item.title}}</a>
+ </li>
+ </ul>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
index 4157fefddc9..f1c1e553b16 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
@@ -1,11 +1,13 @@
-/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars,
-no-param-reassign, no-plusplus */
-/* global Vue */
+/* eslint-disable no-param-reassign, no-plusplus */
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
- next((response) => {
+ next(() => {
Vue.activeResources--;
});
});
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 0a8bc95590e..d86ae57cd9a 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -2,5 +2,6 @@ gl-emoji {
display: inline-block;
display: inline-flex;
vertical-align: middle;
+ font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 1.5em;
}
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
index ea45aaa0253..205d23b1329 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/nav.scss
@@ -138,7 +138,6 @@
.nav-links {
display: inline-block;
- width: 50%;
margin-bottom: 0;
border-bottom: none;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 20eabc83142..33b38ca6923 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -72,11 +72,6 @@
color: $gl-text-color-secondary;
font-size: 14px;
}
-
- svg,
- .fa {
- margin-right: 0;
- }
}
.btn-group {
@@ -921,3 +916,22 @@
}
}
}
+
+/**
+ * Play button with icon in dropdowns
+ */
+.ci-table .no-btn {
+ border: none;
+ background: none;
+ outline: none;
+ width: 100%;
+ text-align: left;
+
+ .icon-play {
+ position: relative;
+ top: 2px;
+ margin-right: 5px;
+ height: 13px;
+ width: 12px;
+ }
+}
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 395a8bffe92..47f7e0b1b28 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -316,6 +316,7 @@ class ProjectsController < Projects::ApplicationController
:namespace_id,
:only_allow_merge_if_all_discussions_are_resolved,
:only_allow_merge_if_pipeline_succeeds,
+ :printing_merge_request_link_enabled,
:path,
:public_builds,
:request_access_enabled,
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 6ea5b1ae51f..ce92cc369ad 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -321,7 +321,14 @@ class Commit
end
def raw_diffs(*args)
- raw.diffs(*args)
+ use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
+ deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only]
+
+ if use_gitaly && !deltas_only
+ Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
+ else
+ raw.diffs(*args)
+ end
end
def diffs(diff_options = nil)
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 3b2c6a178e7..91f4eb13ecc 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -261,6 +261,7 @@ module Issuable
user: user.hook_attrs,
project: project.hook_attrs,
object_attributes: hook_attrs,
+ labels: labels.map(&:hook_attrs),
# DEPRECATED
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
}
diff --git a/app/models/label.rb b/app/models/label.rb
index f68a8c9cff2..568fa6d44f5 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -169,6 +169,10 @@ class Label < ActiveRecord::Base
end
end
+ def hook_attrs
+ attributes
+ end
+
private
def issues_count(user, params = {})
diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb
index ae386b53f42..f00a33969a8 100644
--- a/app/services/merge_requests/get_urls_service.rb
+++ b/app/services/merge_requests/get_urls_service.rb
@@ -7,6 +7,8 @@ module MergeRequests
end
def execute(changes)
+ return [] unless project.printing_merge_request_link_enabled
+
branches = get_branches(changes)
merge_requests_map = opened_merge_requests_from_source_branches(branches)
branches.map do |branch|
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
new file mode 100644
index 00000000000..44ae23fad18
--- /dev/null
+++ b/app/services/notification_recipient_service.rb
@@ -0,0 +1,293 @@
+#
+# Used by NotificationService to determine who should receive notification
+#
+class NotificationRecipientService
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def build_recipients(target, current_user, action: nil, previous_assignee: nil, skip_current_user: true)
+ custom_action = build_custom_key(action, target)
+
+ recipients = target.participants(current_user)
+
+ unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
+ recipients = add_project_watchers(recipients)
+ end
+
+ recipients = add_custom_notifications(recipients, custom_action)
+ recipients = reject_mention_users(recipients)
+
+ # Re-assign is considered as a mention of the new assignee so we add the
+ # new assignee to the list of recipients after we rejected users with
+ # the "on mention" notification level
+ if [:reassign_merge_request, :reassign_issue].include?(custom_action)
+ recipients << previous_assignee if previous_assignee
+ recipients << target.assignee
+ end
+
+ recipients = reject_muted_users(recipients)
+ recipients = add_subscribed_users(recipients, target)
+
+ if [:new_issue, :new_merge_request].include?(custom_action)
+ recipients = add_labels_subscribers(recipients, target)
+ end
+
+ recipients = reject_unsubscribed_users(recipients, target)
+ recipients = reject_users_without_access(recipients, target)
+
+ recipients.delete(current_user) if skip_current_user
+
+ recipients.uniq
+ end
+
+ def build_relabeled_recipients(target, current_user, labels:)
+ recipients = add_labels_subscribers([], target, labels: labels)
+ recipients = reject_unsubscribed_users(recipients, target)
+ recipients = reject_users_without_access(recipients, target)
+ recipients.delete(current_user)
+ recipients.uniq
+ end
+
+ def build_new_note_recipients(note)
+ target = note.noteable
+
+ ability, subject = if note.for_personal_snippet?
+ [:read_personal_snippet, note.noteable]
+ else
+ [:read_project, note.project]
+ end
+
+ mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) }
+
+ # Add all users participating in the thread (author, assignee, comment authors)
+ recipients =
+ if target.respond_to?(:participants)
+ target.participants(note.author)
+ else
+ mentioned_users
+ end
+
+ unless note.for_personal_snippet?
+ # Merge project watchers
+ recipients = add_project_watchers(recipients)
+
+ # Merge project with custom notification
+ recipients = add_custom_notifications(recipients, :new_note)
+ end
+
+ # Reject users with Mention notification level, except those mentioned in _this_ note.
+ recipients = reject_mention_users(recipients - mentioned_users)
+ recipients = recipients + mentioned_users
+
+ recipients = reject_muted_users(recipients)
+
+ recipients = add_subscribed_users(recipients, note.noteable)
+ recipients = reject_unsubscribed_users(recipients, note.noteable)
+ recipients = reject_users_without_access(recipients, note.noteable)
+
+ recipients.delete(note.author)
+ recipients.uniq
+ end
+
+ # Remove users with disabled notifications from array
+ # Also remove duplications and nil recipients
+ def reject_muted_users(users)
+ reject_users(users, :disabled)
+ end
+
+ protected
+
+ # Get project/group users with CUSTOM notification level
+ def add_custom_notifications(recipients, action)
+ user_ids = []
+
+ # Users with a notification setting on group or project
+ user_ids += user_ids_notifiable_on(project, :custom, action)
+ user_ids += user_ids_notifiable_on(project.group, :custom, action)
+
+ # Users with global level custom
+ user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global)
+
+ global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
+ user_ids += user_ids_with_global_level_custom(global_users_ids, action)
+
+ recipients.concat(User.find(user_ids))
+ end
+
+ def add_project_watchers(recipients)
+ recipients.concat(project_watchers).compact
+ end
+
+ # Get project users with WATCH notification level
+ def project_watchers
+ project_members_ids = user_ids_notifiable_on(project)
+
+ user_ids_with_project_global = user_ids_notifiable_on(project, :global)
+ user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
+
+ user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
+
+ user_ids_with_project_setting = select_project_members_ids(project, user_ids_with_project_global, user_ids)
+ user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
+
+ User.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq).to_a
+ end
+
+ # Remove users with notification level 'Mentioned'
+ def reject_mention_users(users)
+ reject_users(users, :mention)
+ end
+
+ def add_subscribed_users(recipients, target)
+ return recipients unless target.respond_to? :subscribers
+
+ recipients + target.subscribers(project)
+ end
+
+ def user_ids_notifiable_on(resource, notification_level = nil, action = nil)
+ return [] unless resource
+
+ if notification_level
+ settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
+ settings = settings.select { |setting| setting.events[action] } if action.present?
+ settings.map(&:user_id)
+ else
+ resource.notification_settings.pluck(:user_id)
+ end
+ end
+
+ # Build a list of user_ids based on project notification settings
+ def select_project_members_ids(project, global_setting, user_ids_global_level_watch)
+ user_ids = user_ids_notifiable_on(project, :watch)
+
+ # If project setting is global, add to watch list if global setting is watch
+ global_setting.each do |user_id|
+ if user_ids_global_level_watch.include?(user_id)
+ user_ids << user_id
+ end
+ end
+
+ user_ids
+ end
+
+ # Build a list of user_ids based on group notification settings
+ def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch)
+ uids = user_ids_notifiable_on(group, :watch)
+
+ # Group setting is watch, add to user_ids list if user is not project member
+ user_ids = []
+ uids.each do |user_id|
+ if project_members.exclude?(user_id)
+ user_ids << user_id
+ end
+ end
+
+ # Group setting is global, add to user_ids list if global setting is watch
+ global_setting.each do |user_id|
+ if project_members.exclude?(user_id) && user_ids_global_level_watch.include?(user_id)
+ user_ids << user_id
+ end
+ end
+
+ user_ids
+ end
+
+ def user_ids_with_global_level_watch(ids)
+ settings_with_global_level_of(:watch, ids).pluck(:user_id)
+ end
+
+ def user_ids_with_global_level_custom(ids, action)
+ settings = settings_with_global_level_of(:custom, ids)
+ settings = settings.select { |setting| setting.events[action] }
+ settings.map(&:user_id)
+ end
+
+ def settings_with_global_level_of(level, ids)
+ NotificationSetting.where(
+ user_id: ids,
+ source_type: nil,
+ level: NotificationSetting.levels[level]
+ )
+ end
+
+ # Reject users which has certain notification level
+ #
+ # Example:
+ # reject_users(users, :watch, project)
+ #
+ def reject_users(users, level)
+ level = level.to_s
+
+ unless NotificationSetting.levels.keys.include?(level)
+ raise 'Invalid notification level'
+ end
+
+ users = users.to_a.compact.uniq
+ users = users.select { |u| u.can?(:receive_notifications) }
+
+ users.reject do |user|
+ global_notification_setting = user.global_notification_setting
+
+ next global_notification_setting.level == level unless project
+
+ setting = user.notification_settings_for(project)
+
+ if project.group && (setting.nil? || setting.global?)
+ setting = user.notification_settings_for(project.group)
+ end
+
+ # reject users who globally set mention notification and has no setting per project/group
+ next global_notification_setting.level == level unless setting
+
+ # reject users who set mention notification in project
+ next true if setting.level == level
+
+ # reject users who have mention level in project and disabled in global settings
+ setting.global? && global_notification_setting.level == level
+ end
+ end
+
+ def reject_unsubscribed_users(recipients, target)
+ return recipients unless target.respond_to? :subscriptions
+
+ recipients.reject do |user|
+ subscription = target.subscriptions.find_by_user_id(user.id)
+ subscription && !subscription.subscribed
+ end
+ end
+
+ def reject_users_without_access(recipients, target)
+ ability = case target
+ when Issuable
+ :"read_#{target.to_ability_name}"
+ when Ci::Pipeline
+ :read_build # We have build trace in pipeline emails
+ end
+
+ return recipients unless ability
+
+ recipients.select do |user|
+ user.can?(ability, target)
+ end
+ end
+
+ def add_labels_subscribers(recipients, target, labels: nil)
+ return recipients unless target.respond_to? :labels
+
+ (labels || target.labels).each do |label|
+ recipients += label.subscribers(project)
+ end
+
+ recipients
+ end
+
+ # Build event key to search on custom notification level
+ # Check NotificationSetting::EMAIL_EVENTS
+ def build_custom_key(action, object)
+ "#{action}_#{object.class.model_name.name.underscore}".to_sym
+ end
+end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index d12692ecc90..2c6f849259e 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -150,7 +150,10 @@ class NotificationService
end
def resolve_all_discussions(merge_request, current_user)
- recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
+ recipients = NotificationRecipientService.new(merge_request.target_project).build_recipients(
+ merge_request,
+ current_user,
+ action: "resolve_all_discussions")
recipients.each do |recipient|
mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
@@ -164,64 +167,15 @@ class NotificationService
end
# Notify users on new note in system
- #
- # TODO: split on methods and refactor
- #
def new_note(note)
return true unless note.noteable_type.present?
# ignore gitlab service messages
return true if note.cross_reference? && note.system?
- target = note.noteable
-
- recipients = []
-
- mentioned_users = note.mentioned_users
-
- ability, subject = if note.for_personal_snippet?
- [:read_personal_snippet, note.noteable]
- else
- [:read_project, note.project]
- end
-
- mentioned_users.select! do |user|
- user.can?(ability, subject)
- end
-
- # Add all users participating in the thread (author, assignee, comment authors)
- participants =
- if target.respond_to?(:participants)
- target.participants(note.author)
- else
- mentioned_users
- end
-
- recipients = recipients.concat(participants)
-
- unless note.for_personal_snippet?
- # Merge project watchers
- recipients = add_project_watchers(recipients, note.project)
-
- # Merge project with custom notification
- recipients = add_custom_notifications(recipients, note.project, :new_note)
- end
-
- # Reject users with Mention notification level, except those mentioned in _this_ note.
- recipients = reject_mention_users(recipients - mentioned_users, note.project)
- recipients = recipients + mentioned_users
-
- recipients = reject_muted_users(recipients, note.project)
-
- recipients = add_subscribed_users(recipients, note.project, note.noteable)
- recipients = reject_unsubscribed_users(recipients, note.noteable)
- recipients = reject_users_without_access(recipients, note.noteable)
-
- recipients.delete(note.author) unless note.author.notified_of_own_activity?
- recipients = recipients.uniq
-
notify_method = "note_#{note.to_ability_name}_email".to_sym
+ recipients = NotificationRecipientService.new(note.project).build_new_note_recipients(note)
recipients.each do |recipient|
mailer.send(notify_method, recipient.id, note.id).deliver_later
end
@@ -290,7 +244,7 @@ class NotificationService
def project_was_moved(project, old_path_with_namespace)
recipients = project.team.members
- recipients = reject_muted_users(recipients, project)
+ recipients = NotificationRecipientService.new(project).reject_muted_users(recipients)
recipients.each do |recipient|
mailer.project_was_moved_email(
@@ -302,7 +256,7 @@ class NotificationService
end
def issue_moved(issue, new_issue, current_user)
- recipients = build_recipients(issue, issue.project, current_user)
+ recipients = NotificationRecipientService.new(issue.project).build_recipients(issue, current_user)
recipients.map do |recipient|
email = mailer.issue_moved_email(recipient, issue, new_issue, current_user)
@@ -324,9 +278,8 @@ class NotificationService
return unless mailer.respond_to?(email_template)
- recipients ||= build_recipients(
+ recipients ||= NotificationRecipientService.new(pipeline.project).build_recipients(
pipeline,
- pipeline.project,
pipeline.user,
action: pipeline.status,
skip_current_user: false).map(&:notification_email)
@@ -338,199 +291,8 @@ class NotificationService
protected
- # Get project/group users with CUSTOM notification level
- def add_custom_notifications(recipients, project, action)
- user_ids = []
-
- # Users with a notification setting on group or project
- user_ids += notification_settings_for(project, :custom, action)
- user_ids += notification_settings_for(project.group, :custom, action)
-
- # Users with global level custom
- users_with_project_level_global = notification_settings_for(project, :global)
- users_with_group_level_global = notification_settings_for(project.group, :global)
-
- global_users_ids = users_with_project_level_global.concat(users_with_group_level_global)
- user_ids += users_with_global_level_custom(global_users_ids, action)
-
- recipients.concat(User.find(user_ids))
- end
-
- # Get project users with WATCH notification level
- def project_watchers(project)
- project_members = notification_settings_for(project)
-
- users_with_project_level_global = notification_settings_for(project, :global)
- users_with_group_level_global = notification_settings_for(project.group, :global)
-
- users = users_with_global_level_watch([users_with_project_level_global, users_with_group_level_global].flatten.uniq)
-
- users_with_project_setting = select_project_member_setting(project, users_with_project_level_global, users)
- users_with_group_setting = select_group_member_setting(project.group, project_members, users_with_group_level_global, users)
-
- User.where(id: users_with_project_setting.concat(users_with_group_setting).uniq).to_a
- end
-
- def notification_settings_for(resource, notification_level = nil, action = nil)
- return [] unless resource
-
- if notification_level
- settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level])
- settings = settings.select { |setting| setting.events[action] } if action.present?
- settings.map(&:user_id)
- else
- resource.notification_settings.pluck(:user_id)
- end
- end
-
- def users_with_global_level_watch(ids)
- settings_with_global_level_of(:watch, ids).pluck(:user_id)
- end
-
- def users_with_global_level_custom(ids, action)
- settings = settings_with_global_level_of(:custom, ids)
- settings = settings.select { |setting| setting.events[action] }
- settings.map(&:user_id)
- end
-
- def settings_with_global_level_of(level, ids)
- NotificationSetting.where(
- user_id: ids,
- source_type: nil,
- level: NotificationSetting.levels[level]
- )
- end
-
- # Build a list of users based on project notification settings
- def select_project_member_setting(project, global_setting, users_global_level_watch)
- users = notification_settings_for(project, :watch)
-
- # If project setting is global, add to watch list if global setting is watch
- global_setting.each do |user_id|
- if users_global_level_watch.include?(user_id)
- users << user_id
- end
- end
-
- users
- end
-
- # Build a list of users based on group notification settings
- def select_group_member_setting(group, project_members, global_setting, users_global_level_watch)
- uids = notification_settings_for(group, :watch)
-
- # Group setting is watch, add to users list if user is not project member
- users = []
- uids.each do |user_id|
- if project_members.exclude?(user_id)
- users << user_id
- end
- end
-
- # Group setting is global, add to users list if global setting is watch
- global_setting.each do |user_id|
- if project_members.exclude?(user_id) && users_global_level_watch.include?(user_id)
- users << user_id
- end
- end
-
- users
- end
-
- def add_project_watchers(recipients, project)
- recipients.concat(project_watchers(project)).compact
- end
-
- # Remove users with disabled notifications from array
- # Also remove duplications and nil recipients
- def reject_muted_users(users, project = nil)
- reject_users(users, :disabled, project)
- end
-
- # Remove users with notification level 'Mentioned'
- def reject_mention_users(users, project = nil)
- reject_users(users, :mention, project)
- end
-
- # Reject users which has certain notification level
- #
- # Example:
- # reject_users(users, :watch, project)
- #
- def reject_users(users, level, project = nil)
- level = level.to_s
-
- unless NotificationSetting.levels.keys.include?(level)
- raise 'Invalid notification level'
- end
-
- users = users.to_a.compact.uniq
- users = users.select { |u| u.can?(:receive_notifications) }
-
- users.reject do |user|
- global_notification_setting = user.global_notification_setting
-
- next global_notification_setting.level == level unless project
-
- setting = user.notification_settings_for(project)
-
- if project.group && (setting.nil? || setting.global?)
- setting = user.notification_settings_for(project.group)
- end
-
- # reject users who globally set mention notification and has no setting per project/group
- next global_notification_setting.level == level unless setting
-
- # reject users who set mention notification in project
- next true if setting.level == level
-
- # reject users who have mention level in project and disabled in global settings
- setting.global? && global_notification_setting.level == level
- end
- end
-
- def reject_unsubscribed_users(recipients, target)
- return recipients unless target.respond_to? :subscriptions
-
- recipients.reject do |user|
- subscription = target.subscriptions.find_by_user_id(user.id)
- subscription && !subscription.subscribed
- end
- end
-
- def reject_users_without_access(recipients, target)
- ability = case target
- when Issuable
- :"read_#{target.to_ability_name}"
- when Ci::Pipeline
- :read_build # We have build trace in pipeline emails
- end
-
- return recipients unless ability
-
- recipients.select do |user|
- user.can?(ability, target)
- end
- end
-
- def add_subscribed_users(recipients, project, target)
- return recipients unless target.respond_to? :subscribers
-
- recipients + target.subscribers(project)
- end
-
- def add_labels_subscribers(recipients, project, target, labels: nil)
- return recipients unless target.respond_to? :labels
-
- (labels || target.labels).each do |label|
- recipients += label.subscribers(project)
- end
-
- recipients
- end
-
def new_resource_email(target, project, method)
- recipients = build_recipients(target, project, target.author, action: "new")
+ recipients = NotificationRecipientService.new(project).build_recipients(target, target.author, action: "new")
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id).deliver_later
@@ -538,7 +300,7 @@ class NotificationService
end
def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method)
- recipients = build_recipients(target, project, current_user, action: "new")
+ recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "new")
recipients = recipients & new_mentioned_users
recipients.each do |recipient|
@@ -549,9 +311,8 @@ class NotificationService
def close_resource_email(target, project, current_user, method, skip_current_user: true)
action = method == :merged_merge_request_email ? "merge" : "close"
- recipients = build_recipients(
+ recipients = NotificationRecipientService.new(project).build_recipients(
target,
- project,
current_user,
action: action,
skip_current_user: skip_current_user
@@ -566,7 +327,12 @@ class NotificationService
previous_assignee_id = previous_record(target, 'assignee_id')
previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
- recipients = build_recipients(target, project, current_user, action: "reassign", previous_assignee: previous_assignee)
+ recipients = NotificationRecipientService.new(project).build_recipients(
+ target,
+ current_user,
+ action: "reassign",
+ previous_assignee: previous_assignee
+ )
recipients.each do |recipient|
mailer.send(
@@ -580,7 +346,7 @@ class NotificationService
end
def relabeled_resource_email(target, project, labels, current_user, method)
- recipients = build_relabeled_recipients(target, project, current_user, labels: labels)
+ recipients = NotificationRecipientService.new(project).build_relabeled_recipients(target, current_user, labels: labels)
label_names = labels.map(&:name)
recipients.each do |recipient|
@@ -589,58 +355,13 @@ class NotificationService
end
def reopen_resource_email(target, project, current_user, method, status)
- recipients = build_recipients(target, project, current_user, action: "reopen")
+ recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "reopen")
recipients.each do |recipient|
mailer.send(method, recipient.id, target.id, status, current_user.id).deliver_later
end
end
- def build_recipients(target, project, current_user, action: nil, previous_assignee: nil, skip_current_user: true)
- custom_action = build_custom_key(action, target)
-
- recipients = target.participants(current_user)
-
- unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action)
- recipients = add_project_watchers(recipients, project)
- end
-
- recipients = add_custom_notifications(recipients, project, custom_action)
- recipients = reject_mention_users(recipients, project)
-
- recipients = recipients.uniq
-
- # Re-assign is considered as a mention of the new assignee so we add the
- # new assignee to the list of recipients after we rejected users with
- # the "on mention" notification level
- if [:reassign_merge_request, :reassign_issue].include?(custom_action)
- recipients << previous_assignee if previous_assignee
- recipients << target.assignee
- end
-
- recipients = reject_muted_users(recipients, project)
- recipients = add_subscribed_users(recipients, project, target)
-
- if [:new_issue, :new_merge_request].include?(custom_action)
- recipients = add_labels_subscribers(recipients, project, target)
- end
-
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
-
- recipients.delete(current_user) if skip_current_user && !current_user.notified_of_own_activity?
-
- recipients.uniq
- end
-
- def build_relabeled_recipients(target, project, current_user, labels:)
- recipients = add_labels_subscribers([], project, target, labels: labels)
- recipients = reject_unsubscribed_users(recipients, target)
- recipients = reject_users_without_access(recipients, target)
- recipients.delete(current_user) unless current_user.notified_of_own_activity?
- recipients.uniq
- end
-
def mailer
Notify
end
@@ -652,10 +373,4 @@ class NotificationService
end
end
end
-
- # Build event key to search on custom notification level
- # Check NotificationSetting::EMAIL_EVENTS
- def build_custom_key(action, object)
- "#{action}_#{object.class.model_name.name.underscore}".to_sym
- end
end
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
index 188198c47d5..61420fd0fb6 100644
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_settings.html.haml
@@ -13,3 +13,7 @@
= form.label :only_allow_merge_if_all_discussions_are_resolved do
= form.check_box :only_allow_merge_if_all_discussions_are_resolved
%strong Only allow merge requests to be merged if all discussions are resolved
+ .checkbox
+ = form.label :printing_merge_request_link_enabled do
+ = form.check_box :printing_merge_request_link_enabled
+ %strong Show link to create/view merge request when pushing from the command line
diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml
deleted file mode 100644
index 0af40ddf8fe..00000000000
--- a/app/views/projects/boards/components/_blank_state.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-%board-blank-state{ "inline-template" => true,
- "v-if" => 'list.id == "blank"' }
- .board-blank-state
- %p
- Add the following default lists to your Issue Board with one click:
- %ul.board-blank-state-list
- %li{ "v-for" => "label in predefinedLabels" }
- %span.label-color{ ":style" => "{ backgroundColor: label.color } " }
- {{ label.title }}
- %p
- Starting out with the default set of lists will get you right on the way to making the most of your board.
- %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
- Add default lists
- %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
- Nevermind, I'll use my own
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index 72bce4049de..0bca6a786cb 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -32,4 +32,4 @@
":root-path" => "rootPath",
"ref" => "board-list" }
- if can?(current_user, :admin_list, @project)
- = render "projects/boards/components/blank_state"
+ %board-blank-state{ "v-if" => 'list.id == "blank"' }
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 58c085cdb9d..85e442e115c 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -5,6 +5,7 @@
= render 'shared/no_ssh'
= render 'shared/no_password'
+= render "projects/head"
= render "home_panel"
.row-content-block.second-block.center
diff --git a/changelogs/unreleased/21451-allow-disable-mr-link.yml b/changelogs/unreleased/21451-allow-disable-mr-link.yml
new file mode 100644
index 00000000000..ef99970a7a2
--- /dev/null
+++ b/changelogs/unreleased/21451-allow-disable-mr-link.yml
@@ -0,0 +1,4 @@
+---
+title: Add ability to disable Merge Request URL on push
+merge_request: 9663
+author: Alex Sanford
diff --git a/changelogs/unreleased/26470-branch-names-with-reference-prefixes-results-in-buggy-branches.yml b/changelogs/unreleased/26470-branch-names-with-reference-prefixes-results-in-buggy-branches.yml
new file mode 100644
index 00000000000..e82cbf00cfb
--- /dev/null
+++ b/changelogs/unreleased/26470-branch-names-with-reference-prefixes-results-in-buggy-branches.yml
@@ -0,0 +1,4 @@
+---
+title: Strip reference prefixes on branch creation
+merge_request: 8498
+author: Matthieu Tardy
diff --git a/changelogs/unreleased/29604-v3-fix-branch-creation.yml b/changelogs/unreleased/29604-v3-fix-branch-creation.yml
new file mode 100644
index 00000000000..25687e8be97
--- /dev/null
+++ b/changelogs/unreleased/29604-v3-fix-branch-creation.yml
@@ -0,0 +1,4 @@
+---
+title: Use "branch_name" instead "branch" on V3 branch creation API
+merge_request:
+author:
diff --git a/changelogs/unreleased/add-labels-to-issue-hook.yml b/changelogs/unreleased/add-labels-to-issue-hook.yml
new file mode 100644
index 00000000000..967430ee09f
--- /dev/null
+++ b/changelogs/unreleased/add-labels-to-issue-hook.yml
@@ -0,0 +1,4 @@
+---
+title: Added labels array to the issue web hook returned object
+merge_request: 9972
+author:
diff --git a/changelogs/unreleased/feature-use-gitaly-for-commit-show.yml b/changelogs/unreleased/feature-use-gitaly-for-commit-show.yml
new file mode 100644
index 00000000000..4b668d994a1
--- /dev/null
+++ b/changelogs/unreleased/feature-use-gitaly-for-commit-show.yml
@@ -0,0 +1,4 @@
+---
+title: Use Gitaly for CommitController#show
+merge_request: 9629
+author:
diff --git a/changelogs/unreleased/fl-remove-ujs-pipelines.yml b/changelogs/unreleased/fl-remove-ujs-pipelines.yml
new file mode 100644
index 00000000000..f353400753a
--- /dev/null
+++ b/changelogs/unreleased/fl-remove-ujs-pipelines.yml
@@ -0,0 +1,4 @@
+---
+title: 'Removes UJS from pipelines tables'
+merge_request: 9929
+author:
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 2b018c68703..ecd73956488 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -42,7 +42,7 @@ Sidekiq.configure_server do |config|
Gitlab::SidekiqThrottler.execute!
- config = ActiveRecord::Base.configurations[Rails.env] ||
+ config = Gitlab::Database.config ||
Rails.application.config.database_configuration[Rails.env]
config['pool'] = Sidekiq.options[:concurrency]
ActiveRecord::Base.establish_connection(config)
diff --git a/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb b/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb
new file mode 100644
index 00000000000..f54608ecceb
--- /dev/null
+++ b/db/migrate/20170301125302_add_printing_merge_request_link_enabled_to_project.rb
@@ -0,0 +1,18 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPrintingMergeRequestLinkEnabledToProject < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def up
+ add_column_with_default(:projects, :printing_merge_request_link_enabled, :boolean, default: true)
+ end
+
+ def down
+ remove_column(:projects, :printing_merge_request_link_enabled)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index cef2f0b0510..61d965fc05f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1003,6 +1003,7 @@ ActiveRecord::Schema.define(version: 20170317131326) do
t.boolean "lfs_enabled"
t.text "description_html"
t.boolean "only_allow_merge_if_all_discussions_are_resolved"
+ t.boolean "printing_merge_request_link_enabled", default: true, null: false
end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
diff --git a/doc/development/frontend.md b/doc/development/frontend.md
index 766b5f1f477..50105a486d0 100644
--- a/doc/development/frontend.md
+++ b/doc/development/frontend.md
@@ -36,16 +36,23 @@ You can find documentation about the desired architecture for a new feature buil
When writing code for realtime features we have to keep a couple of things in mind:
1. Do not overload the server with requests.
-1. It should feel realtime.
+1. It should feel realtime.
-Thus, we must strike a balance between sending requests and the feeling of realtime. Use the following rules when creating realtime solutions.
+Thus, we must strike a balance between sending requests and the feeling of realtime.
+Use the following rules when creating realtime solutions.
-1. The server will tell you how much to poll by sending `X-Poll-Interval` in the header. Use that as your polling interval. This way it is easy for system administrators to change the polling rate. A `X-Poll-Interval: -1` means you should disable polling, and this must be implemented.
-1. A response of `HTTP 429 Too Many Requests`, should disable polling as well. This must also be implemented.
+1. The server will tell you how much to poll by sending `Poll-Interval` in the header.
+Use that as your polling interval. This way it is easy for system administrators to change the
+polling rate.
+A `Poll-Interval: -1` means you should disable polling, and this must be implemented.
+1. A response with HTTP status `4XX` or `5XX` should disable polling as well.
1. Use a common library for polling.
-1. Poll on active tabs only. Use a common library to find out which tab currently has eyes on it. Please use [Focus](https://gitlab.com/andrewn/focus). Specifically [Eyeballs Detector](https://gitlab.com/andrewn/focus/blob/master/lib/eyeballs-detector.js).
-1. Use regular polling intervals, do not use backoff polling, or jitter, as the interval will be controlled by the server.
-1. The backend code will most likely be using etags. You do not and should not check for status `304 Not Modified`. The browser will transform it for you.
+1. Poll on active tabs only. Use a common library to find out which tab currently has eyes on it.
+Please use [Focus](https://gitlab.com/andrewn/focus). Specifically [Eyeballs Detector](https://gitlab.com/andrewn/focus/blob/master/lib/eyeballs-detector.js).
+1. Use regular polling intervals, do not use backoff polling, or jitter, as the interval will be
+controlled by the server.
+1. The backend code will most likely be using etags. You do not and should not check for status
+`304 Not Modified`. The browser will transform it for you.
### Vue
diff --git a/doc/user/award_emojis.md b/doc/user/award_emojis.md
new file mode 100644
index 00000000000..acbd2a66d37
--- /dev/null
+++ b/doc/user/award_emojis.md
@@ -0,0 +1,51 @@
+# Award emoji
+
+>**Notes:**
+- First [introduced][1825] in GitLab 8.2.
+- GitLab 9.0 [introduced][ce-9570] the usage of native emojis if the platform
+ supports them and falls back to images or CSS sprites. This change greatly
+ improved the award emoji performance overall.
+
+When you're collaborating online, you get fewer opportunities for high-fives
+and thumbs-ups. Emoji can be awarded to issues, merge requests, snippets, and
+virtually everywhere where you can have a discussion.
+
+![Award emoji](img/award_emoji_select.png)
+
+Award emoji make it much easier to give and receive feedback without a long
+comment thread. Comments that are only emoji will automatically become
+award emoji.
+
+## Sort issues and merge requests on vote count
+
+> [Introduced][2871] in GitLab 8.5.
+
+You can quickly sort issues and merge requests by the number of votes they
+have received. The sort options can be found in the dropdown menu as "Most
+popular" and "Least popular".
+
+![Votes sort options](img/award_emoji_votes_sort_options.png)
+
+The total number of votes is not summed up. An issue with 18 upvotes and 5
+downvotes is considered more popular than an issue with 17 upvotes and no
+downvotes.
+
+## Award emoji for comments
+
+> [Introduced][4291] in GitLab 8.9.
+
+Award emoji can also be applied to individual comments when you want to
+celebrate an accomplishment or agree with an opinion.
+
+To add an award emoji, click the smile in the top right of the comment and pick
+an emoji from the dropdown. If you want to remove an award emoji, just click
+the emoji again and the vote will be removed.
+
+![Picking an emoji for a comment](img/award_emoji_comment_picker.png)
+
+![An award emoji has been applied to a comment](img/award_emoji_comment_awarded.png)
+
+[2871]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2781
+[1825]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1825
+[4291]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4291
+[ce-9570]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9570
diff --git a/doc/workflow/img/award_emoji_comment_awarded.png b/doc/user/img/award_emoji_comment_awarded.png
index 111793ebf8a..111793ebf8a 100644
--- a/doc/workflow/img/award_emoji_comment_awarded.png
+++ b/doc/user/img/award_emoji_comment_awarded.png
Binary files differ
diff --git a/doc/workflow/img/award_emoji_comment_picker.png b/doc/user/img/award_emoji_comment_picker.png
index 3ad1bab3119..3ad1bab3119 100644
--- a/doc/workflow/img/award_emoji_comment_picker.png
+++ b/doc/user/img/award_emoji_comment_picker.png
Binary files differ
diff --git a/doc/user/img/award_emoji_select.png b/doc/user/img/award_emoji_select.png
new file mode 100644
index 00000000000..496acb29eec
--- /dev/null
+++ b/doc/user/img/award_emoji_select.png
Binary files differ
diff --git a/doc/user/img/award_emoji_votes_sort_options.png b/doc/user/img/award_emoji_votes_sort_options.png
new file mode 100644
index 00000000000..dd84b7f4f64
--- /dev/null
+++ b/doc/user/img/award_emoji_votes_sort_options.png
Binary files differ
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index ed1e867f5fb..dbdc93a77a8 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -250,7 +250,19 @@ X-Gitlab-Event: Issue Hook
"name": "User1",
"username": "user1",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
- }
+ },
+ "labels": [{
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }]
}
```
### Comment events
diff --git a/doc/workflow/award_emoji.md b/doc/workflow/award_emoji.md
index 1df0698afd0..d74378cc564 100644
--- a/doc/workflow/award_emoji.md
+++ b/doc/workflow/award_emoji.md
@@ -1,65 +1 @@
-# Award emoji
-
->**Note:**
-[Introduced][1825] in GitLab 8.2.
-
-When you're collaborating online, you get fewer opportunities for high-fives
-and thumbs-ups. Emoji can be awarded to issues and merge requests, making
-virtual celebrations easier.
-
-![Award emoji](img/award_emoji_select.png)
-
-Award emoji make it much easier to give and receive feedback without a long
-comment thread. Comments that are only emoji will automatically become
-award emoji.
-
-## Sort issues and merge requests on vote count
-
->**Note:**
-[Introduced][2871] in GitLab 8.5.
-
-You can quickly sort issues and merge requests by the number of votes they
-have received. The sort options can be found in the dropdown menu as "Most
-popular" and "Least popular".
-
-![Votes sort options](img/award_emoji_votes_sort_options.png)
-
----
-
-Sort by most popular issues/merge requests.
-
-![Votes sort by most popular](img/award_emoji_votes_most_popular.png)
-
----
-
-Sort by least popular issues/merge requests.
-
-![Votes sort by least popular](img/award_emoji_votes_least_popular.png)
-
----
-
-The total number of votes is not summed up. An issue with 18 upvotes and 5
-downvotes is considered more popular than an issue with 17 upvotes and no
-downvotes.
-
-## Award emoji for comments
-
->**Note:**
-[Introduced][4291] in GitLab 8.9.
-
-Award emoji can also be applied to individual comments when you want to
-celebrate an accomplishment or agree with an opinion.
-
-To add an award emoji, click the smile in the top right of the comment and pick
-an emoji from the dropdown.
-
-![Picking an emoji for a comment](img/award_emoji_comment_picker.png)
-
-![An award emoji has been applied to a comment](img/award_emoji_comment_awarded.png)
-
-If you want to remove an award emoji, just click the emoji again and the vote
-will be removed.
-
-[2871]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2781
-[1825]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1825
-[4291]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4291
+This document was moved to [another location](../user/award_emojis.md).
diff --git a/doc/workflow/img/award_emoji_select.png b/doc/workflow/img/award_emoji_select.png
deleted file mode 100644
index e1b37beaf62..00000000000
--- a/doc/workflow/img/award_emoji_select.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/img/award_emoji_votes_least_popular.png b/doc/workflow/img/award_emoji_votes_least_popular.png
deleted file mode 100644
index 86ede4b0c10..00000000000
--- a/doc/workflow/img/award_emoji_votes_least_popular.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/img/award_emoji_votes_most_popular.png b/doc/workflow/img/award_emoji_votes_most_popular.png
deleted file mode 100644
index 1d3e2e57aa0..00000000000
--- a/doc/workflow/img/award_emoji_votes_most_popular.png
+++ /dev/null
Binary files differ
diff --git a/doc/workflow/img/award_emoji_votes_sort_options.png b/doc/workflow/img/award_emoji_votes_sort_options.png
deleted file mode 100644
index c6dc1b939c1..00000000000
--- a/doc/workflow/img/award_emoji_votes_sort_options.png
+++ /dev/null
Binary files differ
diff --git a/lib/api/v3/branches.rb b/lib/api/v3/branches.rb
index 7d9d6246e46..0a877b960f6 100644
--- a/lib/api/v3/branches.rb
+++ b/lib/api/v3/branches.rb
@@ -45,6 +45,27 @@ module API
status(200)
end
+
+ desc 'Create branch' do
+ success ::API::Entities::RepoBranch
+ end
+ params do
+ requires :branch_name, type: String, desc: 'The name of the branch'
+ requires :ref, type: String, desc: 'Create branch from commit sha or existing branch'
+ end
+ post ":id/repository/branches" do
+ authorize_push_project
+ result = CreateBranchService.new(user_project, current_user).
+ execute(params[:branch_name], params[:ref])
+
+ if result[:status] == :success
+ present result[:branch],
+ with: ::API::Entities::RepoBranch,
+ project: user_project
+ else
+ render_api_error!(result[:message], 400)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index f3f417c1a63..63b8d0d3b9d 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -5,8 +5,12 @@ module Gitlab
# http://dev.mysql.com/doc/refman/5.7/en/integer-types.html
MAX_INT_VALUE = 2147483647
+ def self.config
+ ActiveRecord::Base.configurations[Rails.env]
+ end
+
def self.adapter_name
- ActiveRecord::Base.configurations[Rails.env]['adapter']
+ config['adapter']
end
def self.mysql?
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index 2a017c93f57..019be151353 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -176,9 +176,13 @@ module Gitlab
def initialize(raw_diff, collapse: false)
case raw_diff
when Hash
- init_from_hash(raw_diff, collapse: collapse)
+ init_from_hash(raw_diff)
+ prune_diff_if_eligible(collapse)
when Rugged::Patch, Rugged::Diff::Delta
init_from_rugged(raw_diff, collapse: collapse)
+ when Gitaly::CommitDiffResponse
+ init_from_gitaly(raw_diff)
+ prune_diff_if_eligible(collapse)
when nil
raise "Nil as raw diff passed"
else
@@ -266,13 +270,26 @@ module Gitlab
@diff = encode!(strip_diff_headers(patch.to_s))
end
- def init_from_hash(hash, collapse: false)
+ def init_from_hash(hash)
raw_diff = hash.symbolize_keys
serialize_keys.each do |key|
send(:"#{key}=", raw_diff[key.to_sym])
end
+ end
+
+ def init_from_gitaly(diff_msg)
+ @diff = diff_msg.raw_chunks.join
+ @new_path = encode!(diff_msg.to_path.dup)
+ @old_path = encode!(diff_msg.from_path.dup)
+ @a_mode = diff_msg.old_mode.to_s(8)
+ @b_mode = diff_msg.new_mode.to_s(8)
+ @new_file = diff_msg.from_id == BLANK_SHA
+ @renamed_file = diff_msg.from_path != diff_msg.to_path
+ @deleted_file = diff_msg.to_id == BLANK_SHA
+ end
+ def prune_diff_if_eligible(collapse = false)
prune_large_diff! if too_large?
prune_collapsed_diff! if collapse && collapsible?
end
diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb
index 65e06f5065d..4e45ec7c174 100644
--- a/lib/gitlab/git/diff_collection.rb
+++ b/lib/gitlab/git/diff_collection.rb
@@ -30,7 +30,9 @@ module Gitlab
elsif @deltas_only
each_delta(&block)
else
- each_patch(&block)
+ Gitlab::GitalyClient.migrate(:commit_raw_diffs) do
+ each_patch(&block)
+ end
end
end
diff --git a/lib/gitlab/git_ref_validator.rb b/lib/gitlab/git_ref_validator.rb
index 4d83d8e72a8..0e87ee30c98 100644
--- a/lib/gitlab/git_ref_validator.rb
+++ b/lib/gitlab/git_ref_validator.rb
@@ -5,6 +5,9 @@ module Gitlab
#
# Returns true for a valid reference name, false otherwise
def validate(ref_name)
+ return false if ref_name.start_with?('refs/heads/')
+ return false if ref_name.start_with?('refs/remotes/')
+
Gitlab::Utils.system_silent(
%W(#{Gitlab.config.git.bin_path} check-ref-format refs/#{ref_name}))
end
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index b981a629fb0..5534d4af439 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -25,5 +25,19 @@ module Gitlab
def self.enabled?
gitaly_address.present?
end
+
+ def self.feature_enabled?(feature)
+ enabled? && ENV["GITALY_#{feature.upcase}"] == '1'
+ end
+
+ def self.migrate(feature)
+ is_enabled = feature_enabled?(feature)
+ metric_name = feature.to_s
+ metric_name += "_gitaly" if is_enabled
+
+ Gitlab::Metrics.measure(metric_name) do
+ yield is_enabled
+ end
+ end
end
end
diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb
new file mode 100644
index 00000000000..525b8d680e9
--- /dev/null
+++ b/lib/gitlab/gitaly_client/commit.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module GitalyClient
+ class Commit
+ # The ID of empty tree.
+ # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012
+ EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze
+
+ class << self
+ def diff_from_parent(commit, options = {})
+ stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: GitalyClient.channel)
+ repo = Gitaly::Repository.new(path: commit.project.repository.path_to_repo)
+ parent = commit.parents[0]
+ parent_id = parent ? parent.id : EMPTY_TREE_ID
+ request = Gitaly::CommitDiffRequest.new(
+ repository: repo,
+ left_commit_id: parent_id,
+ right_commit_id: commit.id
+ )
+
+ Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options)
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/main/entry.rb b/qa/qa/page/main/entry.rb
index fe80deb6429..a9810beeb29 100644
--- a/qa/qa/page/main/entry.rb
+++ b/qa/qa/page/main/entry.rb
@@ -5,8 +5,14 @@ module QA
def initialize
visit('/')
- # This resolves cold boot problems with login page
- find('.application', wait: 120)
+ # This resolves cold boot / background tasks problems
+ #
+ start = Time.now
+
+ while Time.now - start < 240
+ break if page.has_css?('.application', wait: 10)
+ refresh
+ end
end
def sign_in_using_credentials
diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb
index 73c5ef31edc..18833ba7266 100644
--- a/spec/features/merge_requests/created_from_fork_spec.rb
+++ b/spec/features/merge_requests/created_from_fork_spec.rb
@@ -60,9 +60,6 @@ feature 'Merge request created from fork' do
expect(page).to have_content pipeline.status
expect(page).to have_content pipeline.id
end
-
- expect(page.find('a.btn-remove')[:href])
- .to include fork_project.path_with_namespace
end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 22bf1bfbdf0..162056671e0 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -99,15 +99,18 @@ describe 'Pipelines', :feature, :js do
end
it 'indicates that pipeline can be canceled' do
- expect(page).to have_link('Cancel')
+ expect(page).to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-running')
end
context 'when canceling' do
- before { click_link('Cancel') }
+ before do
+ find('.js-pipelines-cancel-button').click
+ wait_for_vue_resource
+ end
it 'indicated that pipelines was canceled' do
- expect(page).not_to have_link('Cancel')
+ expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled')
end
end
@@ -126,15 +129,18 @@ describe 'Pipelines', :feature, :js do
end
it 'indicates that pipeline can be retried' do
- expect(page).to have_link('Retry')
+ expect(page).to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-failed')
end
context 'when retrying' do
- before { click_link('Retry') }
+ before do
+ find('.js-pipelines-retry-button').click
+ wait_for_vue_resource
+ end
it 'shows running pipeline that is not retryable' do
- expect(page).not_to have_link('Retry')
+ expect(page).not_to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-running')
end
end
@@ -176,17 +182,17 @@ describe 'Pipelines', :feature, :js do
it 'has link to the manual action' do
find('.js-pipeline-dropdown-manual-actions').click
- expect(page).to have_link('manual build')
+ expect(page).to have_button('manual build')
end
context 'when manual action was played' do
before do
find('.js-pipeline-dropdown-manual-actions').click
- click_link('manual build')
+ click_button('manual build')
end
it 'enqueues manual action job' do
- expect(manual.reload).to be_pending
+ expect(page).to have_selector('.js-pipeline-dropdown-manual-actions:disabled')
end
end
end
@@ -203,7 +209,7 @@ describe 'Pipelines', :feature, :js do
before { visit_project_pipelines }
it 'is cancelable' do
- expect(page).to have_link('Cancel')
+ expect(page).to have_selector('.js-pipelines-cancel-button')
end
it 'has pipeline running' do
@@ -211,10 +217,10 @@ describe 'Pipelines', :feature, :js do
end
context 'when canceling' do
- before { click_link('Cancel') }
+ before { find('.js-pipelines-cancel-button').trigger('click') }
it 'indicates that pipeline was canceled' do
- expect(page).not_to have_link('Cancel')
+ expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled')
end
end
@@ -233,7 +239,7 @@ describe 'Pipelines', :feature, :js do
end
it 'is not retryable' do
- expect(page).not_to have_link('Retry')
+ expect(page).not_to have_selector('.js-pipelines-retry-button')
end
it 'has failed pipeline' do
diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb
index 6815039d5ed..321af416c91 100644
--- a/spec/features/projects/settings/merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/merge_requests_settings_spec.rb
@@ -62,4 +62,27 @@ feature 'Project settings > Merge Requests', feature: true, js: true do
expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
end
end
+
+ describe 'Checkbox to enable merge request link' do
+ before do
+ visit edit_project_path(project)
+ end
+
+ scenario 'is initially checked' do
+ checkbox = find_field('project_printing_merge_request_link_enabled')
+ expect(checkbox).to be_checked
+ end
+
+ scenario 'when unchecked sets :printing_merge_request_link_enabled to false' do
+ uncheck('project_printing_merge_request_link_enabled')
+ click_on('Save')
+
+ # Wait for save to complete and page to reload
+ checkbox = find_field('project_printing_merge_request_link_enabled')
+ expect(checkbox).not_to be_checked
+
+ project.reload
+ expect(project.printing_merge_request_link_enabled).to be(false)
+ end
+ end
end
diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb
index 77a4ba305bb..3cb809d42b5 100644
--- a/spec/helpers/milestones_helper_spec.rb
+++ b/spec/helpers/milestones_helper_spec.rb
@@ -49,16 +49,20 @@ describe MilestonesHelper do
end
describe '#milestone_remaining_days' do
+ around do |example|
+ Timecop.freeze(Time.utc(2017, 3, 17)) { example.run }
+ end
+
context 'when less than 31 days remaining' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 12.days.from_now)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 12.days.from_now.utc)) }
it 'returns days remaining' do
- expect(milestone_remaining).to eq("<strong>11</strong> days remaining")
+ expect(milestone_remaining).to eq("<strong>12</strong> days remaining")
end
end
context 'when less than 1 year and more than 30 days remaining' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.months.from_now)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.months.from_now.utc)) }
it 'returns months remaining' do
expect(milestone_remaining).to eq("<strong>2</strong> months remaining")
@@ -66,7 +70,7 @@ describe MilestonesHelper do
end
context 'when more than 1 year remaining' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 1.year.from_now + 2.days)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: (1.year.from_now + 2.days).utc)) }
it 'returns years remaining' do
expect(milestone_remaining).to eq("<strong>1</strong> year remaining")
@@ -74,7 +78,7 @@ describe MilestonesHelper do
end
context 'when milestone is expired' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.days.ago)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.days.ago.utc)) }
it 'returns "Past due"' do
expect(milestone_remaining).to eq("<strong>Past due</strong>")
@@ -82,7 +86,7 @@ describe MilestonesHelper do
end
context 'when milestone has start_date in the future' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.from_now)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.from_now.utc)) }
it 'returns "Upcoming"' do
expect(milestone_remaining).to eq("<strong>Upcoming</strong>")
@@ -90,7 +94,7 @@ describe MilestonesHelper do
end
context 'when milestone has start_date in the past' do
- let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.ago)) }
+ let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.ago.utc)) }
it 'returns days elapsed' do
expect(milestone_remaining).to eq("<strong>2</strong> days elapsed")
diff --git a/spec/javascripts/boards/board_blank_state_spec.js b/spec/javascripts/boards/board_blank_state_spec.js
new file mode 100644
index 00000000000..47baf83512f
--- /dev/null
+++ b/spec/javascripts/boards/board_blank_state_spec.js
@@ -0,0 +1,93 @@
+/* global BoardService */
+import Vue from 'vue';
+import '~/boards/stores/boards_store';
+import boardBlankState from '~/boards/components/board_blank_state';
+import './mock_data';
+
+describe('Boards blank state', () => {
+ let vm;
+ let fail = false;
+
+ beforeEach((done) => {
+ const Comp = Vue.extend(boardBlankState);
+
+ gl.issueBoards.BoardsStore.create();
+ gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
+
+ spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
+ if (fail) {
+ reject();
+ } else {
+ resolve({
+ json() {
+ return [{
+ id: 1,
+ title: 'To Do',
+ label: { id: 1 },
+ }, {
+ id: 2,
+ title: 'Doing',
+ label: { id: 2 },
+ }];
+ },
+ });
+ }
+ }));
+
+ vm = new Comp();
+
+ setTimeout(() => {
+ vm.$mount();
+ done();
+ });
+ });
+
+ it('renders pre-defined labels', () => {
+ expect(
+ vm.$el.querySelectorAll('.board-blank-state-list li').length,
+ ).toBe(2);
+
+ expect(
+ vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim(),
+ ).toEqual('To Do');
+
+ expect(
+ vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim(),
+ ).toEqual('Doing');
+ });
+
+ it('clears blank state', (done) => {
+ vm.$el.querySelector('.btn-default').click();
+
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeTruthy();
+
+ done();
+ });
+ });
+
+ it('creates pre-defined labels', (done) => {
+ vm.$el.querySelector('.btn-create').click();
+
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
+ expect(gl.issueBoards.BoardsStore.state.lists[0].title).toEqual('To Do');
+ expect(gl.issueBoards.BoardsStore.state.lists[1].title).toEqual('Doing');
+
+ done();
+ });
+ });
+
+ it('resets the store if request fails', (done) => {
+ fail = true;
+
+ vm.$el.querySelector('.btn-create').click();
+
+ setTimeout(() => {
+ expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeFalsy();
+ expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js
index 188908d66bd..82b00b4c1ec 100644
--- a/spec/javascripts/commit/pipelines/mock_data.js
+++ b/spec/javascripts/commit/pipelines/mock_data.js
@@ -1,5 +1,4 @@
-/* eslint-disable no-unused-vars */
-const pipeline = {
+export default {
id: 73,
user: {
name: 'Administrator',
@@ -88,5 +87,3 @@ const pipeline = {
created_at: '2017-01-16T17:13:59.800Z',
updated_at: '2017-01-25T00:00:17.132Z',
};
-
-module.exports = pipeline;
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js
index f09c57978a1..75efcc06585 100644
--- a/spec/javascripts/commit/pipelines/pipelines_spec.js
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js
@@ -1,11 +1,6 @@
-/* global pipeline, Vue */
-
-require('~/flash');
-require('~/commit/pipelines/pipelines_store');
-require('~/commit/pipelines/pipelines_service');
-require('~/commit/pipelines/pipelines_table');
-require('~/vue_shared/vue_resource_interceptor');
-const pipeline = require('./mock_data');
+import Vue from 'vue';
+import PipelinesTable from '~/commit/pipelines/pipelines_table';
+import pipeline from './mock_data';
describe('Pipelines table in Commits and Merge requests', () => {
preloadFixtures('static/pipelines_table.html.raw');
@@ -33,7 +28,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
it('should render the empty state', (done) => {
- const component = new gl.commits.pipelines.PipelinesTableView({
+ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'),
});
@@ -62,7 +57,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
it('should render a table with the received pipelines', (done) => {
- const component = new gl.commits.pipelines.PipelinesTableView({
+ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'),
});
@@ -92,7 +87,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
it('should render empty state', (done) => {
- const component = new gl.commits.pipelines.PipelinesTableView({
+ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'),
});
diff --git a/spec/javascripts/commit/pipelines/pipelines_store_spec.js b/spec/javascripts/commit/pipelines/pipelines_store_spec.js
deleted file mode 100644
index 94973419979..00000000000
--- a/spec/javascripts/commit/pipelines/pipelines_store_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const PipelinesStore = require('~/commit/pipelines/pipelines_store');
-
-describe('Store', () => {
- let store;
-
- beforeEach(() => {
- store = new PipelinesStore();
- });
-
- // unregister intervals and event handlers
- afterEach(() => gl.VueRealtimeListener.reset());
-
- it('should start with a blank state', () => {
- expect(store.state.pipelines.length).toBe(0);
- });
-
- it('should store an array of pipelines', () => {
- const pipelines = [
- {
- id: '1',
- name: 'pipeline',
- },
- {
- id: '2',
- name: 'pipeline_2',
- },
- ];
-
- store.storePipelines(pipelines);
-
- expect(store.state.pipelines.length).toBe(pipelines.length);
- });
-});
diff --git a/spec/javascripts/vue_pipelines_index/async_button_spec.js b/spec/javascripts/vue_pipelines_index/async_button_spec.js
new file mode 100644
index 00000000000..bc8e504c413
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/async_button_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import asyncButtonComp from '~/vue_pipelines_index/components/async_button';
+
+describe('Pipelines Async Button', () => {
+ let component;
+ let spy;
+ let AsyncButtonComponent;
+
+ beforeEach(() => {
+ AsyncButtonComponent = Vue.extend(asyncButtonComp);
+
+ spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
+
+ component = new AsyncButtonComponent({
+ propsData: {
+ endpoint: '/foo',
+ title: 'Foo',
+ icon: 'fa fa-foo',
+ cssClass: 'bar',
+ service: {
+ postAction: spy,
+ },
+ },
+ }).$mount();
+ });
+
+ it('should render a button', () => {
+ expect(component.$el.tagName).toEqual('BUTTON');
+ });
+
+ it('should render the provided icon', () => {
+ expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo');
+ });
+
+ it('should render the provided title', () => {
+ expect(component.$el.getAttribute('title')).toContain('Foo');
+ expect(component.$el.getAttribute('aria-label')).toContain('Foo');
+ });
+
+ it('should render the provided cssClass', () => {
+ expect(component.$el.getAttribute('class')).toContain('bar');
+ });
+
+ it('should call the service when it is clicked with the provided endpoint', () => {
+ component.$el.click();
+ expect(spy).toHaveBeenCalledWith('/foo');
+ });
+
+ it('should hide loading if request fails', () => {
+ spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
+
+ component = new AsyncButtonComponent({
+ propsData: {
+ endpoint: '/foo',
+ title: 'Foo',
+ icon: 'fa fa-foo',
+ cssClass: 'bar',
+ dataAttributes: {
+ 'data-foo': 'foo',
+ },
+ service: {
+ postAction: spy,
+ },
+ },
+ }).$mount();
+
+ component.$el.click();
+ expect(component.$el.querySelector('.fa-spinner')).toBe(null);
+ });
+
+ describe('With confirm dialog', () => {
+ it('should call the service when confimation is positive', () => {
+ spyOn(window, 'confirm').and.returnValue(true);
+ spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
+
+ component = new AsyncButtonComponent({
+ propsData: {
+ endpoint: '/foo',
+ title: 'Foo',
+ icon: 'fa fa-foo',
+ cssClass: 'bar',
+ service: {
+ postAction: spy,
+ },
+ confirmActionMessage: 'bar',
+ },
+ }).$mount();
+
+ component.$el.click();
+ expect(spy).toHaveBeenCalledWith('/foo');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js b/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js
new file mode 100644
index 00000000000..96a2a37b5f7
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import pipelineUrlComp from '~/vue_pipelines_index/components/pipeline_url';
+
+describe('Pipeline Url Component', () => {
+ let PipelineUrlComponent;
+
+ beforeEach(() => {
+ PipelineUrlComponent = Vue.extend(pipelineUrlComp);
+ });
+
+ it('should render a table cell', () => {
+ const component = new PipelineUrlComponent({
+ propsData: {
+ pipeline: {
+ id: 1,
+ path: 'foo',
+ flags: {},
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.tagName).toEqual('TD');
+ });
+
+ it('should render a link the provided path and id', () => {
+ const component = new PipelineUrlComponent({
+ propsData: {
+ pipeline: {
+ id: 1,
+ path: 'foo',
+ flags: {},
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
+ expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
+ });
+
+ it('should render user information when a user is provided', () => {
+ const mockData = {
+ pipeline: {
+ id: 1,
+ path: 'foo',
+ flags: {},
+ user: {
+ web_url: '/',
+ name: 'foo',
+ avatar_url: '/',
+ },
+ },
+ };
+
+ const component = new PipelineUrlComponent({
+ propsData: mockData,
+ }).$mount();
+
+ const image = component.$el.querySelector('.js-pipeline-url-user img');
+
+ expect(
+ component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
+ ).toEqual(mockData.pipeline.user.web_url);
+ expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
+ expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
+ });
+
+ it('should render "API" when no user is provided', () => {
+ const component = new PipelineUrlComponent({
+ propsData: {
+ pipeline: {
+ id: 1,
+ path: 'foo',
+ flags: {},
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.js-pipeline-url-api').textContent).toContain('API');
+ });
+
+ it('should render latest, yaml invalid and stuck flags when provided', () => {
+ const component = new PipelineUrlComponent({
+ propsData: {
+ pipeline: {
+ id: 1,
+ path: 'foo',
+ flags: {
+ latest: true,
+ yaml_errors: true,
+ stuck: true,
+ },
+ },
+ },
+ }).$mount();
+
+ expect(component.$el.querySelector('.js-pipeline-url-lastest').textContent).toContain('latest');
+ expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
+ expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js
new file mode 100644
index 00000000000..dba998c7688
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js
@@ -0,0 +1,62 @@
+import Vue from 'vue';
+import pipelinesActionsComp from '~/vue_pipelines_index/components/pipelines_actions';
+
+describe('Pipelines Actions dropdown', () => {
+ let component;
+ let spy;
+ let actions;
+ let ActionsComponent;
+
+ beforeEach(() => {
+ ActionsComponent = Vue.extend(pipelinesActionsComp);
+
+ actions = [
+ {
+ name: 'stop_review',
+ path: '/root/review-app/builds/1893/play',
+ },
+ ];
+
+ spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
+
+ component = new ActionsComponent({
+ propsData: {
+ actions,
+ service: {
+ postAction: spy,
+ },
+ },
+ }).$mount();
+ });
+
+ it('should render a dropdown with the provided actions', () => {
+ expect(
+ component.$el.querySelectorAll('.dropdown-menu li').length,
+ ).toEqual(actions.length);
+ });
+
+ it('should call the service when an action is clicked', () => {
+ component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
+ component.$el.querySelector('.js-pipeline-action-link').click();
+
+ expect(spy).toHaveBeenCalledWith(actions[0].path);
+ });
+
+ it('should hide loading if request fails', () => {
+ spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
+
+ component = new ActionsComponent({
+ propsData: {
+ actions,
+ service: {
+ postAction: spy,
+ },
+ },
+ }).$mount();
+
+ component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
+ component.$el.querySelector('.js-pipeline-action-link').click();
+
+ expect(component.$el.querySelector('.fa-spinner')).toEqual(null);
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js
new file mode 100644
index 00000000000..f7f49649c1c
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import artifactsComp from '~/vue_pipelines_index/components/pipelines_artifacts';
+
+describe('Pipelines Artifacts dropdown', () => {
+ let component;
+ let artifacts;
+
+ beforeEach(() => {
+ const ArtifactsComponent = Vue.extend(artifactsComp);
+
+ artifacts = [
+ {
+ name: 'artifact',
+ path: '/download/path',
+ },
+ ];
+
+ component = new ArtifactsComponent({
+ propsData: {
+ artifacts,
+ },
+ }).$mount();
+ });
+
+ it('should render a dropdown with the provided artifacts', () => {
+ expect(
+ component.$el.querySelectorAll('.dropdown-menu li').length,
+ ).toEqual(artifacts.length);
+ });
+
+ it('should render a link with the provided path', () => {
+ expect(
+ component.$el.querySelector('.dropdown-menu li a').getAttribute('href'),
+ ).toEqual(artifacts[0].path);
+
+ expect(
+ component.$el.querySelector('.dropdown-menu li a span').textContent,
+ ).toContain(artifacts[0].name);
+ });
+});
diff --git a/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js
new file mode 100644
index 00000000000..5c0934404bb
--- /dev/null
+++ b/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js
@@ -0,0 +1,72 @@
+import PipelineStore from '~/vue_pipelines_index/stores/pipelines_store';
+
+describe('Pipelines Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ });
+
+ it('should be initialized with an empty state', () => {
+ expect(store.state.pipelines).toEqual([]);
+ expect(store.state.count).toEqual({});
+ expect(store.state.pageInfo).toEqual({});
+ });
+
+ describe('storePipelines', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storePipelines();
+ expect(store.state.pipelines).toEqual([]);
+ });
+
+ it('should store the provided array', () => {
+ const array = [{ id: 1, status: 'running' }, { id: 2, status: 'success' }];
+ store.storePipelines(array);
+ expect(store.state.pipelines).toEqual(array);
+ });
+ });
+
+ describe('storeCount', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storeCount();
+ expect(store.state.count).toEqual({});
+ });
+
+ it('should store the provided count', () => {
+ const count = { all: 20, finished: 10 };
+ store.storeCount(count);
+
+ expect(store.state.count).toEqual(count);
+ });
+ });
+
+ describe('storePagination', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storePagination();
+ expect(store.state.pageInfo).toEqual({});
+ });
+
+ it('should store pagination information normalized and parsed', () => {
+ const pagination = {
+ 'X-nExt-pAge': '2',
+ 'X-page': '1',
+ 'X-Per-Page': '1',
+ 'X-Prev-Page': '2',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '2',
+ };
+
+ const expectedResult = {
+ perPage: 1,
+ page: 1,
+ total: 37,
+ totalPages: 2,
+ nextPage: 2,
+ previousPage: 2,
+ };
+
+ store.storePagination(pagination);
+ expect(store.state.pageInfo).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js
index 15ab10b9b69..df547299d75 100644
--- a/spec/javascripts/vue_shared/components/commit_spec.js
+++ b/spec/javascripts/vue_shared/components/commit_spec.js
@@ -1,13 +1,17 @@
-require('~/vue_shared/components/commit');
+import Vue from 'vue';
+import commitComp from '~/vue_shared/components/commit';
describe('Commit component', () => {
let props;
let component;
+ let CommitComponent;
+
+ beforeEach(() => {
+ CommitComponent = Vue.extend(commitComp);
+ });
it('should render a code-fork icon if it does not represent a tag', () => {
- setFixtures('<div class="test-commit-container"></div>');
- component = new window.gl.CommitComponent({
- el: document.querySelector('.test-commit-container'),
+ component = new CommitComponent({
propsData: {
tag: false,
commitRef: {
@@ -23,15 +27,13 @@ describe('Commit component', () => {
username: 'jschatz1',
},
},
- });
+ }).$mount();
expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
});
describe('Given all the props', () => {
beforeEach(() => {
- setFixtures('<div class="test-commit-container"></div>');
-
props = {
tag: true,
commitRef: {
@@ -49,10 +51,9 @@ describe('Commit component', () => {
commitIconSvg: '<svg></svg>',
};
- component = new window.gl.CommitComponent({
- el: document.querySelector('.test-commit-container'),
+ component = new CommitComponent({
propsData: props,
- });
+ }).$mount();
});
it('should render a tag icon if it represents a tag', () => {
@@ -105,7 +106,6 @@ describe('Commit component', () => {
describe('When commit title is not provided', () => {
it('should render default message', () => {
- setFixtures('<div class="test-commit-container"></div>');
props = {
tag: false,
commitRef: {
@@ -118,10 +118,9 @@ describe('Commit component', () => {
author: {},
};
- component = new window.gl.CommitComponent({
- el: document.querySelector('.test-commit-container'),
+ component = new CommitComponent({
propsData: props,
- });
+ }).$mount();
expect(
component.$el.querySelector('.commit-title span').textContent,
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
index 412abfd5e41..699625cdbb7 100644
--- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
+++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
@@ -1,20 +1,20 @@
-require('~/vue_shared/components/pipelines_table_row');
-const pipeline = require('../../commit/pipelines/mock_data');
+import Vue from 'vue';
+import tableRowComp from '~/vue_shared/components/pipelines_table_row';
+import pipeline from '../../commit/pipelines/mock_data';
describe('Pipelines Table Row', () => {
let component;
- preloadFixtures('static/environments/element.html.raw');
beforeEach(() => {
- loadFixtures('static/environments/element.html.raw');
+ const PipelinesTableRowComponent = Vue.extend(tableRowComp);
- component = new gl.pipelines.PipelinesTableRowComponent({
+ component = new PipelinesTableRowComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
pipeline,
- svgs: {},
+ service: {},
},
- });
+ }).$mount();
});
it('should render a table row', () => {
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_spec.js
index 54d81e2ea7d..b0b1df5a753 100644
--- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js
+++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js
@@ -1,24 +1,24 @@
-require('~/vue_shared/components/pipelines_table');
-require('~/lib/utils/datetime_utility');
-const pipeline = require('../../commit/pipelines/mock_data');
+import Vue from 'vue';
+import pipelinesTableComp from '~/vue_shared/components/pipelines_table';
+import '~/lib/utils/datetime_utility';
+import pipeline from '../../commit/pipelines/mock_data';
describe('Pipelines Table', () => {
- preloadFixtures('static/environments/element.html.raw');
+ let PipelinesTableComponent;
beforeEach(() => {
- loadFixtures('static/environments/element.html.raw');
+ PipelinesTableComponent = Vue.extend(pipelinesTableComp);
});
describe('table', () => {
let component;
beforeEach(() => {
- component = new gl.pipelines.PipelinesTableComponent({
- el: document.querySelector('.test-dom-element'),
+ component = new PipelinesTableComponent({
propsData: {
pipelines: [],
- svgs: {},
+ service: {},
},
- });
+ }).$mount();
});
it('should render a table', () => {
@@ -37,26 +37,25 @@ describe('Pipelines Table', () => {
describe('without data', () => {
it('should render an empty table', () => {
- const component = new gl.pipelines.PipelinesTableComponent({
- el: document.querySelector('.test-dom-element'),
+ const component = new PipelinesTableComponent({
propsData: {
pipelines: [],
- svgs: {},
+ service: {},
},
- });
+ }).$mount();
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0);
});
});
describe('with data', () => {
it('should render rows', () => {
- const component = new gl.pipelines.PipelinesTableComponent({
+ const component = new PipelinesTableComponent({
el: document.querySelector('.test-dom-element'),
propsData: {
pipelines: [pipeline],
- svgs: {},
+ service: {},
},
- });
+ }).$mount();
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1);
});
diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js
index 9cb067921a7..a5c3870b3ac 100644
--- a/spec/javascripts/vue_shared/components/table_pagination_spec.js
+++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js
@@ -1,8 +1,10 @@
-require('~/lib/utils/common_utils');
-require('~/vue_shared/components/table_pagination');
+import Vue from 'vue';
+import paginationComp from '~/vue_shared/components/table_pagination';
+import '~/lib/utils/common_utils';
describe('Pagination component', () => {
let component;
+ let PaginationComponent;
const changeChanges = {
one: '',
@@ -12,11 +14,12 @@ describe('Pagination component', () => {
changeChanges.one = one;
};
- it('should render and start at page 1', () => {
- setFixtures('<div class="test-pagination-container"></div>');
+ beforeEach(() => {
+ PaginationComponent = Vue.extend(paginationComp);
+ });
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ it('should render and start at page 1', () => {
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -25,7 +28,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
expect(component.$el.classList).toContain('gl-pagination');
@@ -35,10 +38,7 @@ describe('Pagination component', () => {
});
it('should go to the previous page', () => {
- setFixtures('<div class="test-pagination-container"></div>');
-
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -47,7 +47,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
component.changePage({ target: { innerText: 'Prev' } });
@@ -55,10 +55,7 @@ describe('Pagination component', () => {
});
it('should go to the next page', () => {
- setFixtures('<div class="test-pagination-container"></div>');
-
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -67,7 +64,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
component.changePage({ target: { innerText: 'Next' } });
@@ -75,10 +72,7 @@ describe('Pagination component', () => {
});
it('should go to the last page', () => {
- setFixtures('<div class="test-pagination-container"></div>');
-
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -87,7 +81,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
component.changePage({ target: { innerText: 'Last >>' } });
@@ -95,10 +89,7 @@ describe('Pagination component', () => {
});
it('should go to the first page', () => {
- setFixtures('<div class="test-pagination-container"></div>');
-
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -107,7 +98,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
component.changePage({ target: { innerText: '<< First' } });
@@ -115,10 +106,7 @@ describe('Pagination component', () => {
});
it('should do nothing', () => {
- setFixtures('<div class="test-pagination-container"></div>');
-
- component = new window.gl.VueGlPagination({
- el: document.querySelector('.test-pagination-container'),
+ component = new PaginationComponent({
propsData: {
pageInfo: {
totalPages: 10,
@@ -127,7 +115,7 @@ describe('Pagination component', () => {
},
change,
},
- });
+ }).$mount();
component.changePage({ target: { innerText: '...' } });
diff --git a/spec/lib/git_ref_validator_spec.rb b/spec/lib/git_ref_validator_spec.rb
index dc57e94f193..cc8daa535d6 100644
--- a/spec/lib/git_ref_validator_spec.rb
+++ b/spec/lib/git_ref_validator_spec.rb
@@ -5,6 +5,7 @@ describe Gitlab::GitRefValidator, lib: true do
it { expect(Gitlab::GitRefValidator.validate('implement_@all')).to be_truthy }
it { expect(Gitlab::GitRefValidator.validate('my_new_feature')).to be_truthy }
it { expect(Gitlab::GitRefValidator.validate('#1')).to be_truthy }
+ it { expect(Gitlab::GitRefValidator.validate('feature/refs/heads/foo')).to be_truthy }
it { expect(Gitlab::GitRefValidator.validate('feature/~new/')).to be_falsey }
it { expect(Gitlab::GitRefValidator.validate('feature/^new/')).to be_falsey }
it { expect(Gitlab::GitRefValidator.validate('feature/:new/')).to be_falsey }
@@ -17,4 +18,8 @@ describe Gitlab::GitRefValidator, lib: true do
it { expect(Gitlab::GitRefValidator.validate('feature\new')).to be_falsey }
it { expect(Gitlab::GitRefValidator.validate('feature//new')).to be_falsey }
it { expect(Gitlab::GitRefValidator.validate('feature new')).to be_falsey }
+ it { expect(Gitlab::GitRefValidator.validate('refs/heads/')).to be_falsey }
+ it { expect(Gitlab::GitRefValidator.validate('refs/remotes/')).to be_falsey }
+ it { expect(Gitlab::GitRefValidator.validate('refs/heads/feature')).to be_falsey }
+ it { expect(Gitlab::GitRefValidator.validate('refs/remotes/origin')).to be_falsey }
end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index edd01d032c8..4ce4e6e1034 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -1,10 +1,16 @@
require 'spec_helper'
-class MigrationTest
- include Gitlab::Database
-end
-
describe Gitlab::Database, lib: true do
+ before do
+ stub_const('MigrationTest', Class.new { include Gitlab::Database })
+ end
+
+ describe '.config' do
+ it 'returns a Hash' do
+ expect(described_class.config).to be_an_instance_of(Hash)
+ end
+ end
+
describe '.adapter_name' do
it 'returns the name of the adapter' do
expect(described_class.adapter_name).to be_an_instance_of(String)
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 4c55532d165..992126ef153 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -109,6 +109,43 @@ EOT
end
end
end
+
+ context 'using a Gitaly::CommitDiffResponse' do
+ let(:diff) do
+ described_class.new(
+ Gitaly::CommitDiffResponse.new(
+ to_path: ".gitmodules",
+ from_path: ".gitmodules",
+ old_mode: 0100644,
+ new_mode: 0100644,
+ from_id: '357406f3075a57708d0163752905cc1576fceacc',
+ to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0',
+ raw_chunks: raw_chunks,
+ )
+ )
+ end
+
+ context 'with a small diff' do
+ let(:raw_chunks) { [@raw_diff_hash[:diff]] }
+
+ it 'initializes the diff' do
+ expect(diff.to_hash).to eq(@raw_diff_hash)
+ end
+
+ it 'does not prune the diff' do
+ expect(diff).not_to be_too_large
+ end
+ end
+
+ context 'using a diff that is too large' do
+ let(:raw_chunks) { ['a' * 204800] }
+
+ it 'prunes the diff' do
+ expect(diff.diff).to be_empty
+ expect(diff).to be_too_large
+ end
+ end
+ end
end
describe 'straight diffs' do
diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb
new file mode 100644
index 00000000000..4684b1d1ac0
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::Commit do
+ describe '.diff_from_parent' do
+ let(:diff_stub) { double('Gitaly::Diff::Stub') }
+ let(:project) { create(:project, :repository) }
+ let(:repository_message) { Gitaly::Repository.new(path: project.repository.path) }
+ let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') }
+
+ before do
+ allow(Gitaly::Diff::Stub).to receive(:new).and_return(diff_stub)
+ allow(diff_stub).to receive(:commit_diff).and_return([])
+ end
+
+ context 'when a commit has a parent' do
+ it 'sends an RPC request with the parent ID as left commit' do
+ request = Gitaly::CommitDiffRequest.new(
+ repository: repository_message,
+ left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660',
+ right_commit_id: commit.id,
+ )
+
+ expect(diff_stub).to receive(:commit_diff).with(request)
+
+ described_class.diff_from_parent(commit)
+ end
+ end
+
+ context 'when a commit does not have a parent' do
+ it 'sends an RPC request with empty tree ref as left commit' do
+ initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863')
+ request = Gitaly::CommitDiffRequest.new(
+ repository: repository_message,
+ left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ right_commit_id: initial_commit.id,
+ )
+
+ expect(diff_stub).to receive(:commit_diff).with(request)
+
+ described_class.diff_from_parent(initial_commit)
+ end
+ end
+
+ it 'returns a Gitlab::Git::DiffCollection' do
+ ret = described_class.diff_from_parent(commit)
+
+ expect(ret).to be_kind_of(Gitlab::Git::DiffCollection)
+ end
+
+ it 'passes options to Gitlab::Git::DiffCollection' do
+ options = { max_files: 31, max_lines: 13 }
+
+ expect(Gitlab::Git::DiffCollection).to receive(:new).with([], options)
+
+ described_class.diff_from_parent(commit, options)
+ end
+ end
+end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index e822d7eb348..6ee91576676 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -63,7 +63,7 @@ describe Notify do
end
it 'contains a link to note author' do
- is_expected.to have_body_text issue.author_name
+ is_expected.to have_html_escaped_body_text issue.author_name
is_expected.to have_body_text 'wrote:'
end
end
@@ -75,7 +75,7 @@ describe Notify do
it_behaves_like 'it should show Gmail Actions View Issue link'
it 'contains the description' do
- is_expected.to have_body_text issue_with_description.description
+ is_expected.to have_html_escaped_body_text issue_with_description.description
end
end
@@ -100,11 +100,11 @@ describe Notify do
end
it 'contains the name of the previous assignee' do
- is_expected.to have_body_text previous_assignee.name
+ is_expected.to have_html_escaped_body_text previous_assignee.name
end
it 'contains the name of the new assignee' do
- is_expected.to have_body_text assignee.name
+ is_expected.to have_html_escaped_body_text assignee.name
end
it 'contains a link to the issue' do
@@ -167,7 +167,7 @@ describe Notify do
end
it 'contains the user name' do
- is_expected.to have_body_text current_user.name
+ is_expected.to have_html_escaped_body_text current_user.name
end
it 'contains a link to the issue' do
@@ -242,7 +242,7 @@ describe Notify do
end
it 'contains a link to note author' do
- is_expected.to have_body_text merge_request.author_name
+ is_expected.to have_html_escaped_body_text merge_request.author_name
is_expected.to have_body_text 'wrote:'
end
end
@@ -255,7 +255,7 @@ describe Notify do
it_behaves_like "an unsubscribeable thread"
it 'contains the description' do
- is_expected.to have_body_text merge_request_with_description.description
+ is_expected.to have_html_escaped_body_text merge_request_with_description.description
end
end
@@ -280,11 +280,11 @@ describe Notify do
end
it 'contains the name of the previous assignee' do
- is_expected.to have_body_text previous_assignee.name
+ is_expected.to have_html_escaped_body_text previous_assignee.name
end
it 'contains the name of the new assignee' do
- is_expected.to have_body_text assignee.name
+ is_expected.to have_html_escaped_body_text assignee.name
end
it 'contains a link to the merge request' do
@@ -347,7 +347,7 @@ describe Notify do
end
it 'contains the user name' do
- is_expected.to have_body_text current_user.name
+ is_expected.to have_html_escaped_body_text current_user.name
end
it 'contains a link to the merge request' do
@@ -400,7 +400,7 @@ describe Notify do
end
it 'contains name of project' do
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
end
it 'contains new user role' do
@@ -433,7 +433,7 @@ describe Notify do
expect(to_emails[0].address).to eq(project.members.owners_and_masters.first.user.notification_email)
is_expected.to have_subject "Request to join the #{project.name_with_namespace} project"
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project)
is_expected.to have_body_text project_member.human_access
end
@@ -460,7 +460,7 @@ describe Notify do
expect(to_emails[0].address).to eq(group.members.owners_and_masters.first.user.notification_email)
is_expected.to have_subject "Request to join the #{project.name_with_namespace} project"
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project)
is_expected.to have_body_text project_member.human_access
end
@@ -482,13 +482,14 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied"
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text project.web_url
end
end
describe 'project access changed' do
- let(:project) { create(:empty_project, :public, :access_requestable) }
+ let(:owner) { create(:user, name: "Chang O'Keefe") }
+ let(:project) { create(:empty_project, :public, :access_requestable, namespace: owner.namespace) }
let(:user) { create(:user) }
let(:project_member) { create(:project_member, project: project, user: user) }
subject { Notify.member_access_granted_email('project', project_member.id) }
@@ -499,7 +500,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted"
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.human_access
end
@@ -530,7 +531,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project"
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.human_access
is_expected.to have_body_text project_member.invite_token
@@ -555,10 +556,10 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation accepted'
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.invite_email
- is_expected.to have_body_text invited_user.name
+ is_expected.to have_html_escaped_body_text invited_user.name
end
end
@@ -579,7 +580,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation declined'
- is_expected.to have_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.name_with_namespace
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.invite_email
end
@@ -607,7 +608,7 @@ describe Notify do
end
it 'contains the message from the note' do
- is_expected.to have_body_text note.note
+ is_expected.to have_html_escaped_body_text note.note
end
it 'does not contain note author' do
@@ -620,7 +621,7 @@ describe Notify do
end
it 'contains a link to note author' do
- is_expected.to have_body_text note.author_name
+ is_expected.to have_html_escaped_body_text note.author_name
is_expected.to have_body_text 'wrote:'
end
end
@@ -727,7 +728,7 @@ describe Notify do
end
it 'contains the message from the note' do
- is_expected.to have_body_text note.note
+ is_expected.to have_html_escaped_body_text note.note
end
it 'does not contain note author' do
@@ -740,7 +741,7 @@ describe Notify do
end
it 'contains a link to note author' do
- is_expected.to have_body_text note.author_name
+ is_expected.to have_html_escaped_body_text note.author_name
is_expected.to have_body_text 'wrote:'
end
end
@@ -786,7 +787,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Request to join the #{group.name} group"
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group_group_members_url(group)
is_expected.to have_body_text group_member.human_access
end
@@ -807,7 +808,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Access to the #{group.name} group was denied"
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group.web_url
end
end
@@ -825,7 +826,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Access to the #{group.name} group was granted"
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group.web_url
is_expected.to have_body_text group_member.human_access
end
@@ -856,7 +857,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{group.name} group"
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group.web_url
is_expected.to have_body_text group_member.human_access
is_expected.to have_body_text group_member.invite_token
@@ -881,10 +882,10 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation accepted'
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group.web_url
is_expected.to have_body_text group_member.invite_email
- is_expected.to have_body_text invited_user.name
+ is_expected.to have_html_escaped_body_text invited_user.name
end
end
@@ -905,7 +906,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation declined'
- is_expected.to have_body_text group.name
+ is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group.web_url
is_expected.to have_body_text group_member.invite_email
end
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index 4b449546a30..980a1b70ef5 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -388,4 +388,32 @@ eos
expect(described_class.valid_hash?('a' * 41)).to be false
end
end
+
+ describe '#raw_diffs' do
+ context 'Gitaly commit_raw_diffs feature enabled' do
+ before do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true)
+ end
+
+ context 'when a truthy deltas_only is not passed to args' do
+ it 'fetches diffs from Gitaly server' do
+ expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent).
+ with(commit)
+
+ commit.raw_diffs
+ end
+ end
+
+ context 'when a truthy deltas_only is passed to args' do
+ it 'fetches diffs using Rugged' do
+ opts = { deltas_only: true }
+
+ expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent)
+ expect(commit.raw).to receive(:diffs).with(opts)
+
+ commit.raw_diffs(opts)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 31ae0dce140..9574796a945 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -278,6 +278,16 @@ describe Issue, "Issuable" do
end
end
+ context 'issue has labels' do
+ let(:labels) { [create(:label), create(:label)] }
+
+ before { issue.update_attribute(:labels, labels)}
+
+ it 'includes labels in the hook data' do
+ expect(data[:labels]).to eq(labels.map(&:hook_attrs))
+ end
+ end
+
include_examples 'project hook data'
include_examples 'deprecated repository hook data'
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 9da4140f3ce..90378179e32 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -699,7 +699,9 @@ describe User, models: true do
let!(:user) { create(:user, name: 'John Doe', username: 'john.doe', email: 'john.doe@example.com' ) }
let!(:another_user) { create(:user, name: 'Albert Smith', username: 'albert.smith', email: 'albert.smith@example.com' ) }
- let!(:email) { create(:email, user: another_user) }
+ let!(:email) do
+ create(:email, user: another_user, email: 'alias@example.com')
+ end
it 'returns users with a matching name' do
expect(search_with_secondary_emails(user.name)).to eq([user])
diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb
index f18b8e98707..63ec00cdf04 100644
--- a/spec/requests/api/internal_spec.rb
+++ b/spec/requests/api/internal_spec.rb
@@ -397,16 +397,25 @@ describe API::Internal, api: true do
before do
project.team << [user, :developer]
- get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token
end
it 'returns link to create new merge request' do
+ get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token
+
expect(json_response).to match [{
"branch_name" => "new_branch",
"url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch",
"new_merge_request" => true
}]
end
+
+ it 'returns empty array if printing_merge_request_link_enabled is false' do
+ project.update!(printing_merge_request_link_enabled: false)
+
+ get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token
+
+ expect(json_response).to eq([])
+ end
end
describe 'POST /notify_post_receive' do
diff --git a/spec/requests/api/v3/branches_spec.rb b/spec/requests/api/v3/branches_spec.rb
index e4cedf98e64..5dcd4f21f4e 100644
--- a/spec/requests/api/v3/branches_spec.rb
+++ b/spec/requests/api/v3/branches_spec.rb
@@ -10,6 +10,7 @@ describe API::V3::Branches, api: true do
let!(:master) { create(:project_member, :master, user: user, project: project) }
let!(:guest) { create(:project_member, :guest, user: user2, project: project) }
let!(:branch_name) { 'feature' }
+ let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
let!(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master") }
describe "GET /projects/:id/repository/branches" do
@@ -80,4 +81,55 @@ describe API::V3::Branches, api: true do
expect(response).to have_http_status(403)
end
end
+
+ describe "POST /projects/:id/repository/branches" do
+ it "creates a new branch" do
+ post v3_api("/projects/#{project.id}/repository/branches", user),
+ branch_name: 'feature1',
+ ref: branch_sha
+
+ expect(response).to have_http_status(201)
+
+ expect(json_response['name']).to eq('feature1')
+ expect(json_response['commit']['id']).to eq(branch_sha)
+ end
+
+ it "denies for user without push access" do
+ post v3_api("/projects/#{project.id}/repository/branches", user2),
+ branch_name: branch_name,
+ ref: branch_sha
+ expect(response).to have_http_status(403)
+ end
+
+ it 'returns 400 if branch name is invalid' do
+ post v3_api("/projects/#{project.id}/repository/branches", user),
+ branch_name: 'new design',
+ ref: branch_sha
+ expect(response).to have_http_status(400)
+ expect(json_response['message']).to eq('Branch name is invalid')
+ end
+
+ it 'returns 400 if branch already exists' do
+ post v3_api("/projects/#{project.id}/repository/branches", user),
+ branch_name: 'new_design1',
+ ref: branch_sha
+ expect(response).to have_http_status(201)
+
+ post v3_api("/projects/#{project.id}/repository/branches", user),
+ branch_name: 'new_design1',
+ ref: branch_sha
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']).to eq('Branch already exists')
+ end
+
+ it 'returns 400 if ref name is invalid' do
+ post v3_api("/projects/#{project.id}/repository/branches", user),
+ branch_name: 'new_design3',
+ ref: 'foo'
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']).to eq('Invalid reference name')
+ end
+ end
end
diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb
index 08829e4be70..b7a05907208 100644
--- a/spec/services/merge_requests/get_urls_service_spec.rb
+++ b/spec/services/merge_requests/get_urls_service_spec.rb
@@ -130,5 +130,15 @@ describe MergeRequests::GetUrlsService do
}])
end
end
+
+ context 'when printing_merge_request_link_enabled is false' do
+ it 'returns empty array' do
+ project.update!(printing_merge_request_link_enabled: false)
+
+ result = service.execute(existing_branch_changes)
+
+ expect(result).to eq([])
+ end
+ end
end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index ebbaea4e59a..229291f19e9 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -795,7 +795,7 @@ describe NotificationService, services: true do
update_custom_notification(:reopen_issue, @u_custom_global)
end
- it 'sends email to issue assignee and issue author' do
+ it 'sends email to issue notification recipients' do
notification.reopen_issue(issue, @u_disabled)
should_email(issue.assignee)
@@ -809,6 +809,7 @@ describe NotificationService, services: true do
should_email(@watcher_and_subscriber)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
+ should_not_email(@u_disabled)
should_not_email(@u_lazy_participant)
end
@@ -818,6 +819,32 @@ describe NotificationService, services: true do
let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) }
end
end
+
+ describe '#issue_moved' do
+ let(:new_issue) { create(:issue) }
+
+ it 'sends email to issue notification recipients' do
+ notification.issue_moved(issue, new_issue, @u_disabled)
+
+ should_email(issue.assignee)
+ should_email(issue.author)
+ should_email(@u_watcher)
+ should_email(@u_guest_watcher)
+ should_email(@u_participant_mentioned)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+ end
+
+ it_behaves_like 'participating notifications' do
+ let(:participant) { create(:user, username: 'user-participant') }
+ let(:issuable) { issue }
+ let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) }
+ end
+ end
end
describe 'Merge Requests' do
@@ -1251,6 +1278,48 @@ describe NotificationService, services: true do
end
end
+ describe 'Pipelines' do
+ describe '#pipeline_finished' do
+ let(:project) { create(:project, :public) }
+ let(:current_user) { create(:user) }
+ let(:u_member) { create(:user) }
+ let(:u_other) { create(:user) }
+
+ let(:commit) { project.commit }
+ let(:pipeline) do
+ create(:ci_pipeline, :success,
+ project: project,
+ user: current_user,
+ ref: 'refs/heads/master',
+ sha: commit.id,
+ before_sha: '00000000')
+ end
+
+ before do
+ project.add_master(current_user)
+ project.add_master(u_member)
+ reset_delivered_emails!
+ end
+
+ context 'without custom recipients' do
+ it 'notifies the pipeline user' do
+ notification.pipeline_finished(pipeline)
+
+ should_only_email(current_user, kind: :bcc)
+ end
+ end
+
+ context 'with custom recipients' do
+ it 'notifies the custom recipients' do
+ users = [u_member, u_other]
+ notification.pipeline_finished(pipeline, users.map(&:notification_email))
+
+ should_only_email(*users, kind: :bcc)
+ end
+ end
+ end
+ end
+
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
diff --git a/spec/support/matchers/email_matchers.rb b/spec/support/matchers/email_matchers.rb
new file mode 100644
index 00000000000..d9d59ec12ec
--- /dev/null
+++ b/spec/support/matchers/email_matchers.rb
@@ -0,0 +1,5 @@
+RSpec::Matchers.define :have_html_escaped_body_text do |expected|
+ match do |actual|
+ expect(actual).to have_body_text(ERB::Util.html_escape(expected))
+ end
+end