Merge branch 'auto-pipelines-vue' into 'master'

Pipelines Vue

See merge request !7196
This commit is contained in:
Fatih Acet 2017-01-09 21:11:17 +00:00
commit 2f1701c56a
43 changed files with 2111 additions and 262 deletions

View file

@ -184,11 +184,6 @@
new TreeView();
}
break;
case 'projects:pipelines:index':
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
break;
case 'projects:pipelines:builds':
case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;

View file

@ -139,6 +139,21 @@
}, 200);
};
/**
this will take in the `name` of the param you want to parse in the url
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
*/
w.gl.utils.getParameterByName = (name) => {
const url = window.location.href;
name = name.replace(/[[\]]/g, '\\$&');
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
};
})(window);
}).call(this);

View file

@ -0,0 +1,148 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign, no-plusplus */
((gl) => {
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
const PREV = 'Prev';
const NEXT = 'Next';
const FIRST = '<< First';
const LAST = 'Last >>';
gl.VueGlPagination = Vue.extend({
props: {
/**
This function will take the information given by the pagination component
And make a new Turbolinks call
Here is an example `change` method:
change(pagenum, apiScope) {
Turbolinks.visit(`?scope=${apiScope}&p=${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,
},
},
methods: {
changePage(e) {
let apiScope = gl.utils.getParameterByName('scope');
if (!apiScope) apiScope = 'all';
const text = e.target.innerText;
const { totalPages, nextPage, previousPage } = this.pageInfo;
switch (text) {
case SPREAD:
break;
case LAST:
this.change(totalPages, apiScope);
break;
case NEXT:
this.change(nextPage, apiScope);
break;
case PREV:
this.change(previousPage, apiScope);
break;
case FIRST:
this.change(1, apiScope);
break;
default:
this.change(+text, apiScope);
break;
}
},
},
computed: {
prev() {
return this.pageInfo.previousPage;
},
next() {
return this.pageInfo.nextPage;
},
getItems() {
const total = this.pageInfo.totalPages;
const page = this.pageInfo.page;
const items = [];
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;
},
},
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 = {}));

View file

@ -0,0 +1,41 @@
/* global Vue, VueResource, gl */
/*= require vue_common_component/commit */
/*= require vue-resource
/*= require boards/vue_resource_interceptor */
/*= require ./status.js.es6 */
/*= require ./store.js.es6 */
/*= require ./pipeline_url.js.es6 */
/*= require ./stage.js.es6 */
/*= require ./stages.js.es6 */
/*= require ./pipeline_actions.js.es6 */
/*= require ./time_ago.js.es6 */
/*= require ./pipelines.js.es6 */
(() => {
const project = document.querySelector('.pipelines');
const entry = document.querySelector('.vue-pipelines-index');
const svgs = document.querySelector('.pipeline-svgs');
Vue.use(VueResource);
if (!entry) return null;
return new Vue({
el: entry,
data: {
scope: project.dataset.url,
store: new gl.PipelineStore(),
svgs: svgs.dataset,
},
components: {
'vue-pipelines': gl.VuePipelines,
},
template: `
<vue-pipelines
:scope='scope'
:store='store'
:svgs='svgs'
>
</vue-pipelines>
`,
});
})();

View file

@ -0,0 +1,99 @@
/* global Vue, Flash, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VuePipelineActions = Vue.extend({
props: ['pipeline', 'svgs'],
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`;
},
},
template: `
<td class="pipeline-actions hidden-xs">
<div class="controls pull-right">
<div class="btn-group inline">
<div class="btn-group">
<a
v-if='actions'
class="dropdown-toggle btn btn-default js-pipeline-dropdown-manual-actions"
data-toggle="dropdown"
title="Manual build"
alt="Manual Build"
>
<span v-html='svgs.iconPlay'></span>
<i class="fa fa-caret-down"></i>
</a>
<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'
title="Manual build"
>
<span v-html='svgs.iconPlay'></span>
<span title="Manual build">{{action.name}}</span>
</a>
</li>
</ul>
</div>
<div class="btn-group">
<a
v-if='artifacts'
class="dropdown-toggle btn btn-default build-artifacts js-pipeline-dropdown-download"
data-toggle="dropdown"
type="button"
>
<i class="fa fa-download"></i>
<i class="fa fa-caret-down"></i>
</a>
<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"></i>
<span>{{download(artifact.name)}}</span>
</a>
</li>
</ul>
</div>
</div>
<div class="cancel-retry-btns inline">
<a
v-if='pipeline.flags.retryable'
class="btn has-tooltip"
title="Retry"
rel="nofollow"
data-method="post"
:href='pipeline.retry_path'
>
<i class="fa fa-repeat"></i>
</a>
<a
v-if='pipeline.flags.cancelable'
class="btn btn-remove has-tooltip"
title="Cancel"
rel="nofollow"
data-method="post"
:href='pipeline.cancel_path'
data-original-title="Cancel"
>
<i class="fa fa-remove"></i>
</a>
</div>
</div>
</td>
`,
});
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,63 @@
/* 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

@ -0,0 +1,131 @@
/* global Vue, Turbolinks, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VuePipelines = Vue.extend({
components: {
runningPipeline: gl.VueRunningPipeline,
pipelineActions: gl.VuePipelineActions,
stages: gl.VueStages,
commit: gl.CommitComponent,
pipelineUrl: gl.VuePipelineUrl,
pipelineHead: gl.VuePipelineHead,
glPagination: gl.VueGlPagination,
statusScope: gl.VueStatusScope,
timeAgo: gl.VueTimeAgo,
},
data() {
return {
pipelines: [],
timeLoopInterval: '',
intervalId: '',
apiScope: 'all',
pageInfo: {},
pagenum: 1,
count: { all: 0, running_or_pending: 0 },
pageRequest: false,
};
},
props: ['scope', 'store', 'svgs'],
created() {
const pagenum = gl.utils.getParameterByName('p');
const scope = gl.utils.getParameterByName('scope');
if (pagenum) this.pagenum = pagenum;
if (scope) this.apiScope = scope;
this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
},
methods: {
change(pagenum, apiScope) {
Turbolinks.visit(`?scope=${apiScope}&p=${pagenum}`);
},
author(pipeline) {
if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' };
if (pipeline.commit.author) return pipeline.commit.author;
return {
avatar_url: pipeline.commit.author_gravatar_url,
web_url: `mailto:${pipeline.commit.author_email}`,
username: pipeline.commit.author_name,
};
},
ref(pipeline) {
const { ref } = pipeline;
return { name: ref.name, tag: ref.tag, ref_url: ref.path };
},
commitTitle(pipeline) {
return pipeline.commit ? pipeline.commit.title : '';
},
commitSha(pipeline) {
return pipeline.commit ? pipeline.commit.short_id : '';
},
commitUrl(pipeline) {
return pipeline.commit ? pipeline.commit.commit_path : '';
},
match(string) {
return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase());
},
},
template: `
<div>
<div class="pipelines realtime-loading" v-if='pipelines.length < 1'>
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="table-holder" v-if='pipelines.length'>
<table class="table ci-table">
<thead>
<tr>
<th>Status</th>
<th>Pipeline</th>
<th>Commit</th>
<th>Stages</th>
<th></th>
<th class="hidden-xs"></th>
</tr>
</thead>
<tbody>
<tr class="commit" v-for='pipeline in pipelines'>
<status-scope
:pipeline='pipeline'
:match='match'
:svgs='svgs'
>
</status-scope>
<pipeline-url :pipeline='pipeline'></pipeline-url>
<td>
<commit
:commit-icon-svg='svgs.commitIconSvg'
:author='author(pipeline)'
:tag="pipeline.ref.tag"
:title='commitTitle(pipeline)'
:commit-ref='ref(pipeline)'
:short-sha='commitSha(pipeline)'
:commit-url='commitUrl(pipeline)'
>
</commit>
</td>
<stages
:pipeline='pipeline'
:svgs='svgs'
:match='match'
>
</stages>
<time-ago :pipeline='pipeline' :svgs='svgs'></time-ago>
<pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions>
</tr>
</tbody>
</table>
</div>
<div class="pipelines realtime-loading" v-if='pageRequest'>
<i class="fa fa-spinner fa-spin"></i>
</div>
<gl-pagination
v-if='pageInfo.total > pageInfo.perPage'
:pagenum='pagenum'
:change='change'
:count='count.all'
:pageInfo='pageInfo'
>
</gl-pagination>
</div>
`,
});
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,76 @@
/* global Vue, Flash, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueStage = Vue.extend({
data() {
return {
request: false,
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
};
},
props: ['stage', 'svgs', 'match'],
methods: {
fetchBuilds() {
if (this.request) return this.clearBuilds();
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.request = true;
this.builds = JSON.parse(response.body).html;
}, () => {
const flash = new Flash('Something went wrong on our end.');
this.request = false;
return flash;
});
},
clearBuilds() {
this.builds = '';
this.request = false;
},
},
computed: {
buildsOrSpinner() {
return this.request ? this.builds : this.spinner;
},
dropdownClass() {
if (this.request) 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}`;
},
svg() {
const icon = this.stage.status.icon;
const stageIcon = icon.replace(/icon/i, 'stage_icon');
return this.svgs[this.match(stageIcon)];
},
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'
@blur='fetchBuilds'
:class="triggerButtonClass"
:title='stage.title'
data-placement="top"
data-toggle="dropdown"
type="button">
<span v-html="svg"></span>
<i class="fa fa-caret-down "></i>
</button>
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div class="arrow-up"></div>
<div :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" v-html="buildsOrSpinner"></div>
</ul>
</div>
`,
});
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,21 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueStages = Vue.extend({
components: {
'vue-stage': gl.VueStage,
},
props: ['pipeline', 'svgs', 'match'],
template: `
<td class="stage-cell">
<div
class="stage-container dropdown js-mini-pipeline-graph"
v-for='stage in pipeline.details.stages'
>
<vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage>
</div>
</td>
`,
});
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,34 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueStatusScope = Vue.extend({
props: [
'pipeline', 'svgs', 'match',
],
computed: {
cssClasses() {
const cssObject = { 'ci-status': true };
cssObject[`ci-${this.pipeline.details.status.group}`] = true;
return cssObject;
},
svg() {
return this.svgs[this.match(this.pipeline.details.status.icon)];
},
detailsPath() {
const { status } = this.pipeline.details;
return status.has_details ? status.details_path : false;
},
},
template: `
<td class="commit-link">
<a
:class='cssClasses'
:href='detailsPath'
v-html='svg + pipeline.details.status.text'
>
</a>
</td>
`,
});
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,59 @@
/* global gl, Flash */
/* eslint-disable no-param-reassign, no-underscore-dangle */
/*= require vue_realtime_listener/index.js */
((gl) => {
const pageValues = 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'],
});
gl.PipelineStore = class {
fetchDataLoop(Vue, pageNum, url, apiScope) {
const updatePipelineNums = (count) => {
const { all } = count;
const running = count.running_or_pending;
document.querySelector('.js-totalbuilds-count').innerHTML = all;
document.querySelector('.js-running-count').innerHTML = running;
};
const goFetch = () =>
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);
updatePipelineNums(this.count);
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('Something went wrong on our end.');
});
goFetch();
const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => {
this.$children
.filter(e => e.$options._componentTag === 'time-ago')
.forEach(e => e.changeTime());
}, 10000);
};
startTimeLoops();
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals);
}
};
})(window.gl || (window.gl = {}));

View file

@ -0,0 +1,73 @@
/* global Vue, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueTimeAgo = Vue.extend({
data() {
return {
currentTime: new Date(),
};
},
props: ['pipeline', 'svgs'],
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>
<p class="duration" v-if='duration'>
<span v-html='svgs.iconTimer'></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

@ -0,0 +1,18 @@
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueRealtimeListener = (removeIntervals, startIntervals) => {
const removeAll = () => {
removeIntervals();
window.removeEventListener('beforeunload', removeIntervals);
window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals);
document.removeEventListener('page:fetch', removeAll);
};
window.addEventListener('beforeunload', removeIntervals);
window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals);
document.addEventListener('page:fetch', removeAll);
};
})(window.gl || (window.gl = {}));

View file

@ -1,4 +1,9 @@
.pipelines {
.realtime-loading {
font-size: 40px;
text-align: center;
}
.stage {
max-width: 90px;
width: 90px;
@ -24,6 +29,10 @@
min-width: 1200px;
table-layout: fixed;
.label {
margin-bottom: 3px;
}
.pipeline-id {
color: $black;
}
@ -177,6 +186,7 @@
.stage-cell {
font-size: 0;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
height: 22px;
width: 22px;

View file

@ -7,11 +7,33 @@ class Projects::PipelinesController < Projects::ApplicationController
def index
@scope = params[:scope]
@pipelines = PipelinesFinder.new(project).execute(scope: @scope).page(params[:page]).per(30)
@pipelines = @pipelines.includes(project: :namespace)
@pipelines = PipelinesFinder
.new(project)
.execute(scope: @scope)
.page(params[:page])
.per(30)
@running_or_pending_count = PipelinesFinder.new(project).execute(scope: 'running').count
@pipelines_count = PipelinesFinder.new(project).execute.count
@running_or_pending_count = PipelinesFinder
.new(project).execute(scope: 'running').count
@pipelines_count = PipelinesFinder
.new(project).execute.count
respond_to do |format|
format.html
format.json do
render json: {
pipelines: PipelineSerializer
.new(project: @project, user: @current_user)
.with_pagination(request, response)
.represent(@pipelines),
count: {
all: @pipelines_count,
running_or_pending: @running_or_pending_count
}
}
end
end
end
def new

View file

@ -142,7 +142,7 @@ module Ci
end
def artifacts
builds.latest.with_artifacts_not_expired
builds.latest.with_artifacts_not_expired.includes(project: [:namespace])
end
def project_id
@ -191,7 +191,11 @@ module Ci
end
def manual_actions
builds.latest.manual_actions
builds.latest.manual_actions.includes(project: [:namespace])
end
def stuck?
builds.pending.any?(&:stuck?)
end
def retryable?
@ -283,6 +287,10 @@ module Ci
end
end
def has_yaml_errors?
yaml_errors.present?
end
def environments
builds.where.not(environment: nil).success.pluck(:environment).uniq
end

View file

@ -0,0 +1,14 @@
class BuildActionEntity < Grape::Entity
include RequestAwareEntity
expose :name do |build|
build.name.humanize
end
expose :path do |build|
play_namespace_project_build_path(
build.project.namespace,
build.project,
build)
end
end

View file

@ -0,0 +1,14 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
expose :name do |build|
build.name
end
expose :path do |build|
download_namespace_project_build_artifacts_path(
build.project.namespace,
build.project,
build)
end
end

View file

@ -3,6 +3,10 @@ class CommitEntity < API::Entities::RepoCommit
expose :author, using: UserEntity
expose :author_gravatar_url do |commit|
GravatarService.new.execute(commit.author_email)
end
expose :commit_url do |commit|
namespace_project_tree_url(
request.project.namespace,

View file

@ -0,0 +1,83 @@
class PipelineEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :user, using: UserEntity
expose :path do |pipeline|
namespace_project_pipeline_path(
pipeline.project.namespace,
pipeline.project,
pipeline)
end
expose :details do
expose :status do |pipeline, options|
StatusEntity.represent(
pipeline.detailed_status(request.user),
options)
end
expose :duration
expose :finished_at
expose :stages, using: StageEntity
expose :artifacts, using: BuildArtifactEntity
expose :manual_actions, using: BuildActionEntity
end
expose :flags do
expose :latest?, as: :latest
expose :triggered?, as: :triggered
expose :stuck?, as: :stuck
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
end
expose :ref do
expose :name do |pipeline|
pipeline.ref
end
expose :path do |pipeline|
namespace_project_tree_path(
pipeline.project.namespace,
pipeline.project,
id: pipeline.ref)
end
expose :tag?, as: :tag
expose :branch?, as: :branch
end
expose :commit, using: CommitEntity
expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? }
expose :retry_path, if: proc { can_retry? } do |pipeline|
retry_namespace_project_pipeline_path(pipeline.project.namespace,
pipeline.project,
pipeline.id)
end
expose :cancel_path, if: proc { can_cancel? } do |pipeline|
cancel_namespace_project_pipeline_path(pipeline.project.namespace,
pipeline.project,
pipeline.id)
end
expose :created_at, :updated_at
private
alias_method :pipeline, :object
def can_retry?
pipeline.retryable? &&
can?(request.user, :update_pipeline, pipeline)
end
def can_cancel?
pipeline.cancelable? &&
can?(request.user, :update_pipeline, pipeline)
end
end

View file

@ -0,0 +1,40 @@
class PipelineSerializer < BaseSerializer
entity PipelineEntity
class InvalidResourceError < StandardError; end
include API::Helpers::Pagination
Struct.new('Pagination', :request, :response)
def represent(resource, opts = {})
if paginated?
raise InvalidResourceError unless resource.respond_to?(:page)
super(paginate(resource.includes(project: :namespace)), opts)
else
super(resource, opts)
end
end
def paginated?
defined?(@pagination)
end
def with_pagination(request, response)
tap { @pagination = Struct::Pagination.new(request, response) }
end
private
# Methods needed by `API::Helpers::Pagination`
#
def params
@pagination.request.query_parameters
end
def request
@pagination.request
end
def header(header, value)
@pagination.response.headers[header] = value
end
end

View file

@ -2,14 +2,11 @@ module RequestAwareEntity
extend ActiveSupport::Concern
included do
include Gitlab::Routing.url_helpers
include Gitlab::Routing
include Gitlab::Allowable
end
def request
@options.fetch(:request)
end
def can?(object, action, subject)
Ability.allowed?(object, action, subject)
options.fetch(:request)
end
end

View file

@ -0,0 +1,38 @@
class StageEntity < Grape::Entity
include RequestAwareEntity
expose :name
expose :title do |stage|
"#{stage.name}: #{detailed_status.label}"
end
expose :detailed_status,
as: :status,
with: StatusEntity
expose :path do |stage|
namespace_project_pipeline_path(
stage.pipeline.project.namespace,
stage.pipeline.project,
stage.pipeline,
anchor: stage.name)
end
expose :dropdown_path do |stage|
stage_namespace_project_pipeline_path(
stage.pipeline.project.namespace,
stage.pipeline.project,
stage.pipeline,
stage: stage.name,
format: :json)
end
private
alias_method :stage, :object
def detailed_status
stage.detailed_status(request.user)
end
end

View file

@ -0,0 +1,8 @@
class StatusEntity < Grape::Entity
include RequestAwareEntity
expose :icon, :text, :label, :group
expose :has_details?, as: :has_details
expose :details_path
end

View file

@ -35,21 +35,34 @@
= link_to ci_lint_path, class: 'btn btn-default' do
%span CI Lint
.content-list.pipelines
.content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } }
- if @pipelines.blank?
%div
.nothing-here-block No pipelines to show
- else
.table-holder
%table.table.ci-table.js-pipeline-table
%thead
%th.pipeline-status Status
%th.pipeline-info Pipeline
%th.pipeline-commit Commit
%th.pipeline-stages Stages
%th.pipeline-date
%th.pipeline-actions.hidden-xs
= render @pipelines, commit_sha: true, stage: true, allow_retry: true
.pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"),
"icon_status_canceled" => custom_icon("icon_status_canceled"),
"icon_status_running" => custom_icon("icon_status_running"),
"icon_status_skipped" => custom_icon("icon_status_skipped"),
"icon_status_created" => custom_icon("icon_status_created"),
"icon_status_pending" => custom_icon("icon_status_pending"),
"icon_status_success" => custom_icon("icon_status_success"),
"icon_status_failed" => custom_icon("icon_status_failed"),
"icon_status_warning" => custom_icon("icon_status_warning"),
"stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"),
"stage_icon_status_running" => custom_icon("icon_status_running_borderless"),
"stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"),
"stage_icon_status_created" => custom_icon("icon_status_created_borderless"),
"stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"),
"stage_icon_status_success" => custom_icon("icon_status_success_borderless"),
"stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"),
"stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"),
"icon_play" => custom_icon("icon_play"),
"icon_timer" => custom_icon("icon_timer"),
"icon_status_manual" => custom_icon("icon_status_manual"),
} }
= paginate @pipelines, theme: 'gitlab'
.vue-pipelines-index
= page_specific_javascript_tag('vue_pagination/index.js')
= page_specific_javascript_tag('vue_pipelines_index/index.js')

View file

@ -109,6 +109,8 @@ module Gitlab
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
config.assets.precompile << "vue_pipelines_index/index.js"
config.assets.precompile << "vue_pagination/index.js"
config.assets.precompile << "vendor/assets/fonts/*"
# Version of your assets, change this if you want to expire all your assets

View file

@ -1,6 +1,7 @@
module API
module Helpers
include Gitlab::Utils
include Helpers::Pagination
SUDO_HEADER = "HTTP_SUDO"
SUDO_PARAM = :sudo
@ -85,12 +86,6 @@ module API
IssuesFinder.new(current_user, project_id: user_project.id).find(id)
end
def paginate(relation)
relation.page(params[:page]).per(params[:per_page].to_i).tap do |data|
add_pagination_headers(data)
end
end
def authenticate!
unauthorized! unless current_user
end
@ -361,38 +356,6 @@ module API
@sudo_identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER]
end
def add_pagination_headers(paginated_data)
header 'X-Total', paginated_data.total_count.to_s
header 'X-Total-Pages', paginated_data.total_pages.to_s
header 'X-Per-Page', paginated_data.limit_value.to_s
header 'X-Page', paginated_data.current_page.to_s
header 'X-Next-Page', paginated_data.next_page.to_s
header 'X-Prev-Page', paginated_data.prev_page.to_s
header 'Link', pagination_links(paginated_data)
end
def pagination_links(paginated_data)
request_url = request.url.split('?').first
request_params = params.clone
request_params[:per_page] = paginated_data.limit_value
links = []
request_params[:page] = paginated_data.current_page - 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page?
request_params[:page] = paginated_data.current_page + 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page?
request_params[:page] = 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
request_params[:page] = paginated_data.total_pages
links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
links.join(', ')
end
def secret_token
Gitlab::Shell.secret_token
end

View file

@ -0,0 +1,45 @@
module API
module Helpers
module Pagination
def paginate(relation)
relation.page(params[:page]).per(params[:per_page].to_i).tap do |data|
add_pagination_headers(data)
end
end
private
def add_pagination_headers(paginated_data)
header 'X-Total', paginated_data.total_count.to_s
header 'X-Total-Pages', paginated_data.total_pages.to_s
header 'X-Per-Page', paginated_data.limit_value.to_s
header 'X-Page', paginated_data.current_page.to_s
header 'X-Next-Page', paginated_data.next_page.to_s
header 'X-Prev-Page', paginated_data.prev_page.to_s
header 'Link', pagination_links(paginated_data)
end
def pagination_links(paginated_data)
request_url = request.url.split('?').first
request_params = params.clone
request_params[:per_page] = paginated_data.limit_value
links = []
request_params[:page] = paginated_data.current_page - 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page?
request_params[:page] = paginated_data.current_page + 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page?
request_params[:page] = 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
request_params[:page] = paginated_data.total_pages
links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
links.join(', ')
end
end
end
end

View file

@ -5,13 +5,33 @@ describe Projects::PipelinesController do
let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) }
let(:pipeline) { create(:ci_pipeline, project: project) }
before do
sign_in(user)
end
describe 'GET index.json' do
before do
create_list(:ci_empty_pipeline, 2, project: project)
get :index, namespace_id: project.namespace.path,
project_id: project.path,
format: :json
end
it 'returns JSON with serialized pipelines' do
expect(response).to have_http_status(:ok)
expect(json_response).to include('pipelines')
expect(json_response['pipelines'].count).to eq 2
expect(json_response['count']['all']).to eq 2
expect(json_response['count']['running_or_pending']).to eq 2
end
end
describe 'GET stages.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when accessing existing stage' do
before do
create(:ci_build, pipeline: pipeline, stage: 'build')

View file

@ -31,6 +31,14 @@ FactoryGirl.define do
File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
end
end
# Populates pipeline with errors
#
pipeline.config_processor if evaluator.config
end
trait :invalid do
config(rspec: nil)
end
end
end

View file

@ -8,6 +8,10 @@ FactoryGirl.define do
is_shared false
active true
trait :online do
contacted_at Time.now
end
trait :shared do
is_shared true
end

View file

@ -1,267 +1,364 @@
require 'spec_helper'
describe 'Pipelines', :feature, :js do
include GitlabRoutingHelper
include WaitForAjax
include WaitForVueResource
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
before do
login_as(user)
project.team << [user, :developer]
end
context 'when user is logged in' do
let(:user) { create(:user) }
describe 'GET /:project/pipelines' do
let!(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running') }
[:all, :running, :branches].each do |scope|
context "displaying #{scope}" do
let(:project) { create(:project) }
before { visit namespace_project_pipelines_path(project.namespace, project, scope: scope) }
it { expect(page).to have_content(pipeline.short_sha) }
end
before do
login_as(user)
project.team << [user, :developer]
end
context 'anonymous access' do
before { visit namespace_project_pipelines_path(project.namespace, project) }
describe 'GET /:project/pipelines' do
let(:project) { create(:project) }
it { expect(page).to have_http_status(:success) }
end
context 'cancelable pipeline' do
let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') }
before do
build.run
visit namespace_project_pipelines_path(project.namespace, project)
let!(:pipeline) do
create(
:ci_empty_pipeline,
project: project,
ref: 'master',
status: 'running',
sha: project.commit.id,
)
end
it { expect(page).to have_link('Cancel') }
it { expect(page).to have_selector('.ci-running') }
[:all, :running, :branches].each do |scope|
context "when displaying #{scope}" do
before do
visit_project_pipelines(scope: scope)
end
context 'when canceling' do
before { click_link('Cancel') }
it { expect(page).not_to have_link('Cancel') }
it { expect(page).to have_selector('.ci-canceled') }
end
end
context 'retryable pipelines' do
let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') }
before do
build.drop
visit namespace_project_pipelines_path(project.namespace, project)
end
it { expect(page).to have_link('Retry') }
it { expect(page).to have_selector('.ci-failed') }
context 'when retrying' do
before { click_link('Retry') }
it { expect(page).not_to have_link('Retry') }
it { expect(page).to have_selector('.ci-running') }
end
end
context 'with manual actions' do
let!(:manual) do
create(:ci_build, :manual, pipeline: pipeline,
name: 'manual build',
stage: 'test',
commands: 'test')
end
before do
visit namespace_project_pipelines_path(project.namespace, project)
end
it 'has link to the manual action' do
find('.js-pipeline-dropdown-manual-actions').click
expect(page).to have_link('Manual build')
end
context 'when manual action was played' do
before do
find('.js-pipeline-dropdown-manual-actions').click
click_link('Manual build')
end
it 'enqueues manual action job' do
expect(manual.reload).to be_pending
it 'contains pipeline commit short SHA' do
expect(page).to have_content(pipeline.short_sha)
end
end
end
end
context 'for generic statuses' do
context 'when running' do
let!(:running) { create(:generic_commit_status, status: 'running', pipeline: pipeline, stage: 'test') }
context 'when pipeline is cancelable' do
let!(:build) do
create(:ci_build, pipeline: pipeline,
stage: 'test',
commands: 'test')
end
before do
visit namespace_project_pipelines_path(project.namespace, project)
build.run
visit_project_pipelines
end
it 'is cancelable' do
it 'indicates that pipeline can be canceled' do
expect(page).to have_link('Cancel')
end
it 'has pipeline running' do
expect(page).to have_selector('.ci-running')
end
context 'when canceling' do
before { click_link('Cancel') }
it { expect(page).not_to have_link('Cancel') }
it { expect(page).to have_selector('.ci-canceled') }
it 'indicated that pipelines was canceled' do
expect(page).not_to have_link('Cancel')
expect(page).to have_selector('.ci-canceled')
end
end
end
context 'when failed' do
let!(:status) { create(:generic_commit_status, :pending, pipeline: pipeline, stage: 'test') }
context 'when pipeline is retryable' do
let!(:build) do
create(:ci_build, pipeline: pipeline,
stage: 'test',
commands: 'test')
end
before do
status.drop
visit namespace_project_pipelines_path(project.namespace, project)
build.drop
visit_project_pipelines
end
it 'is not retryable' do
expect(page).not_to have_link('Retry')
end
it 'has failed pipeline' do
it 'indicates that pipeline can be retried' do
expect(page).to have_link('Retry')
expect(page).to have_selector('.ci-failed')
end
end
end
context 'downloadable pipelines' do
context 'with artifacts' do
let!(:with_artifacts) { create(:ci_build, :artifacts, :success, pipeline: pipeline, name: 'rspec tests', stage: 'test') }
context 'when retrying' do
before { click_link('Retry') }
before { visit namespace_project_pipelines_path(project.namespace, project) }
it { expect(page).to have_selector('.build-artifacts') }
it do
find('.js-pipeline-dropdown-download').click
expect(page).to have_link(with_artifacts.name)
it 'shows running pipeline that is not retryable' do
expect(page).not_to have_link('Retry')
expect(page).to have_selector('.ci-running')
end
end
end
context 'with artifacts expired' do
let!(:with_artifacts_expired) { create(:ci_build, :artifacts_expired, :success, pipeline: pipeline, name: 'rspec', stage: 'test') }
context 'when pipeline has configuration errors' do
let(:pipeline) do
create(:ci_pipeline, :invalid, project: project)
end
before { visit namespace_project_pipelines_path(project.namespace, project) }
before { visit_project_pipelines }
it { expect(page).not_to have_selector('.build-artifacts') }
it 'contains badge that indicates errors' do
expect(page).to have_content 'yaml invalid'
end
it 'contains badge with tooltip which contains error' do
expect(pipeline).to have_yaml_errors
expect(page).to have_selector(
%Q{span[data-original-title="#{pipeline.yaml_errors}"]})
end
end
context 'without artifacts' do
let!(:without_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage: 'test') }
context 'with manual actions' do
let!(:manual) do
create(:ci_build, :manual,
pipeline: pipeline,
name: 'manual build',
stage: 'test',
commands: 'test')
end
before { visit namespace_project_pipelines_path(project.namespace, project) }
before { visit_project_pipelines }
it { expect(page).not_to have_selector('.build-artifacts') }
it 'has a dropdown with play button' do
expect(page).to have_selector('.dropdown-toggle.btn.btn-default .icon-play')
end
it 'has link to the manual action' do
find('.js-pipeline-dropdown-manual-actions').click
expect(page).to have_link('Manual build')
end
context 'when manual action was played' do
before do
find('.js-pipeline-dropdown-manual-actions').click
click_link('Manual build')
end
it 'enqueues manual action job' do
expect(manual.reload).to be_pending
end
end
end
context 'for generic statuses' do
context 'when running' do
let!(:running) do
create(:generic_commit_status,
status: 'running',
pipeline: pipeline,
stage: 'test')
end
before { visit_project_pipelines }
it 'is cancelable' do
expect(page).to have_link('Cancel')
end
it 'has pipeline running' do
expect(page).to have_selector('.ci-running')
end
context 'when canceling' do
before { click_link('Cancel') }
it 'indicates that pipeline was canceled' do
expect(page).not_to have_link('Cancel')
expect(page).to have_selector('.ci-canceled')
end
end
end
context 'when failed' do
let!(:status) do
create(:generic_commit_status, :pending,
pipeline: pipeline,
stage: 'test')
end
before do
status.drop
visit_project_pipelines
end
it 'is not retryable' do
expect(page).not_to have_link('Retry')
end
it 'has failed pipeline' do
expect(page).to have_selector('.ci-failed')
end
end
end
context 'downloadable pipelines' do
context 'with artifacts' do
let!(:with_artifacts) do
create(:ci_build, :artifacts, :success,
pipeline: pipeline,
name: 'rspec tests',
stage: 'test')
end
before { visit_project_pipelines }
it 'has artifats' do
expect(page).to have_selector('.build-artifacts')
end
it 'has artifacts download dropdown' do
find('.js-pipeline-dropdown-download').click
expect(page).to have_link(with_artifacts.name)
end
end
context 'with artifacts expired' do
let!(:with_artifacts_expired) do
create(:ci_build, :artifacts_expired, :success,
pipeline: pipeline,
name: 'rspec',
stage: 'test')
end
before { visit_project_pipelines }
it { expect(page).not_to have_selector('.build-artifacts') }
end
context 'without artifacts' do
let!(:without_artifacts) do
create(:ci_build, :success,
pipeline: pipeline,
name: 'rspec',
stage: 'test')
end
before { visit_project_pipelines }
it { expect(page).not_to have_selector('.build-artifacts') }
end
end
context 'mini pipeline graph' do
let!(:build) do
create(:ci_build, :pending, pipeline: pipeline,
stage: 'build',
name: 'build')
end
before { visit_project_pipelines }
it 'should render a mini pipeline graph' do
expect(page).to have_selector('.js-mini-pipeline-graph')
expect(page).to have_selector('.js-builds-dropdown-button')
end
context 'when clicking a stage badge' do
it 'should open a dropdown' do
find('.js-builds-dropdown-button').trigger('click')
expect(page).to have_link build.name
end
it 'should be possible to cancel pending build' do
find('.js-builds-dropdown-button').trigger('click')
find('a.js-ci-action-icon').trigger('click')
expect(page).to have_content('canceled')
expect(build.reload).to be_canceled
end
end
end
end
context 'mini pipleine graph' do
let!(:build) do
create(:ci_build, pipeline: pipeline, stage: 'build', name: 'build')
end
describe 'POST /:project/pipelines' do
let(:project) { create(:project) }
before do
visit namespace_project_pipelines_path(project.namespace, project)
visit new_namespace_project_pipeline_path(project.namespace, project)
end
it 'should render a mini pipeline graph' do
endpoint = stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: build.name)
context 'for valid commit' do
before { fill_in('pipeline[ref]', with: 'master') }
expect(page).to have_selector('.js-mini-pipeline-graph')
expect(page).to have_selector(".js-builds-dropdown-button[data-stage-endpoint='#{endpoint}']")
end
context 'with gitlab-ci.yml' do
before { stub_ci_pipeline_to_return_yaml_file }
context 'when clicking a graph stage' do
it 'should open a dropdown' do
find('.js-builds-dropdown-button').trigger('click')
wait_for_ajax
expect(page).to have_link build.name
it 'creates a new pipeline' do
expect { click_on 'Create pipeline' }
.to change { Ci::Pipeline.count }.by(1)
end
end
it 'should be possible to retry the failed build' do
find('.js-builds-dropdown-button').trigger('click')
context 'without gitlab-ci.yml' do
before { click_on 'Create pipeline' }
wait_for_ajax
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
end
end
find('a.js-ci-action-icon').trigger('click')
expect(page).not_to have_content('Cancel running')
context 'for invalid commit' do
before do
fill_in('pipeline[ref]', with: 'invalid-reference')
click_on 'Create pipeline'
end
it { expect(page).to have_content('Reference not found') }
end
end
describe 'Create pipelines' do
let(:project) { create(:project) }
before do
visit new_namespace_project_pipeline_path(project.namespace, project)
end
describe 'new pipeline page' do
it 'has field to add a new pipeline' do
expect(page).to have_field('pipeline[ref]')
expect(page).to have_content('Create for')
end
end
describe 'find pipelines' do
it 'shows filtered pipelines', js: true do
fill_in('pipeline[ref]', with: 'fix')
find('input#ref').native.send_keys(:keydown)
within('.ui-autocomplete') do
expect(page).to have_selector('li', text: 'fix')
end
end
end
end
end
describe 'POST /:project/pipelines' do
let(:project) { create(:project) }
before { visit new_namespace_project_pipeline_path(project.namespace, project) }
context 'for valid commit' do
before { fill_in('pipeline[ref]', with: 'master') }
context 'with gitlab-ci.yml' do
before { stub_ci_pipeline_to_return_yaml_file }
it { expect{ click_on 'Create pipeline' }.to change{ Ci::Pipeline.count }.by(1) }
end
context 'without gitlab-ci.yml' do
before { click_on 'Create pipeline' }
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
end
end
context 'for invalid commit' do
before do
fill_in('pipeline[ref]', with: 'invalid-reference')
click_on 'Create pipeline'
end
it { expect(page).to have_content('Reference not found') }
end
end
describe 'Create pipelines', feature: true do
let(:project) { create(:project) }
context 'when user is not logged in' do
before do
visit new_namespace_project_pipeline_path(project.namespace, project)
visit namespace_project_pipelines_path(project.namespace, project)
end
describe 'new pipeline page' do
it 'has field to add a new pipeline' do
expect(page).to have_field('pipeline[ref]')
expect(page).to have_content('Create for')
end
context 'when project is public' do
let(:project) { create(:project, :public) }
it { expect(page).to have_content 'No pipelines to show' }
it { expect(page).to have_http_status(:success) }
end
describe 'find pipelines' do
it 'shows filtered pipelines', js: true do
fill_in('pipeline[ref]', with: 'fix')
find('input#ref').native.send_keys(:keydown)
context 'when project is private' do
let(:project) { create(:project, :private) }
within('.ui-autocomplete') do
expect(page).to have_selector('li', text: 'fix')
end
end
it { expect(page).to have_content 'You need to sign in' }
end
end
def visit_project_pipelines(**query)
visit namespace_project_pipelines_path(project.namespace, project, query)
wait_for_vue_resource
end
end

View file

@ -0,0 +1,168 @@
//= require vue
//= require lib/utils/common_utils
//= require vue_pagination/index
/* global fixture, gl */
describe('Pagination component', () => {
let component;
const changeChanges = {
one: '',
two: '',
};
const change = (one, two) => {
changeChanges.one = one;
changeChanges.two = two;
};
it('should render and start at page 1', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 2,
previousPage: '',
},
change,
},
});
expect(component.$el.classList).toContain('gl-pagination');
component.changePage({ target: { innerText: '1' } });
expect(changeChanges.one).toEqual(1);
expect(changeChanges.two).toEqual('all');
});
it('should go to the previous page', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 3,
previousPage: 1,
},
change,
},
});
component.changePage({ target: { innerText: 'Prev' } });
expect(changeChanges.one).toEqual(1);
expect(changeChanges.two).toEqual('all');
});
it('should go to the next page', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 5,
previousPage: 3,
},
change,
},
});
component.changePage({ target: { innerText: 'Next' } });
expect(changeChanges.one).toEqual(5);
expect(changeChanges.two).toEqual('all');
});
it('should go to the last page', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 5,
previousPage: 3,
},
change,
},
});
component.changePage({ target: { innerText: 'Last >>' } });
expect(changeChanges.one).toEqual(10);
expect(changeChanges.two).toEqual('all');
});
it('should go to the first page', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 5,
previousPage: 3,
},
change,
},
});
component.changePage({ target: { innerText: '<< First' } });
expect(changeChanges.one).toEqual(1);
expect(changeChanges.two).toEqual('all');
});
it('should do nothing', () => {
fixture.set('<div class="test-pagination-container"></div>');
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: {
pageInfo: {
totalPages: 10,
nextPage: 2,
previousPage: '',
},
change,
},
});
component.changePage({ target: { innerText: '...' } });
expect(changeChanges.one).toEqual(1);
expect(changeChanges.two).toEqual('all');
});
});
describe('paramHelper', () => {
it('can parse url parameters correctly', () => {
window.history.pushState({}, null, '?scope=all&p=2');
const scope = gl.utils.getParameterByName('scope');
const p = gl.utils.getParameterByName('p');
expect(scope).toEqual('all');
expect(p).toEqual('2');
});
it('returns null if param not in url', () => {
window.history.pushState({}, null, '?p=2');
const scope = gl.utils.getParameterByName('scope');
const p = gl.utils.getParameterByName('p');
expect(scope).toEqual(null);
expect(p).toEqual('2');
});
});

View file

@ -0,0 +1,94 @@
require 'spec_helper'
describe API::Helpers::Pagination do
let(:resource) { Project.all }
subject do
Class.new.include(described_class).new
end
describe '#paginate' do
let(:value) { spy('return value') }
before do
allow(value).to receive(:to_query).and_return(value)
allow(subject).to receive(:header).and_return(value)
allow(subject).to receive(:params).and_return(value)
allow(subject).to receive(:request).and_return(value)
end
describe 'required instance methods' do
let(:return_spy) { spy }
it 'requires some instance methods' do
expect_message(:header)
expect_message(:params)
expect_message(:request)
subject.paginate(resource)
end
end
context 'when resource can be paginated' do
before do
create_list(:empty_project, 3)
end
describe 'first page' do
before do
allow(subject).to receive(:params)
.and_return({ page: 1, per_page: 2 })
end
it 'returns appropriate amount of resources' do
expect(subject.paginate(resource).count).to eq 2
end
it 'adds appropriate headers' do
expect_header('X-Total', '3')
expect_header('X-Total-Pages', '2')
expect_header('X-Per-Page', '2')
expect_header('X-Page', '1')
expect_header('X-Next-Page', '2')
expect_header('X-Prev-Page', '')
expect_header('Link', any_args)
subject.paginate(resource)
end
end
describe 'second page' do
before do
allow(subject).to receive(:params)
.and_return({ page: 2, per_page: 2 })
end
it 'returns appropriate amount of resources' do
expect(subject.paginate(resource).count).to eq 1
end
it 'adds appropriate headers' do
expect_header('X-Total', '3')
expect_header('X-Total-Pages', '2')
expect_header('X-Per-Page', '2')
expect_header('X-Page', '2')
expect_header('X-Next-Page', '')
expect_header('X-Prev-Page', '1')
expect_header('Link', any_args)
subject.paginate(resource)
end
end
end
def expect_header(name, value)
expect(subject).to receive(:header).with(name, value)
end
def expect_message(method)
expect(subject).to receive(method)
.at_least(:once).and_return(value)
end
end
end

View file

@ -888,6 +888,48 @@ describe Ci::Pipeline, models: true do
end
end
describe '#stuck?' do
before do
create(:ci_build, :pending, pipeline: pipeline)
end
context 'when pipeline is stuck' do
it 'is stuck' do
expect(pipeline).to be_stuck
end
end
context 'when pipeline is not stuck' do
before { create(:ci_runner, :shared, :online) }
it 'is not stuck' do
expect(pipeline).not_to be_stuck
end
end
end
describe '#has_yaml_errors?' do
context 'when pipeline has errors' do
let(:pipeline) do
create(:ci_pipeline, config: { rspec: nil })
end
it 'contains yaml errors' do
expect(pipeline).to have_yaml_errors
end
end
context 'when pipeline does not have errors' do
let(:pipeline) do
create(:ci_pipeline, config: { rspec: { script: 'rake test' } })
end
it 'does not containyaml errors' do
expect(pipeline).not_to have_yaml_errors
end
end
end
describe 'notifications when pipeline success or failed' do
let(:project) { create(:project) }

View file

@ -0,0 +1,21 @@
require 'spec_helper'
describe BuildActionEntity do
let(:build) { create(:ci_build, name: 'test_build') }
let(:entity) do
described_class.new(build, request: double)
end
describe '#as_json' do
subject { entity.as_json }
it 'contains humanized build name' do
expect(subject[:name]).to eq 'Test build'
end
it 'contains path to the action play' do
expect(subject[:path]).to include "builds/#{build.id}/play"
end
end
end

View file

@ -0,0 +1,22 @@
require 'spec_helper'
describe BuildArtifactEntity do
let(:build) { create(:ci_build, name: 'test:build') }
let(:entity) do
described_class.new(build, request: double)
end
describe '#as_json' do
subject { entity.as_json }
it 'contains build name' do
expect(subject[:name]).to eq 'test:build'
end
it 'contains path to the artifacts' do
expect(subject[:path])
.to include "builds/#{build.id}/artifacts/download"
end
end
end

View file

@ -45,4 +45,8 @@ describe CommitEntity do
subject
end
it 'exposes gravatar url that belongs to author' do
expect(subject.fetch(:author_gravatar_url)).to match /gravatar/
end
end

View file

@ -0,0 +1,138 @@
require 'spec_helper'
describe PipelineEntity do
let(:user) { create(:user) }
let(:request) { double('request') }
before do
allow(request).to receive(:user).and_return(user)
end
let(:entity) do
described_class.represent(pipeline, request: request)
end
describe '#as_json' do
subject { entity.as_json }
context 'when pipeline is empty' do
let(:pipeline) { create(:ci_empty_pipeline) }
it 'contains required fields' do
expect(subject).to include :id, :user, :path
expect(subject).to include :ref, :commit
expect(subject).to include :updated_at, :created_at
end
it 'contains details' do
expect(subject).to include :details
expect(subject[:details])
.to include :duration, :finished_at
expect(subject[:details])
.to include :stages, :artifacts, :manual_actions
expect(subject[:details][:status]).to include :icon, :text, :label
end
it 'contains flags' do
expect(subject).to include :flags
expect(subject[:flags])
.to include :latest, :triggered, :stuck,
:yaml_errors, :retryable, :cancelable
end
end
context 'when pipeline is retryable' do
let(:project) { create(:empty_project) }
let(:pipeline) do
create(:ci_pipeline, status: :success, project: project)
end
before do
create(:ci_build, :failed, pipeline: pipeline)
end
context 'user has ability to retry pipeline' do
before { project.team << [user, :developer] }
it 'retryable flag is true' do
expect(subject[:flags][:retryable]).to eq true
end
it 'contains retry path' do
expect(subject[:retry_path]).to be_present
end
end
context 'user does not have ability to retry pipeline' do
it 'retryable flag is false' do
expect(subject[:flags][:retryable]).to eq false
end
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
end
end
end
context 'when pipeline is cancelable' do
let(:project) { create(:empty_project) }
let(:pipeline) do
create(:ci_pipeline, status: :running, project: project)
end
before do
create(:ci_build, :pending, pipeline: pipeline)
end
context 'user has ability to cancel pipeline' do
before { project.team << [user, :developer] }
it 'cancelable flag is true' do
expect(subject[:flags][:cancelable]).to eq true
end
it 'contains cancel path' do
expect(subject[:cancel_path]).to be_present
end
end
context 'user does not have ability to cancel pipeline' do
it 'cancelable flag is false' do
expect(subject[:flags][:cancelable]).to eq false
end
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
end
end
end
context 'when pipeline has YAML errors' do
let(:pipeline) do
create(:ci_pipeline, config: { rspec: { invalid: :value } })
end
it 'contains flag that indicates there are errors' do
expect(subject[:flags][:yaml_errors]).to be true
end
it 'contains information about error' do
expect(subject[:yaml_errors]).to be_present
end
end
context 'when pipeline does not have YAML errors' do
let(:pipeline) { create(:ci_empty_pipeline) }
it 'contains flag that indicates there are no errors' do
expect(subject[:flags][:yaml_errors]).to be false
end
it 'does not contain field that normally holds an error' do
expect(subject).not_to have_key(:yaml_errors)
end
end
end
end

View file

@ -0,0 +1,101 @@
require 'spec_helper'
describe PipelineSerializer do
let(:user) { create(:user) }
let(:serializer) do
described_class.new(user: user)
end
let(:entity) do
serializer.represent(resource)
end
subject { entity.as_json }
describe '#represent' do
context 'when used without pagination' do
it 'created a not paginated serializer' do
expect(serializer).not_to be_paginated
end
context 'when a single object is being serialized' do
let(:resource) { create(:ci_empty_pipeline) }
it 'serializers the pipeline object' do
expect(subject[:id]).to eq resource.id
end
end
context 'when multiple objects are being serialized' do
let(:resource) { create_list(:ci_pipeline, 2) }
it 'serializers the array of pipelines' do
expect(subject).not_to be_empty
end
end
end
context 'when used with pagination' do
let(:request) { spy('request') }
let(:response) { spy('response') }
let(:pagination) { {} }
before do
allow(request)
.to receive(:query_parameters)
.and_return(pagination)
end
let(:serializer) do
described_class.new(user: user)
.with_pagination(request, response)
end
it 'created a paginated serializer' do
expect(serializer).to be_paginated
end
context 'when resource does is not paginatable' do
context 'when a single pipeline object is being serialized' do
let(:resource) { create(:ci_empty_pipeline) }
let(:pagination) { { page: 1, per_page: 1 } }
it 'raises error' do
expect { subject }
.to raise_error(PipelineSerializer::InvalidResourceError)
end
end
end
context 'when resource is paginatable relation' do
let(:resource) { Ci::Pipeline.all }
let(:pagination) { { page: 1, per_page: 2 } }
context 'when a single pipeline object is present in relation' do
before { create(:ci_empty_pipeline) }
it 'serializes pipeline relation' do
expect(subject.first).to have_key :id
end
end
context 'when a multiple pipeline objects are being serialized' do
before { create_list(:ci_empty_pipeline, 3) }
it 'serializes appropriate number of objects' do
expect(subject.count).to be 2
end
it 'appends relevant headers' do
expect(response).to receive(:[]=).with('X-Total', '3')
expect(response).to receive(:[]=).with('X-Total-Pages', '2')
expect(response).to receive(:[]=).with('X-Per-Page', '2')
subject
end
end
end
end
end
end

View file

@ -0,0 +1,22 @@
require 'spec_helper'
describe RequestAwareEntity do
subject do
Class.new.include(described_class).new
end
it 'includes URL helpers' do
expect(subject).to respond_to(:namespace_project_path)
end
it 'includes method for checking abilities' do
expect(subject).to respond_to(:can?)
end
it 'fetches request from options' do
expect(subject).to receive(:options)
.and_return({ request: 'some value' })
expect(subject.request).to eq 'some value'
end
end

View file

@ -0,0 +1,51 @@
require 'spec_helper'
describe StageEntity do
let(:pipeline) { create(:ci_pipeline) }
let(:request) { double('request') }
let(:user) { create(:user) }
let(:entity) do
described_class.new(stage, request: request)
end
let(:stage) do
build(:ci_stage, pipeline: pipeline, name: 'test')
end
before do
allow(request).to receive(:user).and_return(user)
create(:ci_build, :success, pipeline: pipeline)
end
describe '#as_json' do
subject { entity.as_json }
it 'contains relevant fields' do
expect(subject).to include :name, :status, :path
end
it 'contains detailed status' do
expect(subject[:status]).to include :text, :label, :group, :icon
expect(subject[:status][:label]).to eq 'passed'
end
it 'contains valid name' do
expect(subject[:name]).to eq 'test'
end
it 'contains path to the stage' do
expect(subject[:path])
.to include "pipelines/#{pipeline.id}##{stage.name}"
end
it 'contains path to the stage dropdown' do
expect(subject[:dropdown_path])
.to include "pipelines/#{pipeline.id}/stage.json?stage=test"
end
it 'contains stage title' do
expect(subject[:title]).to eq 'test: passed'
end
end
end

View file

@ -0,0 +1,23 @@
require 'spec_helper'
describe StatusEntity do
let(:entity) { described_class.new(status) }
let(:status) do
Gitlab::Ci::Status::Success.new(double('object'), double('user'))
end
before do
allow(status).to receive(:has_details?).and_return(true)
allow(status).to receive(:details_path).and_return('some/path')
end
describe '#as_json' do
subject { entity.as_json }
it 'contains status details' do
expect(subject).to include :text, :icon, :label, :group
expect(subject).to include :has_details, :details_path
end
end
end