diff options
80 files changed, 2305 insertions, 293 deletions
@@ -101,7 +101,7 @@ gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing gem 'html-pipeline', '~> 1.11.0' gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' -gem 'gitlab-markup', '~> 1.5.0' +gem 'gitlab-markup', '~> 1.5.1' gem 'redcarpet', '~> 3.3.3' gem 'RedCloth', '~> 4.3.2' gem 'rdoc', '~> 4.2' diff --git a/Gemfile.lock b/Gemfile.lock index d47a82f818b..38c52e47299 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -263,7 +263,7 @@ GEM diff-lcs (~> 1.1) mime-types (>= 1.16, < 3) posix-spawn (~> 0.3) - gitlab-markup (1.5.0) + gitlab-markup (1.5.1) gitlab_omniauth-ldap (1.2.1) net-ldap (~> 0.9) omniauth (~> 1.0) @@ -892,7 +892,7 @@ DEPENDENCIES gemojione (~> 3.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) - gitlab-markup (~> 1.5.0) + gitlab-markup (~> 1.5.1) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.2) gollum-rugged_adapter (~> 0.4.2) diff --git a/app/assets/javascripts/ci_lint_editor.js.es6 b/app/assets/javascripts/ci_lint_editor.js.es6 new file mode 100644 index 00000000000..56ffaa765a8 --- /dev/null +++ b/app/assets/javascripts/ci_lint_editor.js.es6 @@ -0,0 +1,18 @@ +(() => { + window.gl = window.gl || {}; + + class CILintEditor { + constructor() { + this.editor = window.ace.edit('ci-editor'); + this.textarea = document.querySelector('#content'); + + this.editor.getSession().setMode('ace/mode/yaml'); + this.editor.on('input', () => { + const content = this.editor.getSession().getValue(); + this.textarea.value = content; + }); + } + } + + gl.CILintEditor = CILintEditor; +})(); diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index 496fa9903cc..54f13e328bd 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -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; @@ -273,6 +268,10 @@ case 'projects:variables:index': new gl.ProjectVariables(); break; + case 'ci:lints:create': + case 'ci:lints:show': + new gl.CILintEditor(); + break; } switch (path.first()) { case 'admin': diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js.es6 index 31a71379af3..b8d637a9827 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -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); diff --git a/app/assets/javascripts/vue_pagination/index.js.es6 b/app/assets/javascripts/vue_pagination/index.js.es6 new file mode 100644 index 00000000000..605824fa939 --- /dev/null +++ b/app/assets/javascripts/vue_pagination/index.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/index.js.es6 b/app/assets/javascripts/vue_pipelines_index/index.js.es6 new file mode 100644 index 00000000000..9dfbedd73ab --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/index.js.es6 @@ -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> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 new file mode 100644 index 00000000000..ad5cb30cc42 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 new file mode 100644 index 00000000000..ae5649f0519 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 new file mode 100644 index 00000000000..73627e9ba50 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 new file mode 100644 index 00000000000..74a79dcedae --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 new file mode 100644 index 00000000000..cb176b3f0c6 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/status.js.es6 b/app/assets/javascripts/vue_pipelines_index/status.js.es6 new file mode 100644 index 00000000000..05175082704 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/status.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/store.js.es6 b/app/assets/javascripts/vue_pipelines_index/store.js.es6 new file mode 100644 index 00000000000..6b34839b030 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/store.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 new file mode 100644 index 00000000000..655110feba1 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 @@ -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 = {})); diff --git a/app/assets/javascripts/vue_realtime_listener/index.js.es6 b/app/assets/javascripts/vue_realtime_listener/index.js.es6 new file mode 100644 index 00000000000..23cac1466d2 --- /dev/null +++ b/app/assets/javascripts/vue_realtime_listener/index.js.es6 @@ -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 = {})); diff --git a/app/assets/stylesheets/pages/lint.scss b/app/assets/stylesheets/pages/lint.scss index a7c80dce424..68b6c5ecbd4 100644 --- a/app/assets/stylesheets/pages/lint.scss +++ b/app/assets/stylesheets/pages/lint.scss @@ -9,3 +9,13 @@ color: $lint-correct-color; } } + +.ci-linter { + .ci-editor { + height: 400px; + } + + .ci-template pre { + white-space: pre-wrap; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index ed53ad94021..8861315d776 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -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; diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cc347922c6a..84451257b98 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -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 diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index abbbddaa4f6..2a97e8bae4a 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -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 diff --git a/app/models/environment.rb b/app/models/environment.rb index 5cde94b3509..652abf18a8a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -87,7 +87,7 @@ class Environment < ActiveRecord::Base end def update_merge_request_metrics? - self.name == "production" + (environment_type || name) == "production" end def first_deployment_for(commit) diff --git a/app/models/label.rb b/app/models/label.rb index 5c01c15e5af..5b6b9a7a736 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -26,6 +26,7 @@ class Label < ActiveRecord::Base # Don't allow ',' for label titles validates :title, presence: true, format: { with: /\A[^,]+\z/ } validates :title, uniqueness: { scope: [:group_id, :project_id] } + validates :title, length: { maximum: 255 } default_scope { order(title: :asc) } diff --git a/app/models/project.rb b/app/models/project.rb index ec40def6fb1..94a6f3ba799 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -130,7 +130,7 @@ class Project < ActiveRecord::Base has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy - has_many :project_authorizations, dependent: :destroy + has_many :project_authorizations has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source alias_method :members, :project_members diff --git a/app/models/user.rb b/app/models/user.rb index 66a768d54bb..06dd98a3188 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -73,7 +73,7 @@ class User < ActiveRecord::Base has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project - has_many :project_authorizations, dependent: :destroy + has_many :project_authorizations has_many :authorized_projects, through: :project_authorizations, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id @@ -444,7 +444,7 @@ class User < ActiveRecord::Base end def remove_project_authorizations(project_ids) - project_authorizations.where(id: project_ids).delete_all + project_authorizations.where(project_id: project_ids).delete_all end def set_authorized_projects_column diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb new file mode 100644 index 00000000000..3e72892d584 --- /dev/null +++ b/app/serializers/build_action_entity.rb @@ -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 diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb new file mode 100644 index 00000000000..8b643d8e783 --- /dev/null +++ b/app/serializers/build_artifact_entity.rb @@ -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 diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index acc20f6dc52..49f4db36295 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -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, diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb new file mode 100644 index 00000000000..d04a4990cb0 --- /dev/null +++ b/app/serializers/pipeline_entity.rb @@ -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 diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb new file mode 100644 index 00000000000..cfa86cc2553 --- /dev/null +++ b/app/serializers/pipeline_serializer.rb @@ -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 diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index e159d750cb7..3039014aaaa 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -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 diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb new file mode 100644 index 00000000000..7a047bdc712 --- /dev/null +++ b/app/serializers/stage_entity.rb @@ -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 diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb new file mode 100644 index 00000000000..47066bebfb1 --- /dev/null +++ b/app/serializers/status_entity.rb @@ -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 diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 8559908e0c3..21ec1bd9e65 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -35,7 +35,7 @@ module Users # rows not in the new list or with a different access level should be # removed. if !fresh[project_id] || fresh[project_id] != row.access_level - array << row.id + array << row.project_id end end @@ -100,7 +100,7 @@ module Users end def current_authorizations - user.project_authorizations.select(:id, :project_id, :access_level) + user.project_authorizations.select(:project_id, :access_level) end def fresh_authorizations diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml index 889086c62b1..95eb9a57152 100644 --- a/app/views/ci/lints/show.html.haml +++ b/app/views/ci/lints/show.html.haml @@ -1,20 +1,25 @@ - page_title "CI Lint" - page_description "Validate your GitLab CI configuration file" +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') %h2 Check your .gitlab-ci.yml -%hr -.row - = form_tag ci_lint_path, method: :post do - .form-group - = label_tag(:content, 'Content of .gitlab-ci.yml', class: 'control-label text-nowrap') +.ci-linter + .row + = form_tag ci_lint_path, method: :post do + .form-group + .col-sm-12 + .file-holder + .file-title.clearfix + Content of .gitlab-ci.yml + #ci-editor.ci-editor #{@content} + = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) .col-sm-12 - = text_area_tag(:content, @content, class: 'form-control span1', rows: 7, require: true) - .col-sm-12 - .pull-left.prepend-top-10 - = submit_tag('Validate', class: 'btn btn-success submit-yml') + .pull-left.prepend-top-10 + = submit_tag('Validate', class: 'btn btn-success submit-yml') -.row.prepend-top-20 - .col-sm-12 - .results - = render partial: 'create' if defined?(@status) + .row.prepend-top-20 + .col-sm-12 + .results.ci-template + = render partial: 'create' if defined?(@status) diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index d19eaa6add9..38d63fd9acc 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -21,5 +21,5 @@ = render 'shared/group_tips' .form-actions - = f.submit 'Create group', class: "btn btn-create", tabindex: 3 + = f.submit 'Create group', class: "btn btn-create" = link_to 'Cancel', dashboard_groups_path, class: 'btn btn-cancel' diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index ecd812312c0..5f8f56150f9 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -5,7 +5,8 @@ %div{ class: container_class } .top-area.adjust .nav-text - Protected branches can be managed in project settings + Protected branches can be managed in + = link_to 'project settings', namespace_project_protected_branches_path(@project.namespace, @project) .nav-controls = form_tag(filter_branches_path, method: :get) do diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index aaf1b428178..6ce586cc8f6 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -78,9 +78,9 @@ .btn-group.inline - if actions.any? .btn-group - %a.dropdown-toggle.btn.btn-default.js-pipeline-dropdown-manual-actions{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-toggle.btn.btn-default.js-pipeline-dropdown-manual-actions{ type: 'button', 'data-toggle' => 'dropdown' } = custom_icon('icon_play') - = icon('caret-down') + = icon('caret-down', 'aria-hidden' => 'true') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |build| %li @@ -89,7 +89,7 @@ %span= build.name.humanize - if artifacts.present? .btn-group - %a.dropdown-toggle.btn.btn-default.build-artifacts.js-pipeline-dropdown-download{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-toggle.btn.btn-default.build-artifacts.js-pipeline-dropdown-download{ type: 'button', 'data-toggle' => 'dropdown' } = icon("download") = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 4bb3d4d35fb..abea6932567 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -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 - - = paginate @pipelines, theme: 'gitlab' + .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"), + } } + + .vue-pipelines-index + += page_specific_javascript_tag('vue_pagination/index.js') += page_specific_javascript_tag('vue_pipelines_index/index.js') diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml index 000532b1c9a..ee043910548 100644 --- a/app/views/shared/_choose_group_avatar_button.html.haml +++ b/app/views/shared/_choose_group_avatar_button.html.haml @@ -1,4 +1,4 @@ -%a.choose-btn.btn.btn-sm.js-choose-group-avatar-button +%button.choose-btn.btn.btn-sm.js-choose-group-avatar-button %i.fa.fa-paperclip %span Choose File ... diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml index 15ff5b8a27e..c8fd45c4319 100644 --- a/app/views/shared/milestones/_issuables.html.haml +++ b/app/views/shared/milestones/_issuables.html.haml @@ -9,6 +9,7 @@ - if show_counter .right = issuables.size + .pull-right= number_with_delimiter(issuables.size) - class_prefix = dom_class(issuables).pluralize %ul{ class: "well-list #{class_prefix}-sortable-list", id: "#{class_prefix}-list-#{id}", "data-state" => id } diff --git a/changelogs/unreleased/19086-double-newline.yml b/changelogs/unreleased/19086-double-newline.yml new file mode 100644 index 00000000000..dd9b58920fb --- /dev/null +++ b/changelogs/unreleased/19086-double-newline.yml @@ -0,0 +1,4 @@ +--- +title: Fix double spaced CI log +merge_request: 8349 +author: Jared Deckard <jared.deckard@gmail.com> diff --git a/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml b/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml new file mode 100644 index 00000000000..83cf3670ec0 --- /dev/null +++ b/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml @@ -0,0 +1,4 @@ +--- +title: Treat environments matching `production/*` as Production +merge_request: 8500 +author: diff --git a/changelogs/unreleased/25277-milestone-counter-number-with-delimiter.yml b/changelogs/unreleased/25277-milestone-counter-number-with-delimiter.yml new file mode 100644 index 00000000000..0c9853de3b6 --- /dev/null +++ b/changelogs/unreleased/25277-milestone-counter-number-with-delimiter.yml @@ -0,0 +1,4 @@ +--- +title: Added number_with_delimiter to counter on milestone panels +merge_request: +author: Ryan Harris diff --git a/changelogs/unreleased/26129-add-link-to-branches-page.yml b/changelogs/unreleased/26129-add-link-to-branches-page.yml new file mode 100644 index 00000000000..aceb92dbb9c --- /dev/null +++ b/changelogs/unreleased/26129-add-link-to-branches-page.yml @@ -0,0 +1,4 @@ +--- +title: Convert project setting text into protected branch path link +merge_request: 8377 +author: Ken Ding diff --git a/changelogs/unreleased/26238-buttons-not-accessible.yml b/changelogs/unreleased/26238-buttons-not-accessible.yml new file mode 100644 index 00000000000..34d38d45709 --- /dev/null +++ b/changelogs/unreleased/26238-buttons-not-accessible.yml @@ -0,0 +1,4 @@ +--- +title: Fixes buttons not being accessible via the keyboard when creating new group +merge_request: 8469 +author: diff --git a/changelogs/unreleased/26445-make-icon-buttons-accessible-via-keyboard.yml b/changelogs/unreleased/26445-make-icon-buttons-accessible-via-keyboard.yml new file mode 100644 index 00000000000..b4aef8fe3da --- /dev/null +++ b/changelogs/unreleased/26445-make-icon-buttons-accessible-via-keyboard.yml @@ -0,0 +1,4 @@ +--- +title: Make play button on Pipelines page accessible via keyboard +merge_request: +author: Ryan Harris diff --git a/changelogs/unreleased/26446-access-download-artifacts-via-keyboard.yml b/changelogs/unreleased/26446-access-download-artifacts-via-keyboard.yml new file mode 100644 index 00000000000..83f6233dd88 --- /dev/null +++ b/changelogs/unreleased/26446-access-download-artifacts-via-keyboard.yml @@ -0,0 +1,5 @@ +--- +title: Made download artifacts button accessible via keyboard by changing it from + an anchor tag to an actual button +merge_request: +author: Ryan Harris diff --git a/changelogs/unreleased/didemacet-ci-lint-page.yml b/changelogs/unreleased/didemacet-ci-lint-page.yml new file mode 100644 index 00000000000..07386321c9d --- /dev/null +++ b/changelogs/unreleased/didemacet-ci-lint-page.yml @@ -0,0 +1,4 @@ +--- +title: Change CI template linter textarea with Ace Editor +merge_request: 8452 +author: Didem Acet diff --git a/changelogs/unreleased/remove-project-authorizations-id-column.yml b/changelogs/unreleased/remove-project-authorizations-id-column.yml new file mode 100644 index 00000000000..24c86f0fb1b --- /dev/null +++ b/changelogs/unreleased/remove-project-authorizations-id-column.yml @@ -0,0 +1,4 @@ +--- +title: Remove the project_authorizations.id column +merge_request: +author: diff --git a/changelogs/unreleased/update-gitlab-markup-gem.yml b/changelogs/unreleased/update-gitlab-markup-gem.yml new file mode 100644 index 00000000000..96cdfd051f0 --- /dev/null +++ b/changelogs/unreleased/update-gitlab-markup-gem.yml @@ -0,0 +1,4 @@ +--- +title: Update the gitlab-markup gem to the version 1.5.1 +merge_request: 8509 +author: diff --git a/changelogs/unreleased/validate-title-length.yml b/changelogs/unreleased/validate-title-length.yml new file mode 100644 index 00000000000..7abf1c4d05a --- /dev/null +++ b/changelogs/unreleased/validate-title-length.yml @@ -0,0 +1,4 @@ +--- +title: "Validate label's title length" +merge_request: 5767 +author: Tomáš Kukrál diff --git a/config/application.rb b/config/application.rb index d36c6d5c92e..1de7fb7bdb8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/db/post_migrate/20170106172224_remove_project_authorizations_id_column.rb b/db/post_migrate/20170106172224_remove_project_authorizations_id_column.rb new file mode 100644 index 00000000000..7c788160022 --- /dev/null +++ b/db/post_migrate/20170106172224_remove_project_authorizations_id_column.rb @@ -0,0 +1,12 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveProjectAuthorizationsIdColumn < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + remove_column :project_authorizations, :id, :primary_key + end +end diff --git a/db/schema.rb b/db/schema.rb index 9bce3b82d82..f3bf7ced393 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161227192806) do +ActiveRecord::Schema.define(version: 20170106172224) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -862,7 +862,7 @@ ActiveRecord::Schema.define(version: 20161227192806) do add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree add_index "personal_access_tokens", ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree - create_table "project_authorizations", force: :cascade do |t| + create_table "project_authorizations", id: false, force: :cascade do |t| t.integer "user_id" t.integer "project_id" t.integer "access_level" @@ -1307,4 +1307,4 @@ ActiveRecord::Schema.define(version: 20161227192806) do add_foreign_key "subscriptions", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" -end
\ No newline at end of file +end diff --git a/doc/user/project/cycle_analytics.md b/doc/user/project/cycle_analytics.md index 86fe52ef4ff..62afd8cf247 100644 --- a/doc/user/project/cycle_analytics.md +++ b/doc/user/project/cycle_analytics.md @@ -50,7 +50,7 @@ exception of the staging and production stages, where only data deployed to production are measured. Specifically, if your CI is not set up and you have not defined a `production` -[environment], then you will not have any data for those stages. +or `production/*` [environment], then you will not have any data for those stages. Below you can see in more detail what the various stages of Cycle Analytics mean. @@ -61,7 +61,7 @@ Below you can see in more detail what the various stages of Cycle Analytics mean | Code | Measures the median time between pushing a first commit (previous stage) and creating a merge request (MR) related to that commit. The key to keep the process tracked is to include the [issue closing pattern] to the description of the merge request (for example, `Closes #xxx`, where `xxx` is the number of the issue related to this merge request). If the issue closing pattern is not present in the merge request description, the MR is not considered to the measurement time of the stage. | | Test | Measures the median time to run the entire pipeline for that project. It's related to the time GitLab CI takes to run every job for the commits pushed to that merge request defined in the previous stage. It is basically the start->finish time for all pipelines. `master` is not excluded. It does not attempt to track time for any particular stages. | | Review | Measures the median time taken to review the merge request, between its creation and until it's merged. | -| Staging | Measures the median time between merging the merge request until the very first deployment to production. It's tracked by the [environment] set to `production` (case-sensitive, `Production` won't work) in your GitLab CI configuration. If there isn't a `production` environment, this is not tracked. | +| Staging | Measures the median time between merging the merge request until the very first deployment to production. It's tracked by the [environment] set to `production` or matching `production/*` (case-sensitive, `Production` won't work) in your GitLab CI configuration. If there isn't a production environment, this is not tracked. | | Production| The sum of all time (medians) taken to run the entire process, from issue creation to deploying the code to production. | --- @@ -79,10 +79,13 @@ Here's a little explanation of how this works behind the scenes: etc. To sum up, anything that doesn't follow the [GitLab flow] won't be tracked at all. -So, if a merge request doesn't close an issue or an issue is not labeled with a -label present in the Issue Board or assigned a milestone or a project has no -`production` environment (for staging and production stages), the Cycle Analytics -dashboard won't present any data at all. +So, the Cycle Analytics dashboard won't present any data: +- For merge requests that do not close an issue. +- For issues not labeled with a label present in the Issue Board. +- For issues not assigned a milestone. +- For staging and production stages, if the project has no `production` or `production/*` + environment. + ## Example workflow diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index ee9247ee240..20b5bc1502a 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -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 diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb new file mode 100644 index 00000000000..2199eea7e5f --- /dev/null +++ b/lib/api/helpers/pagination.rb @@ -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 diff --git a/lib/ci/ansi2html.rb b/lib/ci/ansi2html.rb index 229050151d3..c10d3616f31 100644 --- a/lib/ci/ansi2html.rb +++ b/lib/ci/ansi2html.rb @@ -105,7 +105,7 @@ module Ci break elsif s.scan(/</) @out << '<' - elsif s.scan(/\n/) + elsif s.scan(/\r?\n/) @out << '<br>' else @out << s.scan(/./m) diff --git a/lib/email_template_interceptor.rb b/lib/email_template_interceptor.rb index fb04a7824b8..63f9f8d7a5a 100644 --- a/lib/email_template_interceptor.rb +++ b/lib/email_template_interceptor.rb @@ -5,8 +5,8 @@ class EmailTemplateInterceptor def self.delivering_email(message) # Remove HTML part if HTML emails are disabled. unless current_application_settings.html_emails_enabled - message.part.delete_if do |part| - part.content_type.try(:start_with?, 'text/html') + message.parts.delete_if do |part| + part.content_type.start_with?('text/html') end end end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 5fe7e6407cc..1ed2ee3ab4a 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -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') diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 1735791f644..77404f46c92 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -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 diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index e3b73e29987..ed4acca23f1 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -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 diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb index 81077f4b005..3ebc432206a 100644 --- a/spec/features/ci_lint_spec.rb +++ b/spec/features/ci_lint_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'CI Lint' do +describe 'CI Lint', js: true do before do login_as :user end @@ -8,7 +8,10 @@ describe 'CI Lint' do describe 'YAML parsing' do before do visit ci_lint_path - fill_in 'content', with: yaml_content + # Ace editor updates a hidden textarea and it happens asynchronously + # `sleep 0.1` is actually needed here because of this + execute_script("ace.edit('ci-editor').setValue(" + yaml_content.to_json + ");") + sleep 0.1 click_on 'Validate' end @@ -40,7 +43,7 @@ describe 'CI Lint' do let(:yaml_content) { 'my yaml content' } it 'loads previous YAML content after validation' do - expect(page).to have_field('content', with: 'my yaml content', type: 'textarea') + expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea') end end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index cef50f6f237..3ba996e2e10 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -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 - - 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 - end - - context 'anonymous access' do - before { visit namespace_project_pipelines_path(project.namespace, project) } + context 'when user is logged in' do + let(:user) { create(:user) } - it { expect(page).to have_http_status(:success) } + before do + login_as(user) + project.team << [user, :developer] 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) + describe 'GET /:project/pipelines' do + let(:project) { create(: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') } + it 'contains pipeline commit short SHA' do + expect(page).to have_content(pipeline.short_sha) + end + end end - end - context 'retryable pipelines' do - let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } + context 'when pipeline is cancelable' do + let!(:build) do + create(:ci_build, pipeline: pipeline, + stage: 'test', + commands: 'test') + end - before do - build.drop - visit namespace_project_pipelines_path(project.namespace, project) - end + before do + build.run + visit_project_pipelines + end - it { expect(page).to have_link('Retry') } - it { expect(page).to have_selector('.ci-failed') } + it 'indicates that pipeline can be canceled' do + expect(page).to have_link('Cancel') + expect(page).to have_selector('.ci-running') + end - context 'when retrying' do - before { click_link('Retry') } + context 'when canceling' do + before { click_link('Cancel') } - it { expect(page).not_to have_link('Retry') } - it { expect(page).to have_selector('.ci-running') } + it 'indicated that pipelines was canceled' do + expect(page).not_to have_link('Cancel') + expect(page).to have_selector('.ci-canceled') + end + end end - end - context 'with manual actions' do - let!(:manual) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'manual build', - stage: 'test', - commands: 'test') - end + context 'when pipeline is retryable' do + let!(:build) do + create(:ci_build, pipeline: pipeline, + stage: 'test', + commands: 'test') + end - before do - visit namespace_project_pipelines_path(project.namespace, project) - end + before do + build.drop + visit_project_pipelines + end - it 'has link to the manual action' do - find('.js-pipeline-dropdown-manual-actions').click + it 'indicates that pipeline can be retried' do + expect(page).to have_link('Retry') + expect(page).to have_selector('.ci-failed') + end - expect(page).to have_link('Manual build') - end + context 'when retrying' do + before { click_link('Retry') } - context 'when manual action was played' do - before do - find('.js-pipeline-dropdown-manual-actions').click - click_link('Manual build') + 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 - it 'enqueues manual action job' do - expect(manual.reload).to be_pending + context 'when pipeline has configuration errors' do + let(:pipeline) do + create(:ci_pipeline, :invalid, project: project) end - end - end - context 'for generic statuses' do - context 'when running' do - let!(:running) { create(:generic_commit_status, status: 'running', pipeline: pipeline, stage: 'test') } + before { visit_project_pipelines } - before do - visit namespace_project_pipelines_path(project.namespace, project) + it 'contains badge that indicates errors' do + expect(page).to have_content 'yaml invalid' end - it 'is cancelable' do - expect(page).to have_link('Cancel') + 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 - it 'has pipeline running' do - expect(page).to have_selector('.ci-running') + context 'with manual actions' do + let!(:manual) do + create(:ci_build, :manual, + pipeline: pipeline, + name: 'manual build', + stage: 'test', + commands: 'test') end - context 'when canceling' do - before { click_link('Cancel') } + before { visit_project_pipelines } - it { expect(page).not_to have_link('Cancel') } - it { expect(page).to have_selector('.ci-canceled') } + it 'has a dropdown with play button' do + expect(page).to have_selector('.dropdown-toggle.btn.btn-default .icon-play') end - end - context 'when failed' do - let!(:status) { create(:generic_commit_status, :pending, pipeline: pipeline, stage: 'test') } + it 'has link to the manual action' do + find('.js-pipeline-dropdown-manual-actions').click - before do - status.drop - visit namespace_project_pipelines_path(project.namespace, project) + expect(page).to have_link('Manual build') end - it 'is not retryable' do - expect(page).not_to have_link('Retry') - end + context 'when manual action was played' do + before do + find('.js-pipeline-dropdown-manual-actions').click + click_link('Manual build') + end - it 'has failed pipeline' do - expect(page).to have_selector('.ci-failed') + it 'enqueues manual action job' do + expect(manual.reload).to be_pending + end 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') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + 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 - it { expect(page).to have_selector('.build-artifacts') } - it do - find('.js-pipeline-dropdown-download').click - expect(page).to have_link(with_artifacts.name) + 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 'with artifacts expired' do - let!(:with_artifacts_expired) { create(:ci_build, :artifacts_expired, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + 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 namespace_project_pipelines_path(project.namespace, project) } + before { visit_project_pipelines } - it { expect(page).not_to have_selector('.build-artifacts') } - end + it 'has artifats' do + expect(page).to have_selector('.build-artifacts') + end - context 'without artifacts' do - let!(:without_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + it 'has artifacts download dropdown' do + find('.js-pipeline-dropdown-download').click - before { visit namespace_project_pipelines_path(project.namespace, project) } + expect(page).to have_link(with_artifacts.name) + end + end - it { expect(page).not_to have_selector('.build-artifacts') } - 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 - context 'mini pipleine graph' do - let!(:build) do - create(:ci_build, pipeline: pipeline, stage: 'build', name: 'build') - end + before { visit_project_pipelines } - before do - visit namespace_project_pipelines_path(project.namespace, project) - end + 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 - it 'should render a mini pipeline graph' do - endpoint = stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: build.name) + before { visit_project_pipelines } - expect(page).to have_selector('.js-mini-pipeline-graph') - expect(page).to have_selector(".js-builds-dropdown-button[data-stage-endpoint='#{endpoint}']") + it { expect(page).not_to have_selector('.build-artifacts') } + end end - context 'when clicking a graph stage' do - it 'should open a dropdown' do - find('.js-builds-dropdown-button').trigger('click') + context 'mini pipeline graph' do + let!(:build) do + create(:ci_build, :pending, pipeline: pipeline, + stage: 'build', + name: 'build') + end - wait_for_ajax + before { visit_project_pipelines } - expect(page).to have_link build.name + 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 - it 'should be possible to retry the failed build' do - find('.js-builds-dropdown-button').trigger('click') + 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 - wait_for_ajax + it 'should be possible to cancel pending build' do + find('.js-builds-dropdown-button').trigger('click') + find('a.js-ci-action-icon').trigger('click') - find('a.js-ci-action-icon').trigger('click') - expect(page).not_to have_content('Cancel running') + expect(page).to have_content('canceled') + expect(build.reload).to be_canceled + end end end end - end - describe 'POST /:project/pipelines' do - let(:project) { create(:project) } + describe 'POST /:project/pipelines' do + let(:project) { create(:project) } - before { visit new_namespace_project_pipeline_path(project.namespace, project) } + before do + visit new_namespace_project_pipeline_path(project.namespace, project) + end + + 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 } - context 'for valid commit' do - before { fill_in('pipeline[ref]', with: 'master') } + it 'creates a new pipeline' do + expect { click_on 'Create pipeline' } + .to change { Ci::Pipeline.count }.by(1) + end + end - context 'with gitlab-ci.yml' do - before { stub_ci_pipeline_to_return_yaml_file } + context 'without gitlab-ci.yml' do + before { click_on 'Create pipeline' } - it { expect{ click_on 'Create pipeline' }.to change{ Ci::Pipeline.count }.by(1) } + it { expect(page).to have_content('Missing .gitlab-ci.yml file') } + end end - context 'without gitlab-ci.yml' do - before { click_on 'Create pipeline' } + 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('Missing .gitlab-ci.yml file') } + it { expect(page).to have_content('Reference not found') } end end - context 'for invalid commit' do + describe 'Create pipelines' do + let(:project) { create(:project) } + before do - fill_in('pipeline[ref]', with: 'invalid-reference') - click_on 'Create pipeline' + 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 - it { expect(page).to have_content('Reference not found') } + 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 '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 diff --git a/spec/javascripts/vue_pagination/pagination_spec.js.es6 b/spec/javascripts/vue_pagination/pagination_spec.js.es6 new file mode 100644 index 00000000000..1a7f2bb5fb8 --- /dev/null +++ b/spec/javascripts/vue_pagination/pagination_spec.js.es6 @@ -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'); + }); +}); diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb new file mode 100644 index 00000000000..267318faed4 --- /dev/null +++ b/spec/lib/api/helpers/pagination_spec.rb @@ -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 diff --git a/spec/lib/ci/ansi2html_spec.rb b/spec/lib/ci/ansi2html_spec.rb index 898f1e84ab0..0762fd7e56a 100644 --- a/spec/lib/ci/ansi2html_spec.rb +++ b/spec/lib/ci/ansi2html_spec.rb @@ -136,6 +136,14 @@ describe Ci::Ansi2html, lib: true do expect(subject.convert("<")[:html]).to eq('<') end + it "replaces newlines with line break tags" do + expect(subject.convert("\n")[:html]).to eq('<br>') + end + + it "groups carriage returns with newlines" do + expect(subject.convert("\r\n")[:html]).to eq('<br>') + end + describe "incremental update" do shared_examples 'stateable converter' do let(:pass1) { subject.convert(pre_text) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index cebaa157ef3..d1aee27057a 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -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) } diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 93eb402e060..96efe1696c3 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -63,6 +63,23 @@ describe Environment, models: true do end end + describe '#update_merge_request_metrics?' do + { 'production' => true, + 'production/eu' => true, + 'production/www.gitlab.com' => true, + 'productioneu' => false, + 'Production' => false, + 'Production/eu' => false, + 'test-production' => false + }.each do |name, expected_value| + it "returns #{expected_value} for #{name}" do + env = create(:environment, name: name) + + expect(env.update_merge_request_metrics?).to eq(expected_value) + end + end + end + describe '#first_deployment_for' do let(:project) { create(:project) } let!(:deployment) { create(:deployment, environment: environment, ref: commit.parent.id) } diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 0c163659a71..a9139f7d4ab 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -31,12 +31,14 @@ describe Label, models: true do it 'validates title' do is_expected.not_to allow_value('G,ITLAB').for(:title) is_expected.not_to allow_value('').for(:title) + is_expected.not_to allow_value('s' * 256).for(:title) is_expected.to allow_value('GITLAB').for(:title) is_expected.to allow_value('gitlab').for(:title) is_expected.to allow_value('G?ITLAB').for(:title) is_expected.to allow_value('G&ITLAB').for(:title) is_expected.to allow_value("customer's request").for(:title) + is_expected.to allow_value('s' * 255).for(:title) end end diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb new file mode 100644 index 00000000000..383704572b1 --- /dev/null +++ b/spec/serializers/build_action_entity_spec.rb @@ -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 diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb new file mode 100644 index 00000000000..2fc60aa9de6 --- /dev/null +++ b/spec/serializers/build_artifact_entity_spec.rb @@ -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 diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb index 15f11ac3df9..a8662e81d20 100644 --- a/spec/serializers/commit_entity_spec.rb +++ b/spec/serializers/commit_entity_spec.rb @@ -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 diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb new file mode 100644 index 00000000000..b19464c7117 --- /dev/null +++ b/spec/serializers/pipeline_entity_spec.rb @@ -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 diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb new file mode 100644 index 00000000000..3a32cb394dd --- /dev/null +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -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 diff --git a/spec/serializers/request_aware_entity_spec.rb b/spec/serializers/request_aware_entity_spec.rb new file mode 100644 index 00000000000..aa666b961dc --- /dev/null +++ b/spec/serializers/request_aware_entity_spec.rb @@ -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 diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb new file mode 100644 index 00000000000..4ab40d08432 --- /dev/null +++ b/spec/serializers/stage_entity_spec.rb @@ -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 diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb new file mode 100644 index 00000000000..89428b4216e --- /dev/null +++ b/spec/serializers/status_entity_spec.rb @@ -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 diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index 1f6919151de..9fbb61565e3 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -20,7 +20,7 @@ describe Users::RefreshAuthorizedProjectsService do to_remove = create_authorization(project2, user) expect(service).to receive(:update_with_lease). - with([to_remove.id], [[user.id, project.id, Gitlab::Access::MASTER]]) + with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]]) service.execute end @@ -29,7 +29,7 @@ describe Users::RefreshAuthorizedProjectsService do to_remove = create_authorization(project, user, Gitlab::Access::DEVELOPER) expect(service).to receive(:update_with_lease). - with([to_remove.id], [[user.id, project.id, Gitlab::Access::MASTER]]) + with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]]) service.execute end @@ -90,7 +90,7 @@ describe Users::RefreshAuthorizedProjectsService do it 'removes authorizations that should be removed' do authorization = create_authorization(project, user) - service.update_authorizations([authorization.id]) + service.update_authorizations([authorization.project_id]) expect(user.project_authorizations).to be_empty end @@ -147,7 +147,12 @@ describe Users::RefreshAuthorizedProjectsService do end it 'sets the values to the project authorization rows' do - expect(hash.values).to eq([ProjectAuthorization.first]) + expect(hash.values.length).to eq(1) + + value = hash.values[0] + + expect(value.project_id).to eq(project.id) + expect(value.access_level).to eq(Gitlab::Access::MASTER) end end @@ -167,10 +172,6 @@ describe Users::RefreshAuthorizedProjectsService do expect(service.current_authorizations.length).to eq(1) end - it 'includes the row ID for every row' do - expect(row.id).to be_a_kind_of(Numeric) - end - it 'includes the project ID for every row' do expect(row.project_id).to eq(project.id) end diff --git a/spec/views/shared/milestones/_issuables.html.haml.rb b/spec/views/shared/milestones/_issuables.html.haml.rb new file mode 100644 index 00000000000..4769d569548 --- /dev/null +++ b/spec/views/shared/milestones/_issuables.html.haml.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe 'shared/milestones/_issuables.html.haml' do + let(:issuables_size) { 100 } + + before do + allow(view).to receive_messages(title: nil, id: nil, show_project_name: nil, + show_full_project_name: nil, dom_class: '', + issuables: double(size: issuables_size).as_null_object) + + stub_template 'shared/milestones/_issuable.html.haml' => '' + end + + it 'should show the issuables count if show_counter is true' do + render 'shared/milestones/issuables', show_counter: true + expect(rendered).to have_content('100') + end + + it 'should not show the issuables count if show_counter is false' do + render 'shared/milestones/issuables', show_counter: false + expect(rendered).not_to have_content('100') + end + + describe 'a high issuables count' do + let(:issuables_size) { 1000 } + + it 'should show a delimited number if show_counter is true' do + render 'shared/milestones/issuables', show_counter: true + expect(rendered).to have_content('1,000') + end + end +end |
