Merge branch 'master' into 27574-pipelines-empty-state

* master: (23 commits)
  Resolve "Extract logic of who should receive notification into separate classes"
  Remove UJS actions from pipelines tables
  Added Gitlab::Database.config
  Fix time-sensitive helper spec
  Updates realtime documentation for the Frontend
  Add ability to disable Merge Request URL on push
  Added labels to the issue web hook
  documentation blurb in issue template
  Add a new have_html_escaped_body_text that match an HTML-escaped text
  Stop CI notification showing when status is nil
  Refactor award emojis document
  Do not use Ruby Timeout module in GitLab QA
  Make sure alias email would never match:
  Make the test less time sensitive by extending 0.2
  Restore sub-nav for empty project
  Fix Unicode 1.1 emojis
  Use "branch_name" instead "branch" on V3 branch creation API
  Fixed eslint
  Catches errors when generating lists
  Resolve GitLab QA cold boot problems on entry page
  ...
This commit is contained in:
Filipa Lacerda 2017-03-17 18:23:47 +00:00
commit 059ad4754c
110 changed files with 3033 additions and 2023 deletions

View file

@ -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.)

View file

@ -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'

View file

@ -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)

View file

@ -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,

View file

@ -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),
},
};

View file

@ -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);

View file

@ -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;

View file

@ -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();
/**
* 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);
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
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>
<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>
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="table-holder pipelines"
v-if="!isLoading && state.pipelines.length > 0">
<pipelines-table-component
:pipelines="state.pipelines"
:service="service" />
</div>
</div>
`,
});

View file

@ -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);
},
},
/**

View file

@ -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,

View file

@ -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: {

View file

@ -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() {

View file

@ -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) {

View file

@ -1,5 +1,4 @@
import '~/lib/utils/common_utils';
/**
* Environments Store.
*

View file

@ -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;

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -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>
`,
};

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View file

@ -1,23 +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('#pipelines-list-vue'),
data() {
const project = document.querySelector('.pipelines');
const store = new PipelinesStore();
return {
store: new gl.PipelineStore(),
store,
endpoint: project.dataset.url,
};
},
components: {
'vue-pipelines': gl.VuePipelines,
'vue-pipelines': PipelinesComponent,
},
template: `
<vue-pipelines :store="store"/>
<vue-pipelines
:endpoint="endpoint"
:store="store" />
`,
}));

View file

@ -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 = {}));

View file

@ -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 = {}));

View file

@ -1,266 +1,290 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign */
/* global Flash */
/* eslint-disable no-new */
import Vue from 'vue';
import '~/flash';
import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
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');
export default {
props: {
endpoint: {
type: String,
required: true,
},
},
((gl) => {
gl.VuePipelines = Vue.extend({
components: {
'gl-pagination': TablePaginationComponent,
'pipelines-table-component': PipelinesTableComponent,
},
components: {
'gl-pagination': gl.VueGlPagination,
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
computed: {
canCreatePipelineParsed() {
return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
},
data() {
const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
return {
...pipelinesData,
pipelines: [],
apiScope: 'all',
pageInfo: {},
pagenum: 1,
count: {
all: 0,
pending: 0,
running: 0,
finished: 0,
},
pageRequest: false,
hasError: false,
pipelinesEmptyStateSVG,
pipelinesErrorStateSVG,
};
},
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.endpoint, this.apiScope);
scope() {
return gl.utils.getParameterByName('scope');
},
beforeUpdate() {
if (this.pipelines.length && this.$children) {
CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue);
}
shouldRenderErrorState() {
return this.hasError && !this.pageRequest;
},
computed: {
canCreatePipelineParsed() {
return gl.utils.convertPermissionToBoolean(this.canCreatePipeline);
},
scope() {
return gl.utils.getParameterByName('scope');
},
shouldRenderErrorState() {
return this.hasError && !this.pageRequest;
},
/**
* The empty state should only be rendered when the request is made to fetch all pipelines
* and none is returned.
*
* @return {Boolean}
*/
shouldRenderEmptyState() {
return !this.hasError &&
!this.pageRequest && (
!this.pipelines.length && (this.scope === 'all' || this.scope === null)
);
},
shouldRenderTable() {
return !this.hasError &&
!this.pageRequest && this.pipelines.length;
},
/**
* Header tabs should only be rendered when we receive an error or a successfull response with
* pipelines.
*
* @return {Boolean}
*/
shouldRenderTabs() {
return !this.pageRequest && !this.hasError && this.pipelines.length;
},
/**
* Pagination should only be rendered when there is more than one page.
*
* @return {Boolean}
*/
shouldRenderPagination() {
return !this.pageRequest &&
this.pipelines.length &&
this.pageInfo.total > this.pageInfo.perPage;
},
/**
* The empty state should only be rendered when the request is made to fetch all pipelines
* and none is returned.
*
* @return {Boolean}
*/
shouldRenderEmptyState() {
return !this.hasError &&
!this.pageRequest && (
!this.pipelines.length && (this.scope === 'all' || this.scope === null)
);
},
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;
},
shouldRenderTable() {
return !this.hasError &&
!this.pageRequest && this.pipelines.length;
},
template: `
<div :class="cssClass">
<div class="top-area" v-if="!shouldRenderEmptyState">
<ul
class="nav-links">
<li :class="{ 'active': scope === null || scope === 'all'}">
<a :href="allPath">
All
</a>
<span class="badge js-totalbuilds-count">
{{count.all}}
</span>
</li>
<li
class="js-pipelines-tab-pending"
:class="{ 'active': scope === 'pending'}">
<a :href="pendingPath">
Pending
</a>
/**
* Header tabs should only be rendered when we receive an error or a successfull response with
* pipelines.
*
* @return {Boolean}
*/
shouldRenderTabs() {
return !this.pageRequest && !this.hasError && this.pipelines.length;
},
<span class="badge">
{{count.pending}}
</span>
</li>
<li
class="js-pipelines-tab-running"
:class="{ 'active': scope === 'running'}">
/**
* Pagination should only be rendered when there is more than one page.
*
* @return {Boolean}
*/
shouldRenderPagination() {
return !this.pageRequest &&
this.pipelines.length &&
this.pageInfo.total > this.pageInfo.perPage;
},
},
<a :href="runningPath">
Running
</a>
data() {
const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
<span class="badge">
{{count.running}}
</span>
</li>
return {
...pipelinesData,
state: this.store.state,
apiScope: 'all',
pagenum: 1,
pageRequest: false,
hasError: false,
pipelinesEmptyStateSVG,
pipelinesErrorStateSVG,
};
},
<li
class="js-pipelines-tab-finished"
:class="{ 'active': scope === 'finished'}">
created() {
this.service = new PipelinesService(this.endpoint);
<a :href="finishedPath">
Finished
</a>
<span class="badge">
{{count.finished}}
</span>
</li>
this.fetchPipelines();
<li
class="js-pipelines-tab-branches"
:class="{ 'active': scope === 'branches'}">
<a :href="branchesPath">Branches</a>
</li>
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
<li
class="js-pipelines-tab-tags"
:class="{ 'active': scope === 'tags'}">
<a :href="tagsPath">Tags</a>
</li>
</ul>
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
<div class="nav-controls">
<a
v-if="canCreatePipelineParsed"
:href="newPipelinePath"
class="btn btn-create">
Run Pipeline
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;
},
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 :class="cssClass">
<div class="top-area" v-if="!shouldRenderEmptyState">
<ul
class="nav-links">
<li :class="{ 'active': scope === null || scope === 'all'}">
<a :href="allPath">
All
</a>
<span class="badge js-totalbuilds-count">
{{count.all}}
</span>
</li>
<li
class="js-pipelines-tab-pending"
:class="{ 'active': scope === 'pending'}">
<a :href="pendingPath">
Pending
</a>
<a
v-if="!hasCi"
:href="helpPagePath"
class="btn btn-info">
Get started with Pipelines
<span class="badge">
{{count.pending}}
</span>
</li>
<li
class="js-pipelines-tab-running"
:class="{ 'active': scope === 'running'}">
<a :href="runningPath">
Running
</a>
<a
:href="ciLintPath"
class="btn btn-default">
CI Lint
<span class="badge">
{{count.running}}
</span>
</li>
<li
class="js-pipelines-tab-finished"
:class="{ 'active': scope === 'finished'}">
<a :href="finishedPath">
Finished
</a>
</div>
<span class="badge">
{{count.finished}}
</span>
</li>
<li
class="js-pipelines-tab-branches"
:class="{ 'active': scope === 'branches'}">
<a :href="branchesPath">Branches</a>
</li>
<li
class="js-pipelines-tab-tags"
:class="{ 'active': scope === 'tags'}">
<a :href="tagsPath">Tags</a>
</li>
</ul>
<div class="nav-controls">
<a
v-if="canCreatePipelineParsed"
:href="newPipelinePath"
class="btn btn-create">
Run Pipeline
</a>
<a
v-if="!hasCi"
:href="helpPagePath"
class="btn btn-info">
Get started with Pipelines
</a>
<a
:href="ciLintPath"
class="btn btn-default">
CI Lint
</a>
</div>
<div class="pipelines realtime-loading"
v-if="pageRequest">
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
</div>
<div v-if="shouldRenderEmptyState"
class="row empty-state">
<div class="col-xs-12 pull-right">
<div class="svg-content">
${pipelinesEmptyStateSVG}
</div>
</div>
<div class="col-xs-12 center">
<div class="text-content">
<h4>Build with confidence</h4>
<p>
Continous Integration can help catch bugs by running your tests automatically,
while Continuous Deployment can help you deliver code to your product environment.
<a :href="helpPagePath" class="btn btn-info">
Get started with Pipelines
</a>
</p>
</div>
</div>
</div>
<div v-if="shouldRenderErrorState"
class="row empty-state">
<div class="col-xs-12 pull-right">
<div class="svg-content">
${pipelinesErrorStateSVG}
</div>
</div>
<div class="col-xs-12 center">
<div class="text-content">
<h4>The API failed to fetch the pipelines.</h4>
</div>
</div>
</div>
<div class="table-holder"
v-if="shouldRenderTable">
<pipelines-table-component :pipelines='pipelines'/>
</div>
<gl-pagination
v-if="shouldRenderPagination"
:pagenum="pagenum"
:change="change"
:count="count.all"
:pageInfo="pageInfo"/>
</div>
`,
});
})(window.gl || (window.gl = {}));
<div class="pipelines realtime-loading"
v-if="pageRequest">
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
</div>
<div v-if="shouldRenderEmptyState"
class="row empty-state">
<div class="col-xs-12 pull-right">
<div class="svg-content">
${pipelinesEmptyStateSVG}
</div>
</div>
<div class="col-xs-12 center">
<div class="text-content">
<h4>Build with confidence</h4>
<p>
Continous Integration can help catch bugs by running your tests automatically,
while Continuous Deployment can help you deliver code to your product environment.
<a :href="helpPagePath" class="btn btn-info">
Get started with Pipelines
</a>
</p>
</div>
</div>
</div>
<div v-if="shouldRenderErrorState"
class="row empty-state">
<div class="col-xs-12 pull-right">
<div class="svg-content">
${pipelinesErrorStateSVG}
</div>
</div>
<div class="col-xs-12 center">
<div class="text-content">
<h4>The API failed to fetch the pipelines.</h4>
</div>
</div>
</div>
<div class="table-holder"
v-if="shouldRenderTable">
<pipelines-table-component :pipelines='pipelines'/>
</div>
<gl-pagination
v-if="shouldRenderPagination"
:pagenum="pagenum"
:change="change"
:count="count.all"
:pageInfo="pageInfo"/>
</div>
`,
};

View file

@ -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 });
}
}

View file

@ -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 = {}));

View file

@ -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 = {}));

View file

@ -1,32 +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;
this.hasError = true;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
}
};
})(window.gl || (window.gl = {}));

View file

@ -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');
import '../../vue_realtime_listener';
class PipelinesStore {
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;

View file

@ -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 = {}));

View file

@ -1,164 +1,157 @@
/* global Vue */
window.Vue = require('vue');
const commitIconSvg = require('icons/_icon_commit.svg');
import commitIconSvg from '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: () => ({}),
},
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">
/**
* Used to show the commit short sha that links to the commit url.
*/
shortSha: {
type: String,
required: false,
default: '',
},
<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>
/**
* If provided shows the commit tile.
*/
title: {
type: String,
required: false,
default: '',
},
<a v-if="hasCommitRef"
class="monospace branch-name"
:href="commitRef.ref_url">
{{commitRef.name}}
</a>
/**
* 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: () => ({}),
},
},
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
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;
},
<a class="commit-id monospace"
:href="commitUrl">
{{shortSha}}
</a>
/**
* 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;
},
<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>
/**
* 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;
},
},
<a class="commit-row-message"
:href="commitUrl">
{{title}}
</a>
</span>
<span v-else>
Cant find HEAD commit for this branch
</span>
</p>
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>
`,
};

View file

@ -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,
},
},
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>
`,
});
})();
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"
:service="service"></tr>
</template>
</tbody>
</table>
`,
};

View file

@ -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,
},
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,
service: {
type: Object,
required: true,
},
},
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;
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,
},
// 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;
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;
// 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,
});
}
}
// 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;
// 4. If committer is not a GitLab User he/she can have a Gravatar
if (this.pipeline &&
this.pipeline.commit) {
commitAuthorInformation = {
// 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,
web_url: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
});
}
}
return commitAuthorInformation;
},
// 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,
};
}
/**
* 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;
}, {});
}
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;
},
return commitAuthorInformation;
},
template: `
<tr class="commit">
<status-scope :pipeline="pipeline"/>
/**
* 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;
},
<pipeline-url :pipeline="pipeline"></pipeline-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;
}, {});
}
<td>
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
:commit-url="commitUrl"
:short-sha="commitShortSha"
:title="commitTitle"
:author="commitAuthor"/>
</td>
return undefined;
},
<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>
/**
* 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;
},
<time-ago :pipeline="pipeline"/>
/**
* 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;
},
<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>
`,
};

View file

@ -1,147 +1,135 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign, no-plusplus */
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
const PREV = 'Prev';
const NEXT = 'Next';
const FIRST = '<< First';
const LAST = 'Last >>';
window.Vue = require('vue');
export default {
props: {
/**
This function will take the information given by the pagination component
((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 >>';
Here is an example `change` method:
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,
},
/**
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,
change(pagenum) {
gl.utils.visitUrl(`?page=${pagenum}`);
},
*/
change: {
type: Function,
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;
}
},
/**
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,
},
computed: {
prev() {
return this.pageInfo.previousPage;
},
next() {
return this.pageInfo.nextPage;
},
getItems() {
const total = this.pageInfo.totalPages;
const page = this.pageInfo.page;
const items = [];
},
methods: {
changePage(e) {
const text = e.target.innerText;
const { totalPages, nextPage, previousPage } = this.pageInfo;
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 > 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);
for (let i = start; i <= end; i++) {
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 (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 });
return items;
},
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;
}
},
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 = {}));
},
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: PREV, prev: true });
} else {
items.push({ title: PREV, disabled: true, prev: 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);
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 (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 });
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>
`,
};

View file

@ -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--;
});
});

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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)
}

View file

@ -169,6 +169,10 @@ class Label < ActiveRecord::Base
end
end
def hook_attrs
attributes
end
private
def issues_count(user, params = {})

View file

@ -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|

View file

@ -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

View file

@ -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)
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,
nil, # The acting user, who won't be added to recipients
action: pipeline.status).map(&:notification_email)
@ -337,199 +290,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
@ -537,7 +299,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|
@ -548,9 +310,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
@ -565,7 +326,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(
@ -579,7 +345,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|
@ -588,58 +354,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
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)
recipients.uniq
end
def mailer
Notify
end
@ -651,10 +372,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

View file

@ -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

View file

@ -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

View file

@ -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"' }

View file

@ -5,6 +5,7 @@
= render 'shared/no_ssh'
= render 'shared/no_password'
= render "projects/head"
= render "home_panel"
.row-content-block.second-block.center

View file

@ -0,0 +1,4 @@
---
title: Add ability to disable Merge Request URL on push
merge_request: 9663
author: Alex Sanford

View file

@ -0,0 +1,4 @@
---
title: Strip reference prefixes on branch creation
merge_request: 8498
author: Matthieu Tardy

View file

@ -0,0 +1,4 @@
---
title: Use "branch_name" instead "branch" on V3 branch creation API
merge_request:
author:

View file

@ -0,0 +1,4 @@
---
title: Added labels array to the issue web hook returned object
merge_request: 9972
author:

View file

@ -0,0 +1,4 @@
---
title: Use Gitaly for CommitController#show
merge_request: 9629
author:

View file

@ -0,0 +1,4 @@
---
title: 'Removes UJS from pipelines tables'
merge_request: 9929
author:

View file

@ -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)

View file

@ -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

View file

@ -1003,6 +1003,7 @@ ActiveRecord::Schema.define(version: 20170315174634) 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

View file

@ -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

51
doc/user/award_emojis.md Normal file
View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -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

View file

@ -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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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();
});
});
});

View file

@ -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;

View file

@ -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'),
});

View file

@ -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);
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});
});

View file

@ -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,

View file

@ -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', () => {

View file

@ -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);
});

View file

@ -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: '...' } });

View file

@ -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

View file

@ -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)

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more