diff options
156 files changed, 3297 insertions, 575 deletions
@@ -84,10 +84,14 @@ gem 'dropzonejs-rails', '~> 0.7.1' # for backups gem 'fog-aws', '~> 0.9' gem 'fog-core', '~> 1.40' +gem 'fog-google', '~> 0.5' gem 'fog-local', '~> 0.3' gem 'fog-openstack', '~> 0.1' gem 'fog-rackspace', '~> 0.1.1' +# for Google storage +gem 'google-api-client', '~> 0.8.6' + # for aws storage gem 'unf', '~> 0.1.4' @@ -97,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 b042e4b1b09..cb71a468aa1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,10 @@ GEM attr_encrypted (3.0.3) encryptor (~> 3.0.0) attr_required (1.0.0) + autoparse (0.3.3) + addressable (>= 2.3.1) + extlib (>= 0.9.15) + multi_json (>= 1.0.0) autoprefixer-rails (6.2.3) execjs json @@ -179,6 +183,7 @@ GEM excon (0.52.0) execjs (2.6.0) expression_parser (0.9.0) + extlib (0.9.16) factory_girl (4.7.0) activesupport (>= 3.0.0) factory_girl_rails (4.7.0) @@ -208,6 +213,10 @@ GEM builder excon (~> 0.49) formatador (~> 0.2) + fog-google (0.5.0) + fog-core + fog-json + fog-xml fog-json (1.0.2) fog-core (~> 1.0) multi_json (~> 1.10) @@ -254,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) @@ -279,6 +288,25 @@ GEM json multi_json request_store (>= 1.0) + google-api-client (0.8.7) + activesupport (>= 3.2, < 5.0) + addressable (~> 2.3) + autoparse (~> 0.3) + extlib (~> 0.9) + faraday (~> 0.9) + googleauth (~> 0.3) + launchy (~> 2.4) + multi_json (~> 1.10) + retriable (~> 1.4) + signet (~> 0.6) + googleauth (0.5.1) + faraday (~> 0.9) + jwt (~> 1.4) + logging (~> 2.0) + memoist (~> 0.12) + multi_json (~> 1.11) + os (~> 0.9) + signet (~> 0.7) grape (0.18.0) activesupport builder @@ -381,11 +409,16 @@ GEM listen (3.0.5) rb-fsevent (>= 0.9.3) rb-inotify (>= 0.9) + little-plugger (1.1.4) + logging (2.1.0) + little-plugger (~> 1.1) + multi_json (~> 1.10) loofah (2.0.3) nokogiri (>= 1.5.9) mail (2.6.4) mime-types (>= 1.16, < 4) mail_room (0.9.0) + memoist (0.15.0) method_source (0.8.2) mime-types (2.99.3) mimemagic (0.3.0) @@ -473,6 +506,7 @@ GEM org-ruby (0.9.12) rubypants (~> 0.2) orm_adapter (0.5.0) + os (0.9.6) paranoia (2.2.0) activerecord (>= 4.0, < 5.1) parser (2.3.1.4) @@ -584,6 +618,7 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) + retriable (1.4.1) rinku (2.0.0) rotp (2.1.2) rouge (2.0.7) @@ -675,6 +710,11 @@ GEM sidekiq (>= 4.2.1) sidekiq-limit_fetch (3.4.0) sidekiq (>= 4) + signet (0.7.3) + addressable (~> 2.3) + faraday (~> 0.9) + jwt (~> 1.5) + multi_json (~> 1.10) simplecov (0.12.0) docile (~> 1.1.0) json (>= 1.8, < 3) @@ -841,6 +881,7 @@ DEPENDENCIES flay (~> 2.6.1) fog-aws (~> 0.9) fog-core (~> 1.40) + fog-google (~> 0.5) fog-local (~> 0.3) fog-openstack (~> 0.1) fog-rackspace (~> 0.1.1) @@ -851,11 +892,12 @@ 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) gon (~> 6.1.0) + google-api-client (~> 0.8.6) grape (~> 0.18.0) grape-entity (~> 0.6.0) haml_lint (~> 0.18.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 1c1b6cd2dad..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; @@ -215,7 +210,9 @@ new gl.Members(); new UsersSelect(); break; - case 'projects:project_members:index': + case 'projects:members:show': + new gl.MemberExpirationDate('.js-access-expiration-date-groups'); + new GroupsSelect(); new gl.MemberExpirationDate(); new gl.Members(); new UsersSelect(); @@ -261,10 +258,6 @@ case 'projects:artifacts:browse': new BuildArtifacts(); break; - case 'projects:group_links:index': - new gl.MemberExpirationDate(); - new GroupsSelect(); - break; case 'search:show': new Search(); break; @@ -275,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/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6 index 6b7fb9215d1..8b6fafb6104 100644 --- a/app/assets/javascripts/environments/components/environment.js.es6 +++ b/app/assets/javascripts/environments/components/environment.js.es6 @@ -216,7 +216,7 @@ <th class="environments-deploy">Last deployment</th> <th class="environments-build">Build</th> <th class="environments-commit">Commit</th> - <th class="environments-date"></th> + <th class="environments-date">Created</th> <th class="hidden-xs environments-actions"></th> </tr> </thead> 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/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js.es6 index 7741cd29793..bf6c0ec2798 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js.es6 @@ -1,30 +1,29 @@ -/* eslint-disable func-names, space-before-function-paren, vars-on-top, no-var, object-shorthand, comma-dangle, max-len */ -(function() { +(() => { // Add datepickers to all `js-access-expiration-date` elements. If those elements are // children of an element with the `clearable-input` class, and have a sibling // `js-clear-input` element, then show that element when there is a value in the // datepicker, and make clicking on that element clear the field. // - gl.MemberExpirationDate = function() { + window.gl = window.gl || {}; + gl.MemberExpirationDate = (selector = '.js-access-expiration-date') => { function toggleClearInput() { $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== ''); } - - var inputs = $('.js-access-expiration-date'); + const inputs = $(selector); inputs.datepicker({ dateFormat: 'yy-mm-dd', minDate: 1, - onSelect: function () { + onSelect: function onSelect() { $(this).trigger('change'); toggleClearInput.call(this); - } + }, }); - inputs.next('.js-clear-input').on('click', function(event) { + inputs.next('.js-clear-input').on('click', function clicked(event) { event.preventDefault(); - var input = $(this).closest('.clearable-input').find('.js-access-expiration-date'); + const input = $(this).closest('.clearable-input').find(selector); input.datepicker('setDate', null) .trigger('change'); toggleClearInput.call(input); 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/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 771dfaec46e..1c6698ad0c6 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -163,6 +163,10 @@ ul.content-list { &:last-child { margin-right: 0; + + @media(max-width: $screen-xs-max) { + margin: 0 auto; + } } } 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/members.scss b/app/assets/stylesheets/pages/members.scss index 36ee5d17211..be7193bae04 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -25,7 +25,7 @@ } .form-horizontal { - margin-top: 5px; + margin-top: 20px; @media (min-width: $screen-sm-min) { display: -webkit-flex; @@ -98,6 +98,10 @@ padding-right: 35px; @media (min-width: $screen-sm-min) { + width: 250px; + } + + @media (min-width: $screen-md-min) { width: 350px; } 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/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 9eaf26a0dbf..66b7bdbd988 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -4,10 +4,7 @@ class Projects::GroupLinksController < Projects::ApplicationController before_action :authorize_admin_project_member!, only: [:update] def index - @group_links = project.project_group_links.all - - @skip_groups = @group_links.pluck(:group_id) - @skip_groups << project.namespace_id unless project.personal? + redirect_to namespace_project_settings_members_path end def create @@ -25,7 +22,7 @@ class Projects::GroupLinksController < Projects::ApplicationController flash[:alert] = 'Please select a group.' end - redirect_to namespace_project_group_links_path(project.namespace, project) + redirect_to namespace_project_settings_members_path(project.namespace, project) end def update @@ -39,7 +36,7 @@ class Projects::GroupLinksController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to namespace_project_group_links_path(project.namespace, project) + redirect_to namespace_project_settings_members_path(project.namespace, project) end format.js { head :ok } end 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/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 3aec6f18e27..6e158e685e9 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -6,54 +6,14 @@ class Projects::ProjectMembersController < Projects::ApplicationController before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index - @sort = params[:sort].presence || sort_value_name - @group_links = @project.project_group_links - - @project_members = @project.project_members - @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) - - group = @project.group - - if group - # We need `.where.not(user_id: nil)` here otherwise when a group has an - # invitee, it would make the following query return 0 rows since a NULL - # user_id would be present in the subquery - # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values - # FIXME: This whole logic should be moved to a finder! - non_null_user_ids = @project_members.where.not(user_id: nil).select(:user_id) - group_members = group.group_members.where.not(user_id: non_null_user_ids) - group_members = group_members.non_invite unless can?(current_user, :admin_group, @group) - end - - if params[:search].present? - user_ids = @project.users.search(params[:search]).select(:id) - @project_members = @project_members.where(user_id: user_ids) - - if group_members - user_ids = group.users.search(params[:search]).select(:id) - group_members = group_members.where(user_id: user_ids) - end - - @group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) - end - - wheres = ["members.id IN (#{@project_members.select(:id).to_sql})"] - wheres << "members.id IN (#{group_members.select(:id).to_sql})" if group_members - - @project_members = Member. - where(wheres.join(' OR ')). - sort(@sort). - page(params[:page]) - - @requesters = AccessRequestsFinder.new(@project).execute(current_user) - - @project_member = @project.project_members.new + sort = params[:sort].presence || sort_value_name + redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort) end def create status = Members::CreateService.new(@project, current_user, params).execute - redirect_url = namespace_project_project_members_path(@project.namespace, @project) + redirect_url = namespace_project_settings_members_path(@project.namespace, @project) if status redirect_to redirect_url, notice: 'Users were successfully added.' @@ -76,14 +36,14 @@ class Projects::ProjectMembersController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to namespace_project_project_members_path(@project.namespace, @project) + redirect_to namespace_project_settings_members_path(@project.namespace, @project) end format.js { head :ok } end end def resend_invite - redirect_path = namespace_project_project_members_path(@project.namespace, @project) + redirect_path = namespace_project_settings_members_path(@project.namespace, @project) @project_member = @project.project_members.find(params[:id]) @@ -106,7 +66,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_404 end - redirect_to(namespace_project_project_members_path(project.namespace, project), + redirect_to(namespace_project_settings_members_path(project.namespace, project), notice: notice) end diff --git a/app/controllers/projects/settings/members_controller.rb b/app/controllers/projects/settings/members_controller.rb new file mode 100644 index 00000000000..5735e281f66 --- /dev/null +++ b/app/controllers/projects/settings/members_controller.rb @@ -0,0 +1,55 @@ +module Projects + module Settings + class MembersController < Projects::ApplicationController + include SortingHelper + + def show + @sort = params[:sort].presence || sort_value_name + @group_links = @project.project_group_links + + @project_members = @project.project_members + @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) + + group = @project.group + + # group links + @group_links = @project.project_group_links.all + + @skip_groups = @group_links.pluck(:group_id) + @skip_groups << @project.namespace_id unless @project.personal? + + if group + # We need `.where.not(user_id: nil)` here otherwise when a group has an + # invitee, it would make the following query return 0 rows since a NULL + # user_id would be present in the subquery + # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values + group_members = MembersFinder.new(@project_members, group).execute(current_user) + end + + if params[:search].present? + user_ids = @project.users.search(params[:search]).select(:id) + @project_members = @project_members.where(user_id: user_ids) + + if group_members + user_ids = group.users.search(params[:search]).select(:id) + group_members = group_members.where(user_id: user_ids) + end + + @group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) + end + + wheres = ["members.id IN (#{@project_members.select(:id).to_sql})"] + wheres << "members.id IN (#{group_members.select(:id).to_sql})" if group_members + + @project_members = Member. + where(wheres.join(' OR ')). + sort(@sort). + page(params[:page]) + + @requesters = AccessRequestsFinder.new(@project).execute(current_user) + + @project_member = @project.project_members.new + end + end + end +end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb new file mode 100644 index 00000000000..702944404f5 --- /dev/null +++ b/app/finders/members_finder.rb @@ -0,0 +1,13 @@ +class MembersFinder < Projects::ApplicationController + def initialize(project_members, project_group) + @project_members = project_members + @project_group = project_group + end + + def execute(current_user) + non_null_user_ids = @project_members.where.not(user_id: nil).select(:user_id) + group_members = @project_group.group_members.where.not(user_id: non_null_user_ids) + group_members = group_members.non_invite unless can?(current_user, :admin_group, @project_group) + group_members + end +end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 99db73c9ee0..5742fec4458 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -206,4 +206,9 @@ module GitlabRoutingHelper file_namespace_project_build_artifacts_path(*args) end end + + # Settings + def project_settings_members_path(project, *args) + namespace_project_settings_members_path(project.namespace, project, *args) + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 6645f13346d..6654f6997ce 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -75,7 +75,7 @@ module SearchHelper { category: "Current Project", label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, - { category: "Current Project", label: "Members", url: namespace_project_project_members_path(@project.namespace, @project) }, + { category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) }, { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, ] else 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/key.rb b/app/models/key.rb index 6f377f0e8ae..8be29c697f1 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -49,6 +49,10 @@ class Key < ActiveRecord::Base "key-#{id}" end + def update_last_used_at + UseKeyWorker.perform_async(self.id) + end + def add_to_shell GitlabShellWorker.perform_async( :add_key, 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/notification_setting.rb b/app/models/notification_setting.rb index 43fc218de2b..58f6214bea7 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -37,6 +37,10 @@ class NotificationSetting < ActiveRecord::Base :success_pipeline ] + EXCLUDED_WATCHER_EVENTS = [ + :success_pipeline + ] + store :events, accessors: EMAIL_EVENTS, coder: JSON before_create :set_events 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/notification_service.rb b/app/services/notification_service.rb index 9a7af5730d2..c3b61e68eab 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -591,7 +591,10 @@ class NotificationService custom_action = build_custom_key(action, target) recipients = target.participants(current_user) - recipients = add_project_watchers(recipients, project) + + unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action) + recipients = add_project_watchers(recipients, project) + end recipients = add_custom_notifications(recipients, project, custom_action) recipients = reject_mention_users(recipients, project) diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 6040391fd94..96c363c8d1a 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -36,7 +36,7 @@ module Projects def groups current_user.authorized_groups.sort_by(&:path).map do |group| count = group.users.count - { username: group.path, name: group.name, count: count, avatar_url: group.avatar.url } + { username: group.path, name: group.name, count: count, avatar_url: group.avatar_url } end 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/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 613b8b7d301..0fb2bb460cb 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -1,14 +1,9 @@ - if project_nav_tab? :team - = nav_link(controller: [:project_members, :teams]) do - = link_to namespace_project_project_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do + = nav_link(controller: [:members, :teams]) do + = link_to namespace_project_settings_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do %span Members - if can_edit - - if @project.allowed_to_share_with_group? - = nav_link(controller: :group_links) do - = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do - %span - Groups = nav_link(controller: :deploy_keys) do = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do %span diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 3276db6692c..d2a60ac2867 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -6,6 +6,9 @@ = key.title .description = key.fingerprint + .last-used-at + last used: + = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : 'n/a' .pull-right %span.key-created-at created #{time_ago_with_tooltip(key.created_at)} diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index dd7615400dc..d44603c638c 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -11,6 +11,9 @@ %li %span.light Created on: %strong= @key.created_at.to_s(:medium) + %li + %span.light Last used on: + %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 %p 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/group_links/index.html.haml b/app/views/projects/group_links/_index.html.haml index 1b0dbbb8111..99d0df2ac34 100644 --- a/app/views/projects/group_links/index.html.haml +++ b/app/views/projects/group_links/_index.html.haml @@ -20,10 +20,10 @@ .form-group = label_tag :expires_at, 'Access expiration date', class: 'label-light' .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date' + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Select access expiration date', id: 'expires_at_groups' %i.clear-icon.js-clear-input .help-block - On this date, all users in the group will automatically lose access to this project. + On this date, all members in the group will automatically lose access to this project. = submit_tag "Share", class: "btn btn-create" .col-lg-9.col-lg-offset-3 %hr 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/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml new file mode 100644 index 00000000000..ab0771b5751 --- /dev/null +++ b/app/views/projects/project_members/_index.html.haml @@ -0,0 +1,22 @@ +.row.prepend-top-default + .col-lg-3.settings-sidebar + %h4.prepend-top-0 + Members + - if can?(current_user, :admin_project_member, @project) + %p + Add a new member to + %strong= @project.name + .col-lg-9 + .light.prepend-top-default + - if can?(current_user, :admin_project_member, @project) + = render "projects/project_members/new_project_member" + + = render 'shared/members/requests', membership_source: @project, requesters: @requesters + .append-bottom-default.clearfix + %h5.member.existing-title + Existing members and groups + - if @group_links.any? + = render 'projects/project_members/groups', group_links: @group_links + + = render 'projects/project_members/team', members: @project_members + = paginate @project_members, theme: "gitlab" diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 79dcd7a6ee9..2b1c23f7dda 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -1,22 +1,18 @@ = form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'users-project-form' } do |f| - .row - .col-md-4.col-lg-6 - = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true) - .help-block.append-bottom-10 - Search for users by name, username, or email, or invite new ones using their email address. - - .col-md-3.col-lg-2 - = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select" - .help-block.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions"), class: "vlink" - about role permissions - - .col-md-3.col-lg-2 - .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' - %i.clear-icon.js-clear-input - .help-block.append-bottom-10 - On this date, the user(s) will automatically lose access to this project. - - .col-md-2 - = f.submit "Add to project", class: "btn btn-create btn-block" + .form-group + = users_select_tag(:user_ids, multiple: true, class: "input-clamp", scope: :all, email_user: true, placeholder: "Search for members to update or invite") + .help-block.append-bottom-10 + Search for members by name, username, or email, or invite new ones using their email address. + .form-group + = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select" + .help-block.append-bottom-10 + = link_to "Read more", help_page_path("user/permissions"), class: "vlink" + about role permissions + .form-group + .clearable-input + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' + %i.clear-icon.js-clear-input + .help-block.append-bottom-10 + On this date, the member(s) will automatically lose access to this project. + = f.submit "Add to project", class: "btn btn-create" + = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default", title: "Import members from another project" diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index c1e894d8f40..5292e73be7a 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,7 +1,13 @@ .panel.panel-default .panel-heading - Users with access to + Members with access to %strong #{@project.name} %span.badge= @project_members.total_count + = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + .form-group + = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } + %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } + = icon("search") + = render 'shared/members/sort_dropdown' %ul.content-list = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml index eef97107d77..42ce4f8001b 100644 --- a/app/views/projects/project_members/import.html.haml +++ b/app/views/projects/project_members/import.html.haml @@ -12,5 +12,4 @@ .form-actions = button_tag 'Import project members', class: "btn btn-create" - = link_to "Cancel", namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-cancel" - + = link_to "Cancel", namespace_project_settings_members_path(@project.namespace, @project), class: "btn btn-cancel" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml deleted file mode 100644 index 4f1cec20f85..00000000000 --- a/app/views/projects/project_members/index.html.haml +++ /dev/null @@ -1,29 +0,0 @@ -- page_title "Members" - -.project-members-page.prepend-top-default - %h4.project-members-title.clearfix - Members - = link_to "Import", import_namespace_project_project_members_path(@project.namespace, @project), class: "btn btn-default pull-right hidden-xs", title: "Import members from another project" - - if can?(current_user, :admin_project_member, @project) - .project-members-new.append-bottom-default - %p.clearfix - Add new user to - %strong= @project.name - = render "new_project_member" - - = render 'shared/members/requests', membership_source: @project, requesters: @requesters - - .append-bottom-default.clearfix - %h5.member.existing-title - Existing users and groups - = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do - .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } - %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } - = icon("search") - = render 'shared/members/sort_dropdown' - - if @group_links.any? - = render 'groups', group_links: @group_links - - = render 'team', members: @project_members - = paginate @project_members, theme: "gitlab" diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml new file mode 100644 index 00000000000..d81ed7bb609 --- /dev/null +++ b/app/views/projects/settings/members/show.html.haml @@ -0,0 +1,6 @@ +- page_title "Members" + += render "projects/project_members/index" +- if can?(current_user, :admin_project, @project) + - if @project.allowed_to_share_with_group? + = render "projects/group_links/index" 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/members/_group.html.haml b/app/views/shared/members/_group.html.haml index a46ba3b0605..81b5bc1de30 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -37,7 +37,6 @@ %i.clear-icon.js-clear-input - if can_admin_member = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), - remote: true, method: :delete, data: { confirm: "Are you sure you want to remove #{group.name}?" }, class: 'btn btn-remove prepend-left-10' do 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/app/workers/use_key_worker.rb b/app/workers/use_key_worker.rb new file mode 100644 index 00000000000..c9d382cc5d6 --- /dev/null +++ b/app/workers/use_key_worker.rb @@ -0,0 +1,13 @@ +class UseKeyWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(key_id) + key = Key.find(key_id) + key.touch(:last_used_at) + rescue ActiveRecord::RecordNotFound + Rails.logger.error("UseKeyWorker: couldn't find key with ID=#{key_id}, skipping job") + + false + end +end 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/25371-environments-date-created-column-is-not-labeled.yml b/changelogs/unreleased/25371-environments-date-created-column-is-not-labeled.yml new file mode 100644 index 00000000000..13d3476fe39 --- /dev/null +++ b/changelogs/unreleased/25371-environments-date-created-column-is-not-labeled.yml @@ -0,0 +1,4 @@ +--- +title: Adds label to Environments "Date Created" +merge_request: 8376 +author: Saad Shahd diff --git a/changelogs/unreleased/25985-combine-members-and-groups-settings-pages.yml b/changelogs/unreleased/25985-combine-members-and-groups-settings-pages.yml new file mode 100644 index 00000000000..206be8fe3cb --- /dev/null +++ b/changelogs/unreleased/25985-combine-members-and-groups-settings-pages.yml @@ -0,0 +1,5 @@ +--- +title: Combined the settings options project members and groups into a single one + called members +merge_request: +author: diff --git a/changelogs/unreleased/26014-fix-update-doc.yml b/changelogs/unreleased/26014-fix-update-doc.yml new file mode 100644 index 00000000000..419c032cb0f --- /dev/null +++ b/changelogs/unreleased/26014-fix-update-doc.yml @@ -0,0 +1,4 @@ +--- +title: Re-order update steps in the 8.14 -> 8.15 upgrade guide +merge_request: +author: diff --git a/changelogs/unreleased/26051-fix-missing-endpoint-route-method.yml b/changelogs/unreleased/26051-fix-missing-endpoint-route-method.yml new file mode 100644 index 00000000000..85440eb86f9 --- /dev/null +++ b/changelogs/unreleased/26051-fix-missing-endpoint-route-method.yml @@ -0,0 +1,4 @@ +--- +title: Don't instrument 405 Grape calls +merge_request: 8445 +author: 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/feature-log-ldap-to-application-log.yml b/changelogs/unreleased/feature-log-ldap-to-application-log.yml new file mode 100644 index 00000000000..4cfbc23edb7 --- /dev/null +++ b/changelogs/unreleased/feature-log-ldap-to-application-log.yml @@ -0,0 +1,4 @@ +--- +title: Log LDAP blocking/unblocking events to application log +merge_request: 8042 +author: Markus Koller diff --git a/changelogs/unreleased/fix-broken-url-on-group-avatar.yml b/changelogs/unreleased/fix-broken-url-on-group-avatar.yml new file mode 100644 index 00000000000..7ce22b4826e --- /dev/null +++ b/changelogs/unreleased/fix-broken-url-on-group-avatar.yml @@ -0,0 +1,4 @@ +--- +title: Fix broken url on group avatar +merge_request: 8464 +author: hogewest diff --git a/changelogs/unreleased/get_last_used_date_of_ssh_key.yml b/changelogs/unreleased/get_last_used_date_of_ssh_key.yml new file mode 100644 index 00000000000..b753949922c --- /dev/null +++ b/changelogs/unreleased/get_last_used_date_of_ssh_key.yml @@ -0,0 +1,4 @@ +--- +title: Record and show last used date of SSH Keys +merge_request: 8113 +author: Vincent Wong 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/remove-successful-pipeline-emails-for-now.yml b/changelogs/unreleased/remove-successful-pipeline-emails-for-now.yml new file mode 100644 index 00000000000..47d484e5c84 --- /dev/null +++ b/changelogs/unreleased/remove-successful-pipeline-emails-for-now.yml @@ -0,0 +1,4 @@ +--- +title: Make successful pipeline emails off for watchers +merge_request: 8176 +author: diff --git a/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml b/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml new file mode 100644 index 00000000000..8ec3cfdbb08 --- /dev/null +++ b/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml @@ -0,0 +1,4 @@ +--- +title: Restore backup correctly when "BACKUP" environment variable is passed +merge_request: 8477 +author: diff --git a/changelogs/unreleased/support-google-cloud-storage-backups.yml b/changelogs/unreleased/support-google-cloud-storage-backups.yml new file mode 100644 index 00000000000..cec279a5c73 --- /dev/null +++ b/changelogs/unreleased/support-google-cloud-storage-backups.yml @@ -0,0 +1,4 @@ +--- +title: Re-add Google Cloud Storage as a backup strategy +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/config/routes/project.rb b/config/routes/project.rb index 4d20acbef7a..26e2dc9e6e7 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -307,6 +307,10 @@ constraints(ProjectUrlConstrainer.new) do end end + namespace :settings do + resource :members, only: [:show] + end + # Since both wiki and repository routing contains wildcard characters # its preferable to keep it below all other project routes draw :wiki diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index c22964179d9..022b0e80917 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -29,6 +29,7 @@ - [email_receiver, 2] - [emails_on_push, 2] - [mailers, 2] + - [use_key, 1] - [repository_fork, 1] - [repository_import, 1] - [project_service, 1] diff --git a/db/migrate/20161221152132_add_last_used_at_to_key.rb b/db/migrate/20161221152132_add_last_used_at_to_key.rb new file mode 100644 index 00000000000..fb2b15817de --- /dev/null +++ b/db/migrate/20161221152132_add_last_used_at_to_key.rb @@ -0,0 +1,9 @@ +class AddLastUsedAtToKey < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :keys, :last_used_at, :datetime + end +end 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 923ece86edb..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" @@ -528,6 +528,7 @@ ActiveRecord::Schema.define(version: 20161227192806) do t.string "fingerprint" t.boolean "public", default: false, null: false t.boolean "can_push", default: false, null: false + t.datetime "last_used_at" end add_index "keys", ["fingerprint"], name: "index_keys_on_fingerprint", unique: true, using: :btree @@ -861,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" @@ -1306,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/administration/auth/ldap.md b/doc/administration/auth/ldap.md index b8b63df091e..04723365277 100644 --- a/doc/administration/auth/ldap.md +++ b/doc/administration/auth/ldap.md @@ -298,8 +298,11 @@ LDAP server please double-check the LDAP `port` and `method` settings used by GitLab. Common combinations are `method: 'plain'` and `port: 389`, OR `method: 'ssl'` and `port: 636`. -### Login with valid credentials rejected +### Troubleshooting -If there is an unexpected error while authenticating the user with the LDAP -backend, the login is rejected and details about the error are logged to +If a user account is blocked or unblocked due to the LDAP configuration, a +message will be logged to `application.log`. + +If there is an unexpected error during an LDAP lookup (configuration error, +timeout), the login is rejected and a message will be logged to `production.log`. diff --git a/doc/administration/reply_by_email.md b/doc/administration/reply_by_email.md index 14cd7a03826..00494e7e9d6 100644 --- a/doc/administration/reply_by_email.md +++ b/doc/administration/reply_by_email.md @@ -71,8 +71,8 @@ If you want to use Gmail / Google Apps with Reply by email, make sure you have [IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018) and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255). -To set up a basic Postfix mail server with IMAP access on Ubuntu, follow -[these instructions](./postfix.md). +To set up a basic Postfix mail server with IMAP access on Ubuntu, follow the +[Postfix setup documentation](reply_by_email_postfix_setup.md). ### Omnibus package installations diff --git a/doc/install/installation.md b/doc/install/installation.md index 2740b2982b9..9dba03b1924 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -271,9 +271,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-15-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-16-stable gitlab -**Note:** You can change `8-15-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `8-16-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It @@ -400,16 +400,10 @@ GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). The following command-line will install GitLab-Workhorse in `/home/git/gitlab-workhorse` which is the recommended location. - cd /home/git/gitlab - sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production ### Initialize Database and Activate Advanced Features - # Go to GitLab installation folder - - cd /home/git/gitlab - sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production # Type 'yes' to create the database tables. diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index f42bb6a81a2..51f7454610d 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -9,6 +9,9 @@ This archive will be saved in `backup_path`, which is specified in the The filename will be `[TIMESTAMP]_gitlab_backup.tar`, where `TIMESTAMP` identifies the time at which each backup was created. +> In GitLab 8.15 we changed the timestamp format from `EPOCH` (`1393513186`) +> to `EPOCH_YYYY_MM_DD` (`1393513186_2014_02_27`) + You can only restore a backup to exactly the same version of GitLab on which it was created. The best way to migrate your repositories from one server to another is through backup restore. @@ -88,7 +91,7 @@ It uses the [Fog library](http://fog.io/) to perform the upload. In the example below we use Amazon S3 for storage, but Fog also lets you use [other storage providers](http://fog.io/storage/). GitLab [imports cloud drivers](https://gitlab.com/gitlab-org/gitlab-ce/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88) -for AWS, OpenStack Swift and Rackspace as well. A local driver is +for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is [also available](#uploading-to-locally-mounted-shares). For omnibus packages: @@ -223,7 +226,8 @@ For installations from source: ## Backup archive permissions -The backup archives created by GitLab (123456_gitlab_backup.tar) will have owner/group git:git and 0600 permissions by default. +The backup archives created by GitLab (`1393513186_2014_02_27_gitlab_backup.tar`) +will have owner/group git:git and 0600 permissions by default. This is meant to avoid other system users reading GitLab's data. If you need the backup archives to have different permissions you can use the 'archive_permissions' setting. @@ -335,7 +339,7 @@ First make sure your backup tar file is in the backup directory described in the `/var/opt/gitlab/backups`. ```shell -sudo cp 1393513186_gitlab_backup.tar /var/opt/gitlab/backups/ +sudo cp 1393513186_2014_02_27_gitlab_backup.tar /var/opt/gitlab/backups/ ``` Stop the processes that are connected to the database. Leave the rest of GitLab diff --git a/doc/update/8.14-to-8.15.md b/doc/update/8.14-to-8.15.md index 8d4bfd913bd..b1e3b116548 100644 --- a/doc/update/8.14-to-8.15.md +++ b/doc/update/8.14-to-8.15.md @@ -11,12 +11,15 @@ guide links by version. ### 1. Stop server - sudo service gitlab stop +```bash +sudo service gitlab stop +``` ### 2. Backup ```bash cd /home/git/gitlab + sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production ``` @@ -49,6 +52,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Get latest code ```bash +cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically ``` @@ -56,6 +61,8 @@ sudo -u git -H git checkout -- db/schema.rb # local changes will be restored aut For GitLab Community Edition: ```bash +cd /home/git/gitlab + sudo -u git -H git checkout 8-15-stable ``` @@ -64,28 +71,12 @@ OR For GitLab Enterprise Edition: ```bash -sudo -u git -H git checkout 8-15-stable-ee -``` - -### 5. Update gitlab-shell - -```bash -cd /home/git/gitlab-shell -sudo -u git -H git fetch --all --tags -sudo -u git -H git checkout v4.1.1 -``` - -### 6. Update gitlab-workhorse - -Install and compile gitlab-workhorse. This requires -[Go 1.5](https://golang.org/dl) which should already be on your system from -GitLab 8.1. +cd /home/git/gitlab -```bash -sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +sudo -u git -H git checkout 8-15-stable-ee ``` -### 7. Install libs, migrations, etc. +### 5. Install libs, migrations, etc. ```bash cd /home/git/gitlab @@ -106,6 +97,27 @@ sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production ``` +### 6. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +``` + +### 7. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v4.1.1 +``` + ### 8. Update configuration files #### New configuration options for `gitlab.yml` @@ -113,6 +125,8 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: ```sh +cd /home/git/gitlab + git diff origin/8-14-stable:config/gitlab.yml.example origin/8-15-stable:config/gitlab.yml.example ``` @@ -122,6 +136,8 @@ Configure Git to generate packfile bitmaps (introduced in Git 2.0) on the GitLab server during `git gc`. ```sh +cd /home/git/gitlab + sudo -u git -H git config --global repack.writeBitmaps true ``` @@ -130,6 +146,8 @@ sudo -u git -H git config --global repack.writeBitmaps true Ensure you're still up-to-date with the latest NGINX configuration changes: ```sh +cd /home/git/gitlab + # For HTTPS configurations git diff origin/8-14-stable:lib/support/nginx/gitlab-ssl origin/8-15-stable:lib/support/nginx/gitlab-ssl @@ -162,26 +180,42 @@ See [smtp_settings.rb.sample] as an example. Ensure you're still up-to-date with the latest init script changes: - sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` For Ubuntu 16.04.1 LTS: - sudo systemctl daemon-reload +```bash +sudo systemctl daemon-reload +``` ### 9. Start application - sudo service gitlab start - sudo service nginx restart +```bash +sudo service gitlab start +sudo service nginx restart +``` ### 10. Check application status Check if GitLab and its environment are configured correctly: - sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` To make sure you didn't miss anything run a more thorough check: - sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` If all items are green, then congratulations, the upgrade is complete! @@ -196,6 +230,7 @@ database migration (the backup is already migrated to the previous version). ```bash cd /home/git/gitlab + sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production ``` diff --git a/doc/update/8.15-to-8.16.md b/doc/update/8.15-to-8.16.md new file mode 100644 index 00000000000..3d68fe201a7 --- /dev/null +++ b/doc/update/8.15-to-8.16.md @@ -0,0 +1,237 @@ +# From 8.15 to 8.16 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +We will continue supporting Ruby < 2.3 for the time being but we recommend you +upgrade to Ruby 2.3 if you're running a source installation, as this is the same +version that ships with our Omnibus package. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz +echo 'a8db9ce7f9110320f33b8325200e3ecfbd2b534b ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 8-16-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 8-16-stable-ee +``` + +### 5. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production +``` + +### 6. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +``` + +### 7. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v4.1.1 +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/8-15-stable:config/gitlab.yml.example origin/8-16-stable:config/gitlab.yml.example +``` + +#### Git configuration + +Configure Git to generate packfile bitmaps (introduced in Git 2.0) on +the GitLab server during `git gc`. + +```sh +cd /home/git/gitlab + +sudo -u git -H git config --global repack.writeBitmaps true +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/8-15-stable:lib/support/nginx/gitlab-ssl origin/8-16-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-15-stable:lib/support/nginx/gitlab origin/8-16-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 9. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 10. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.15) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.14 to 8.15](8.14-to-8.15.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. diff --git a/doc/update/patch_versions.md b/doc/update/patch_versions.md index 685972cfb41..54d523b59fd 100644 --- a/doc/update/patch_versions.md +++ b/doc/update/patch_versions.md @@ -14,6 +14,7 @@ user on the database version) ```bash cd /home/git/gitlab + sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production ``` @@ -32,28 +33,13 @@ current version with `cat VERSION`). ```bash cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- Gemfile.lock db/schema.rb sudo -u git -H git checkout LATEST_TAG -b LATEST_TAG ``` -### 3. Update gitlab-shell to the corresponding version - -```bash -cd /home/git/gitlab-shell -sudo -u git -H git fetch -sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -``` - -### 4. Update gitlab-workhorse to the corresponding version - -```bash -cd /home/git/gitlab - -sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production -``` - -### 5. Install libs, migrations, etc. +### 3. Install libs, migrations, etc. ```bash cd /home/git/gitlab @@ -74,6 +60,23 @@ sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production ``` +### 4. Update gitlab-workhorse to the corresponding version + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +``` + +### 5. Update gitlab-shell to the corresponding version + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` +``` + ### 6. Start application ```bash @@ -86,6 +89,8 @@ sudo service nginx restart Check if GitLab and its environment are configured correctly: ```bash +cd /home/git/gitlab + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production ``` 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/doc/workflow/notifications.md b/doc/workflow/notifications.md index c936e8833c6..4c52974e103 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -73,7 +73,7 @@ In all of the below cases, the notification will be sent to: ...with notification level "Participating" or higher -- Watchers: users with notification level "Watch" +- Watchers: users with notification level "Watch" (however successful pipeline would be off for watchers) - Subscribers: anyone who manually subscribed to the issue/merge request - Custom: Users with notification level "custom" who turned on notifications for any of the events present in the table below diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index 22d971fadfb..c89f587f14d 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -113,8 +113,10 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps project.team << [user, :reporter] end - step 'I click link "Import team from another project"' do - click_link "Import" + step 'I click link "Import team from another project"' do + page.within '.users-project-form' do + click_link "Import" + end end When 'I submit "Website" project for import team' do diff --git a/lib/api/api.rb b/lib/api/api.rb index 9d5adffd8f4..6cf6b501021 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -14,7 +14,11 @@ module API end # Retain 405 error rather than a 500 error for Grape 0.15.0+. - # See: https://github.com/ruby-grape/grape/commit/252bfd27c320466ec3c0751812cf44245e97e5de + # https://github.com/ruby-grape/grape/blob/a3a28f5b5dfbb2797442e006dbffd750b27f2a76/UPGRADING.md#changes-to-method-not-allowed-routes + rescue_from Grape::Exceptions::MethodNotAllowed do |e| + error! e.message, e.status, e.headers + end + rescue_from Grape::Exceptions::Base do |e| error! e.message, e.status, e.headers end 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/api/internal.rb b/lib/api/internal.rb index db2d18f935d..d235977fbd8 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -28,6 +28,8 @@ module API protocol = params[:protocol] + actor.update_last_used_at if actor.is_a?(Key) + access = if wiki? Gitlab::GitAccessWiki.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) @@ -61,6 +63,8 @@ module API status 200 key = Key.find(params[:key_id]) + key.update_last_used_at + token_handler = Gitlab::LfsToken.new(key) { @@ -103,7 +107,9 @@ module API key = Key.find_by(id: params[:key_id]) - unless key + if key + key.update_last_used_at + else return { 'success' => false, 'message' => 'Could not find the given key' } end diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb index 7e6537e3d9e..cefbfdce3bb 100644 --- a/lib/backup/manager.rb +++ b/lib/backup/manager.rb @@ -2,6 +2,7 @@ module Backup class Manager ARCHIVES_TO_BACKUP = %w[uploads builds artifacts lfs registry] FOLDERS_TO_BACKUP = %w[repositories db] + FILE_NAME_SUFFIX = '_gitlab_backup.tar' def pack # Make sure there is a connection @@ -14,7 +15,7 @@ module Backup s[:gitlab_version] = Gitlab::VERSION s[:tar_version] = tar_version s[:skipped] = ENV["SKIP"] - tar_file = s[:backup_created_at].strftime('%s_%Y_%m_%d') + '_gitlab_backup.tar' + tar_file = "#{s[:backup_created_at].strftime('%s_%Y_%m_%d')}#{FILE_NAME_SUFFIX}" Dir.chdir(Gitlab.config.backup.path) do File.open("#{Gitlab.config.backup.path}/backup_information.yml", @@ -82,7 +83,7 @@ module Backup removed = 0 Dir.chdir(Gitlab.config.backup.path) do - Dir.glob('*_gitlab_backup.tar').each do |file| + Dir.glob("*#{FILE_NAME_SUFFIX}").each do |file| next unless file =~ /(\d+)(?:_\d{4}_\d{2}_\d{2})?_gitlab_backup\.tar/ timestamp = $1.to_i @@ -108,41 +109,50 @@ module Backup Dir.chdir(Gitlab.config.backup.path) # check for existing backups in the backup dir - file_list = Dir.glob("*_gitlab_backup.tar") - puts "no backups found" if file_list.count == 0 + file_list = Dir.glob("*#{FILE_NAME_SUFFIX}") + + if file_list.count == 0 + $progress.puts "No backups found in #{Gitlab.config.backup.path}" + $progress.puts "Please make sure that file name ends with #{FILE_NAME_SUFFIX}" + exit 1 + end if file_list.count > 1 && ENV["BACKUP"].nil? - puts "Found more than one backup, please specify which one you want to restore:" - puts "rake gitlab:backup:restore BACKUP=timestamp_of_backup" + $progress.puts 'Found more than one backup, please specify which one you want to restore:' + $progress.puts 'rake gitlab:backup:restore BACKUP=timestamp_of_backup' exit 1 end - tar_file = ENV["BACKUP"].nil? ? file_list.first : file_list.grep(ENV['BACKUP']).first + if ENV['BACKUP'].present? + tar_file = "#{ENV['BACKUP']}#{FILE_NAME_SUFFIX}" + else + tar_file = file_list.first + end unless File.exist?(tar_file) - puts "The specified backup doesn't exist!" + $progress.puts "The backup file #{tar_file} does not exist!" exit 1 end - $progress.print "Unpacking backup ... " + $progress.print 'Unpacking backup ... ' unless Kernel.system(*%W(tar -xf #{tar_file})) - puts "unpacking backup failed".color(:red) + $progress.puts 'unpacking backup failed'.color(:red) exit 1 else - $progress.puts "done".color(:green) + $progress.puts 'done'.color(:green) end ENV["VERSION"] = "#{settings[:db_version]}" if settings[:db_version].to_i > 0 # restoring mismatching backups can lead to unexpected problems if settings[:gitlab_version] != Gitlab::VERSION - puts "GitLab version mismatch:".color(:red) - puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red) - puts " Please switch to the following version and try again:".color(:red) - puts " version: #{settings[:gitlab_version]}".color(:red) - puts - puts "Hint: git checkout v#{settings[:gitlab_version]}" + $progress.puts 'GitLab version mismatch:'.color(:red) + $progress.puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red) + $progress.puts ' Please switch to the following version and try again:'.color(:red) + $progress.puts " version: #{settings[:gitlab_version]}".color(:red) + $progress.puts + $progress.puts "Hint: git checkout v#{settings[:gitlab_version]}" exit 1 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/ci/api/api.rb b/lib/ci/api/api.rb index a6b9beecded..24bb3649a76 100644 --- a/lib/ci/api/api.rb +++ b/lib/ci/api/api.rb @@ -8,6 +8,16 @@ module Ci rack_response({ 'message' => '404 Not found' }.to_json, 404) end + # Retain 405 error rather than a 500 error for Grape 0.15.0+. + # https://github.com/ruby-grape/grape/blob/a3a28f5b5dfbb2797442e006dbffd750b27f2a76/UPGRADING.md#changes-to-method-not-allowed-routes + rescue_from Grape::Exceptions::MethodNotAllowed do |e| + error! e.message, e.status, e.headers + end + + rescue_from Grape::Exceptions::Base do |e| + error! e.message, e.status, e.headers + end + rescue_from :all do |exception| handle_api_exception(exception) end 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/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb index 7e06bd2b0fb..7ed01bf56ca 100644 --- a/lib/gitlab/ldap/access.rb +++ b/lib/gitlab/ldap/access.rb @@ -34,21 +34,21 @@ module Gitlab def allowed? if ldap_user unless ldap_config.active_directory - user.activate if user.ldap_blocked? + unblock_user(user, 'is available again') if user.ldap_blocked? return true end # Block user in GitLab if he/she was blocked in AD if Gitlab::LDAP::Person.disabled_via_active_directory?(user.ldap_identity.extern_uid, adapter) - user.ldap_block + block_user(user, 'is disabled in Active Directory') false else - user.activate if user.ldap_blocked? + unblock_user(user, 'is not disabled anymore') if user.ldap_blocked? true end else # Block the user if they no longer exist in LDAP/AD - user.ldap_block + block_user(user, 'does not exist anymore') false end end @@ -64,6 +64,24 @@ module Gitlab def ldap_user @ldap_user ||= Gitlab::LDAP::Person.find_by_dn(user.ldap_identity.extern_uid, adapter) end + + def block_user(user, reason) + user.ldap_block + + Gitlab::AppLogger.info( + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " + + "blocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end + + def unblock_user(user, reason) + user.activate + + Gitlab::AppLogger.info( + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " + + "unblocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end end end end diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index 91fb0bb317a..d01d47a6a7a 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -70,8 +70,12 @@ module Gitlab def tag_endpoint(trans, env) endpoint = env[ENDPOINT_KEY] - path = endpoint_paths_cache[endpoint.route.request_method][endpoint.route.path] - trans.action = "Grape##{endpoint.route.request_method} #{path}" + + # endpoint.route is nil in the case of a 405 response + if endpoint.route + path = endpoint_paths_cache[endpoint.route.request_method][endpoint.route.path] + trans.action = "Grape##{endpoint.route.request_method} #{path}" + end end private diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index b9d9117c928..17dc101b7ee 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -31,7 +31,7 @@ describe Projects::GroupLinksController do it 'redirects to project group links page' do expect(response).to redirect_to( - namespace_project_group_links_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) end end @@ -62,7 +62,7 @@ describe Projects::GroupLinksController do it 'redirects to project group links page' do expect(response).to redirect_to( - namespace_project_group_links_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) end end @@ -76,7 +76,7 @@ describe Projects::GroupLinksController do it 'redirects to project group links page' do expect(response).to redirect_to( - namespace_project_group_links_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) expect(flash[:alert]).to eq('Please select a group.') 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/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index b52137fbe7e..442f81187dc 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -5,11 +5,11 @@ describe Projects::ProjectMembersController do let(:project) { create(:empty_project, :public, :access_requestable) } describe 'GET index' do - it 'renders index with 200 status code' do + it 'should have the settings/members address with a 302 status code' do get :index, namespace_id: project.namespace, project_id: project - expect(response).to have_http_status(200) - expect(response).to render_template(:index) + expect(response).to have_http_status(302) + expect(response.location).to include namespace_project_settings_members_path(project.namespace, project) end end @@ -44,7 +44,7 @@ describe Projects::ProjectMembersController do access_level: Gitlab::Access::GUEST expect(response).to set_flash.to 'Users were successfully added.' - expect(response).to redirect_to(namespace_project_project_members_path(project.namespace, project)) + expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project)) end it 'adds no user to members' do @@ -56,7 +56,7 @@ describe Projects::ProjectMembersController do access_level: Gitlab::Access::GUEST expect(response).to set_flash.to 'No users or groups specified.' - expect(response).to redirect_to(namespace_project_project_members_path(project.namespace, project)) + expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project)) end end end @@ -99,7 +99,7 @@ describe Projects::ProjectMembersController do id: member expect(response).to redirect_to( - namespace_project_project_members_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) expect(project.members).not_to include member end @@ -259,7 +259,7 @@ describe Projects::ProjectMembersController do expect(project.team_members).to include member expect(response).to set_flash.to 'Successfully imported' expect(response).to redirect_to( - namespace_project_project_members_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) end end diff --git a/spec/controllers/projects/settings/members_controller_spec.rb b/spec/controllers/projects/settings/members_controller_spec.rb new file mode 100644 index 00000000000..076d6cd9c6e --- /dev/null +++ b/spec/controllers/projects/settings/members_controller_spec.rb @@ -0,0 +1,14 @@ +require('spec_helper') + +describe Projects::Settings::MembersController do + let(:project) { create(:empty_project, :public, :access_requestable) } + + describe 'GET show' do + it 'renders show with 200 status code' do + get :show, namespace_id: project.namespace, project_id: project + + expect(response).to have_http_status(200) + expect(response).to render_template(:show) + end + end +end diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb index a7c5283df94..007b35bbb77 100644 --- a/spec/db/production/settings.rb +++ b/spec/db/production/settings.rb @@ -2,10 +2,11 @@ require 'spec_helper' require 'rainbow/ext/string' describe 'seed production settings', lib: true do + include StubENV + context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN').and_return('013456789') + stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789') end it 'writes the token to the database' do 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/group_links_spec.rb b/spec/features/projects/group_links_spec.rb index 1a71a03fbd9..8b302a6aa23 100644 --- a/spec/features/projects/group_links_spec.rb +++ b/spec/features/projects/group_links_spec.rb @@ -14,10 +14,10 @@ feature 'Project group links', feature: true, js: true do context 'setting an expiration date for a group link' do before do - visit namespace_project_group_links_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) select2 group.id, from: '#link_group_id' - fill_in 'expires_at', with: (Time.current + 4.5.days).strftime('%Y-%m-%d') + fill_in 'expires_at_groups', with: (Time.current + 4.5.days).strftime('%Y-%m-%d') page.find('body').click click_on 'Share' end diff --git a/spec/features/projects/members/anonymous_user_sees_members_spec.rb b/spec/features/projects/members/anonymous_user_sees_members_spec.rb index c5e3d143d91..d82cf53c690 100644 --- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb +++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb @@ -11,10 +11,10 @@ feature 'Projects > Members > Anonymous user sees members', feature: true do end scenario "anonymous user visits the project's members page and sees the list of members" do - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) expect(current_path).to eq( - namespace_project_project_members_path(project.namespace, project)) + namespace_project_settings_members_path(project.namespace, project)) expect(page).to have_content(user.name) end end diff --git a/spec/features/projects/members/group_links_spec.rb b/spec/features/projects/members/group_links_spec.rb index 94995f7cf95..cffb935ad5a 100644 --- a/spec/features/projects/members/group_links_spec.rb +++ b/spec/features/projects/members/group_links_spec.rb @@ -12,7 +12,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t @group_link = create(:project_group_link, project: project, group: group) login_as(user) - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) end it 'updates group access level' do @@ -24,7 +24,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t wait_for_ajax - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) expect(first('.group_member')).to have_content('Guest') end diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb index 7d0065ee2c4..3385e5972ff 100644 --- a/spec/features/projects/members/group_members_spec.rb +++ b/spec/features/projects/members/group_members_spec.rb @@ -19,7 +19,7 @@ feature 'Projects members', feature: true do context 'with a group invitee' do before do group_invitee - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) end scenario 'does not appear in the project members page' do @@ -33,7 +33,7 @@ feature 'Projects members', feature: true do before do group_invitee project_invitee - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) end scenario 'shows the project invitee, the project developer, and the group owner' do @@ -54,7 +54,7 @@ feature 'Projects members', feature: true do context 'with a group requester' do before do group.request_access(group_requester) - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) end scenario 'does not appear in the project members page' do @@ -68,7 +68,7 @@ feature 'Projects members', feature: true do before do group.request_access(group_requester) project.request_access(project_requester) - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) end scenario 'shows the project requester, the project developer, and the group owner' do diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb index b7273021c95..f136d9ce0fa 100644 --- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -14,15 +14,15 @@ feature 'Projects > Members > Master adds member with expiration date', feature: login_as(master) end - scenario 'expiration date is displayed in the members list' do + scenario 'expiration date is displayed in the members list', js: true do travel_to Time.zone.parse('2016-08-06 08:00') do - visit namespace_project_project_members_path(project.namespace, project) - + visit namespace_project_settings_members_path(project.namespace, project) page.within '.users-project-form' do select2(new_member.id, from: '#user_ids', multiple: true) fill_in 'expires_at', with: '2016-08-10' - click_on 'Add to project' end + find('.users-project-form').click + click_on 'Add to project' page.within "#project_member_#{new_member.project_members.first.id}" do expect(page).to have_content('Expires in 4 days') diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 97c42bd7f01..0b4dcaa39c6 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -39,7 +39,7 @@ feature 'Projects > Members > User requests access', feature: true do open_project_settings_menu click_link 'Members' - visit namespace_project_project_members_path(project.namespace, project) + visit namespace_project_settings_members_path(project.namespace, project) page.within('.content') do expect(page).not_to have_content(user.name) 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/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 1897c8119d2..ecebabefff8 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -82,8 +82,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end - describe "GET /:project_path/project_members" do - subject { namespace_project_project_members_path(project.namespace, project) } + describe "GET /:project_path/settings/members" do + subject { namespace_project_settings_members_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index f52e23f9433..9bc59a7c4f9 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -82,8 +82,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end - describe "GET /:project_path/project_members" do - subject { namespace_project_project_members_path(project.namespace, project) } + describe "GET /:project_path/settings/members" do + subject { namespace_project_settings_members_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index bed9e92fcb6..a8d43b3d581 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -82,8 +82,8 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for(:visitor) } end - describe "GET /:project_path/project_members" do - subject { namespace_project_project_members_path(project.namespace, project) } + describe "GET /:project_path/settings/members" do + subject { namespace_project_settings_members_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index 837b0de9a4c..ad7f032d1e5 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' require_relative '../../config/initializers/secret_token' describe 'create_tokens', lib: true do + include StubENV + let(:secrets) { ActiveSupport::OrderedOptions.new } before do - allow(ENV).to receive(:[]).and_call_original allow(File).to receive(:write) allow(File).to receive(:delete) allow(Rails).to receive_message_chain(:application, :secrets).and_return(secrets) @@ -17,7 +18,7 @@ describe 'create_tokens', lib: true do context 'setting secret_key_base and otp_key_base' do context 'when none of the secrets exist' do before do - allow(ENV).to receive(:[]).with('SECRET_KEY_BASE').and_return(nil) + stub_env('SECRET_KEY_BASE', nil) allow(File).to receive(:exist?).with('.secret').and_return(false) allow(File).to receive(:exist?).with('config/secrets.yml').and_return(false) allow(self).to receive(:warn_missing_secret) @@ -69,7 +70,7 @@ describe 'create_tokens', lib: true do context 'when secret_key_base exists in the environment and secrets.yml' do before do - allow(ENV).to receive(:[]).with('SECRET_KEY_BASE').and_return('env_key') + stub_env('SECRET_KEY_BASE', 'env_key') secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' 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/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb index 1b749d1bd39..f84782ab440 100644 --- a/spec/lib/gitlab/backup/manager_spec.rb +++ b/spec/lib/gitlab/backup/manager_spec.rb @@ -1,9 +1,27 @@ require 'spec_helper' describe Backup::Manager, lib: true do - describe '#remove_old' do - let(:progress) { StringIO.new } + include StubENV + + let(:progress) { StringIO.new } + + before do + allow(progress).to receive(:puts) + allow(progress).to receive(:print) + + allow_any_instance_of(String).to receive(:color) do |string, _color| + string + end + + @old_progress = $progress # rubocop:disable Style/GlobalVars + $progress = progress # rubocop:disable Style/GlobalVars + end + + after do + $progress = @old_progress # rubocop:disable Style/GlobalVars + end + describe '#remove_old' do let(:files) do [ '1451606400_2016_01_01_gitlab_backup.tar', @@ -20,20 +38,6 @@ describe Backup::Manager, lib: true do allow(Dir).to receive(:glob).and_return(files) allow(FileUtils).to receive(:rm) allow(Time).to receive(:now).and_return(Time.utc(2016)) - - allow(progress).to receive(:puts) - allow(progress).to receive(:print) - - allow_any_instance_of(String).to receive(:color) do |string, _color| - string - end - - @old_progress = $progress # rubocop:disable Style/GlobalVars - $progress = progress # rubocop:disable Style/GlobalVars - end - - after do - $progress = @old_progress # rubocop:disable Style/GlobalVars end context 'when keep_time is zero' do @@ -124,4 +128,82 @@ describe Backup::Manager, lib: true do end end end + + describe '#unpack' do + before do + allow(Dir).to receive(:chdir) + end + + context 'when there are no backup files in the directory' do + before do + allow(Dir).to receive(:glob).and_return([]) + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(progress).to have_received(:puts) + .with(a_string_matching('No backups found')) + end + end + + context 'when there are two backup files in the directory and BACKUP variable is not set' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar', + ] + ) + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(progress).to have_received(:puts) + .with(a_string_matching('Found more than one backup')) + end + end + + context 'when BACKUP variable is set to a non-existing file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(false) + + stub_env('BACKUP', 'wrong') + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar') + expect(progress).to have_received(:puts) + .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist')) + end + end + + context 'when BACKUP variable is set to a correct file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(true) + allow(Kernel).to receive(:system).and_return(true) + allow(YAML).to receive(:load_file).and_return(gitlab_version: Gitlab::VERSION) + + stub_env('BACKUP', '1451606400_2016_01_01') + end + + it 'unpacks the file' do + subject.unpack + + expect(Kernel).to have_received(:system) + .with("tar", "-xf", "1451606400_2016_01_01_gitlab_backup.tar") + expect(progress).to have_received(:puts).with(a_string_matching('done')) + end + end + end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index ac26c831fd0..d88a141b458 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -248,6 +248,7 @@ DeployKey: - fingerprint - public - can_push +- last_used_at Service: - id - type diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb index 534bcbf39fe..011c33e63a1 100644 --- a/spec/lib/gitlab/ldap/access_spec.rb +++ b/spec/lib/gitlab/ldap/access_spec.rb @@ -15,9 +15,9 @@ describe Gitlab::LDAP::Access, lib: true do it { is_expected.to be_falsey } it 'should block user in GitLab' do + expect(access).to receive(:block_user).with(user, 'does not exist anymore') + access.allowed? - expect(user).to be_blocked - expect(user).to be_ldap_blocked end end @@ -34,9 +34,9 @@ describe Gitlab::LDAP::Access, lib: true do it { is_expected.to be_falsey } it 'blocks user in GitLab' do + expect(access).to receive(:block_user).with(user, 'is disabled in Active Directory') + access.allowed? - expect(user).to be_blocked - expect(user).to be_ldap_blocked end end @@ -53,7 +53,10 @@ describe Gitlab::LDAP::Access, lib: true do end it 'does not unblock user in GitLab' do + expect(access).not_to receive(:unblock_user) + access.allowed? + expect(user).to be_blocked expect(user).not_to be_ldap_blocked # this block is handled by omniauth not by our internal logic end @@ -65,8 +68,9 @@ describe Gitlab::LDAP::Access, lib: true do end it 'unblocks user in GitLab' do + expect(access).to receive(:unblock_user).with(user, 'is not disabled anymore') + access.allowed? - expect(user).not_to be_blocked end end end @@ -87,9 +91,9 @@ describe Gitlab::LDAP::Access, lib: true do it { is_expected.to be_falsey } it 'blocks user in GitLab' do + expect(access).to receive(:block_user).with(user, 'does not exist anymore') + access.allowed? - expect(user).to be_blocked - expect(user).to be_ldap_blocked end end @@ -99,11 +103,54 @@ describe Gitlab::LDAP::Access, lib: true do end it 'unblocks the user if it exists' do + expect(access).to receive(:unblock_user).with(user, 'is available again') + access.allowed? - expect(user).not_to be_blocked end end end end end + + describe '#block_user' do + before do + user.activate + allow(Gitlab::AppLogger).to receive(:info) + + access.block_user user, 'reason' + end + + it 'blocks the user' do + expect(user).to be_blocked + expect(user).to be_ldap_blocked + end + + it 'logs the reason' do + expect(Gitlab::AppLogger).to have_received(:info).with( + "LDAP account \"123456\" reason, " + + "blocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end + end + + describe '#unblock_user' do + before do + user.ldap_block + allow(Gitlab::AppLogger).to receive(:info) + + access.unblock_user user, 'reason' + end + + it 'activates the user' do + expect(user).not_to be_blocked + expect(user).not_to be_ldap_blocked + end + + it 'logs the reason' do + Gitlab::AppLogger.info( + "LDAP account \"123456\" reason, " + + "unblocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end + end end 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/key_spec.rb b/spec/models/key_spec.rb index 7758b7ffa97..5eaddd822be 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -28,6 +28,15 @@ describe Key, models: true do expect(build(:key, user: user).publishable_key).to include("#{user.name} (#{Gitlab.config.gitlab.host})") end end + + describe "#update_last_used_at" do + it "enqueues a UseKeyWorker job" do + key = create(:key) + + expect(UseKeyWorker).to receive(:perform_async).with(key.id) + key.update_last_used_at + end + end end context "validation of uniqueness (based on fingerprint uniqueness)" do 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/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb new file mode 100644 index 00000000000..063b3bd76eb --- /dev/null +++ b/spec/services/projects/participants_service_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Projects::ParticipantsService, services: true do + describe '#groups' do + describe 'avatar_url' do + let(:project) { create(:empty_project, :public) } + let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) } + let(:user) { create(:user) } + let(:base_url) { Settings.send(:build_base_gitlab_url) } + let!(:group_member) { create(:group_member, group: group, user: user) } + + it 'should return an url for the avatar' do + participants = described_class.new(project, user) + groups = participants.groups + + expect(groups.size).to eq 1 + expect(groups.first[:avatar_url]).to eq "#{base_url}/uploads/group/avatar/#{group.id}/dk.png" + end + + it 'should return an url for the avatar with relative url' do + stub_config_setting(relative_url_root: '/gitlab') + stub_config_setting(url: Settings.send(:build_gitlab_url)) + + participants = described_class.new(project, user) + groups = participants.groups + + expect(groups.size).to eq 1 + expect(groups.first[:avatar_url]).to eq "#{base_url}/gitlab/uploads/group/avatar/#{group.id}/dk.png" + end + 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/support/stub_env.rb b/spec/support/stub_env.rb new file mode 100644 index 00000000000..18597b5c71f --- /dev/null +++ b/spec/support/stub_env.rb @@ -0,0 +1,7 @@ +module StubENV + def stub_env(key, value) + allow(ENV).to receive(:[]).and_call_original unless @env_already_stubbed + @env_already_stubbed ||= true + allow(ENV).to receive(:[]).with(key).and_return(value) + end +end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index a9fea5f1e81..bc751d20ce1 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -41,7 +41,7 @@ describe 'gitlab:app namespace rake task' do context 'gitlab version' do before do - allow(Dir).to receive(:glob).and_return([]) + allow(Dir).to receive(:glob).and_return(['1_gitlab_backup.tar']) allow(Dir).to receive(:chdir) allow(File).to receive(:exist?).and_return(true) allow(Kernel).to receive(:system).and_return(true) 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 diff --git a/spec/workers/use_key_worker_spec.rb b/spec/workers/use_key_worker_spec.rb new file mode 100644 index 00000000000..e50c788b82a --- /dev/null +++ b/spec/workers/use_key_worker_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe UseKeyWorker do + describe "#perform" do + it "updates the key's last_used_at attribute to the current time when it exists" do + worker = described_class.new + key = create(:key) + current_time = Time.zone.now + + Timecop.freeze(current_time) do + expect { worker.perform(key.id) } + .to change { key.reload.last_used_at }.from(nil).to be_like_time(current_time) + end + end + + it "returns false and skips the job when the key doesn't exist" do + worker = described_class.new + key = create(:key) + + expect(worker.perform(key.id + 1)).to eq false + end + end +end |