diff options
333 files changed, 7039 insertions, 1198 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c35175b4bbc..9f41cbc9228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,19 @@ entry. - Fix "Without projects" filter. !6611 (Ben Bodenmiller) - Fix 404 when visit /projects page +## 8.13.6 (2016-11-17) + +- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002 +- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option. !7117 +- Fix relative links in Markdown wiki when displayed in "Project" tab. !7218 +- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry) +- Fix cache for commit status in commits list to respect branches. !7372 +- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford) +- Limit labels returned for a specific project as an administrator. !7496 +- Clicking "force remove source branch" label now toggles the checkbox again. +- Allow commit note to be visible if repo is visible. +- Fix project Visibility Level selector not using default values. + ## 8.13.5 (2016-11-08) - Restore unauthenticated access to public container registries @@ -103,7 +116,6 @@ entry. - Removes any symlinks before importing a project export file. CVE-2016-9086 - Fixed Import/Export foreign key issue to do with project members. -- Fix relative links in Markdown wiki when displayed in "Project" tab !7218 - Changed build dropdown list length to be 6,5 builds long in the pipeline graph ## 8.13.2 (2016-10-31) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a009138446..659871a06a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -216,7 +216,10 @@ associated with in the description of the issue. We welcome merge requests with fixes and improvements to GitLab code, tests, and/or documentation. The features we would really like a merge request for are listed with the label [`Accepting Merge Requests` on our issue tracker for CE][accepting-mrs-ce] -and [EE][accepting-mrs-ee] but other improvements are also welcome. +and [EE][accepting-mrs-ee] but other improvements are also welcome. Please note +that if an issue is marked for the current milestone either before or while you +are working on it, a team member may take over the merge request in order to +ensure the work is finished before the release date. If you want to add a new feature that is not labeled it is best to first create a feedback issue (if there isn't one already) and leave a comment asking for it @@ -330,13 +330,10 @@ gem 'octokit', '~> 4.3.0' gem 'mail_room', '~> 0.9.0' gem 'email_reply_parser', '~> 0.5.8' +gem 'html2text' gem 'ruby-prof', '~> 0.16.2' -## CI -gem 'activerecord-session_store', '~> 1.0.0' -gem 'nested_form', '~> 0.3.2' - # OAuth gem 'oauth2', '~> 1.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 81b43f2238a..bdc60552480 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,12 +32,6 @@ GEM activemodel (= 4.2.7.1) activesupport (= 4.2.7.1) arel (~> 6.0) - activerecord-session_store (1.0.0) - actionpack (>= 4.0, < 5.1) - activerecord (>= 4.0, < 5.1) - multi_json (~> 1.11, >= 1.11.2) - rack (>= 1.5.2, < 3) - railties (>= 4.0, < 5.1) activerecord_sane_schema_dumper (0.2) rails (>= 4, < 5) activesupport (4.2.7.1) @@ -345,6 +339,8 @@ GEM html-pipeline (1.11.0) activesupport (>= 2) nokogiri (~> 1.4) + html2text (0.2.0) + nokogiri (~> 1.6) htmlentities (4.3.4) httparty (0.13.7) json (~> 1.8) @@ -416,7 +412,6 @@ GEM multi_xml (0.5.5) multipart-post (2.0.0) mysql2 (0.3.20) - nested_form (0.3.2) net-ldap (0.12.1) net-ssh (3.0.1) newrelic_rpm (3.16.0.318) @@ -598,7 +593,7 @@ GEM railties (>= 4.2.0, < 5.1) rinku (2.0.0) rotp (2.1.2) - rouge (2.0.6) + rouge (2.0.7) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -809,7 +804,6 @@ PLATFORMS DEPENDENCIES RedCloth (~> 4.3.2) ace-rails-ap (~> 4.1.0) - activerecord-session_store (~> 1.0.0) activerecord_sane_schema_dumper (= 0.2) acts-as-taggable-on (~> 4.0) addressable (~> 2.3.8) @@ -881,6 +875,7 @@ DEPENDENCIES health_check (~> 2.2.0) hipchat (~> 1.5.0) html-pipeline (~> 1.11.0) + html2text httparty (~> 0.13.3) influxdb (~> 0.2) jira-ruby (~> 1.1.2) @@ -901,7 +896,6 @@ DEPENDENCIES minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) - nested_form (~> 0.3.2) net-ssh (~> 3.0.1) newrelic_rpm (~> 3.16) nokogiri (~> 1.6.7, >= 1.6.7.2) diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 19bfdf1de8c..5d9ac4d350a 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -35,7 +35,7 @@ DefaultOptions: { sorter: function(query, items, searchKey) { // Highlight first item only if at least one char was typed - this.setting.highlightFirst = query.length > 0; + this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; if ((items[0].name != null) && items[0].name === 'loading') { return items; } @@ -51,11 +51,6 @@ if (!GitLab.GfmAutoComplete.dataLoaded) { return this.at; } else { - if (value.indexOf("unlabel") !== -1) { - GitLab.GfmAutoComplete.input.atwho('load', '~', GitLab.GfmAutoComplete.cachedData.unlabels); - } else { - GitLab.GfmAutoComplete.input.atwho('load', '~', GitLab.GfmAutoComplete.cachedData.labels); - } return value; } } @@ -117,6 +112,7 @@ insertTpl: '${atwho-at}${username}', searchKey: 'search', data: ['loading'], + alwaysHighlightFirst: true, callbacks: { sorter: this.DefaultOptions.sorter, filter: this.DefaultOptions.filter, @@ -363,4 +359,3 @@ }; }).call(this); - diff --git a/app/assets/javascripts/group_label_subscription.js.es6 b/app/assets/javascripts/group_label_subscription.js.es6 new file mode 100644 index 00000000000..eea6cd40859 --- /dev/null +++ b/app/assets/javascripts/group_label_subscription.js.es6 @@ -0,0 +1,53 @@ +/* eslint-disable */ +(function(global) { + class GroupLabelSubscription { + constructor(container) { + const $container = $(container); + this.$dropdown = $container.find('.dropdown'); + this.$subscribeButtons = $container.find('.js-subscribe-button'); + this.$unsubscribeButtons = $container.find('.js-unsubscribe-button'); + + this.$subscribeButtons.on('click', this.subscribe.bind(this)); + this.$unsubscribeButtons.on('click', this.unsubscribe.bind(this)); + } + + unsubscribe(event) { + event.preventDefault(); + + const url = this.$unsubscribeButtons.attr('data-url'); + + $.ajax({ + type: 'POST', + url: url + }).done(() => { + this.toggleSubscriptionButtons(); + this.$unsubscribeButtons.removeAttr('data-url'); + }); + } + + subscribe(event) { + event.preventDefault(); + + const $btn = $(event.currentTarget); + const url = $btn.attr('data-url'); + + this.$unsubscribeButtons.attr('data-url', url); + + $.ajax({ + type: 'POST', + url: url + }).done(() => { + this.toggleSubscriptionButtons(); + }); + } + + toggleSubscriptionButtons() { + this.$dropdown.toggleClass('hidden'); + this.$subscribeButtons.toggleClass('hidden'); + this.$unsubscribeButtons.toggleClass('hidden'); + } + } + + global.GroupLabelSubscription = GroupLabelSubscription; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 5e0257c09a6..b1928f8d279 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -145,7 +145,8 @@ if (action === 'show') { action = 'notes'; } - $(".merge-request-tabs a[data-action='" + action + "']").tab('show').trigger('shown.bs.tab'); + // important note: the .tab('show') method triggers 'shown.bs.tab' event itself + $(".merge-request-tabs a[data-action='" + action + "']").tab('show'); }; // Replaces the current Merge Request-specific action in the URL with a new one diff --git a/app/assets/javascripts/pipelines.js.es6 b/app/assets/javascripts/pipelines.js.es6 index e6fada5c84c..a84db9c0233 100644 --- a/app/assets/javascripts/pipelines.js.es6 +++ b/app/assets/javascripts/pipelines.js.es6 @@ -3,26 +3,12 @@ class Pipelines { constructor() { - this.initGraphToggle(); this.addMarginToBuildColumns(); } - initGraphToggle() { - this.pipelineGraph = document.querySelector('.pipeline-graph'); - this.toggleButton = document.querySelector('.toggle-pipeline-btn'); - this.toggleButtonText = this.toggleButton.querySelector('.toggle-btn-text'); - this.toggleButton.addEventListener('click', this.toggleGraph.bind(this)); - } - - toggleGraph() { - const graphCollapsed = this.pipelineGraph.classList.contains('graph-collapsed'); - this.toggleButton.classList.toggle('graph-collapsed'); - this.pipelineGraph.classList.toggle('graph-collapsed'); - this.toggleButtonText.textContent = graphCollapsed ? 'Hide' : 'Expand'; - } - addMarginToBuildColumns() { - const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)'); + this.pipelineGraph = document.querySelector('.pipeline-graph'); + const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)'); for (buildNodeIndex in secondChildBuildNodes) { const buildNode = secondChildBuildNodes[buildNodeIndex]; const firstChildBuildNode = buildNode.previousElementSibling; diff --git a/app/assets/javascripts/project_label_subscription.js.es6 b/app/assets/javascripts/project_label_subscription.js.es6 new file mode 100644 index 00000000000..03a115cb35b --- /dev/null +++ b/app/assets/javascripts/project_label_subscription.js.es6 @@ -0,0 +1,53 @@ +/* eslint-disable */ +(function(global) { + class ProjectLabelSubscription { + constructor(container) { + this.$container = $(container); + this.$buttons = this.$container.find('.js-subscribe-button'); + + this.$buttons.on('click', this.toggleSubscription.bind(this)); + } + + toggleSubscription(event) { + event.preventDefault(); + + const $btn = $(event.currentTarget); + const $span = $btn.find('span'); + const url = $btn.attr('data-url'); + const oldStatus = $btn.attr('data-status'); + + $btn.addClass('disabled'); + $span.toggleClass('hidden'); + + $.ajax({ + type: 'POST', + url: url + }).done(() => { + let newStatus, newAction; + + if (oldStatus === 'unsubscribed') { + [newStatus, newAction] = ['subscribed', 'Unsubscribe']; + } else { + [newStatus, newAction] = ['unsubscribed', 'Subscribe']; + } + + $span.toggleClass('hidden'); + $btn.removeClass('disabled'); + + this.$buttons.attr('data-status', newStatus); + this.$buttons.find('> span').text(newAction); + + for (let button of this.$buttons) { + let $button = $(button); + + if ($button.attr('data-original-title')) { + $button.tooltip('hide').attr('data-original-title', newAction).tooltip('fixTitle'); + } + } + }); + } + } + + global.ProjectLabelSubscription = ProjectLabelSubscription; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index d5cca1b10fb..7c7f991dd87 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -39,3 +39,5 @@ @import "framework/typography.scss"; @import "framework/zen.scss"; @import "framework/blank"; +@import "framework/wells.scss"; +@import "framework/page-header.scss"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 9acff45de75..4a9aa0f8717 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -349,6 +349,12 @@ } } +.btn-inverted { + &-secondary { + @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light); + } +} + @media (max-width: $screen-xs-max) { .btn-wide-on-xs { width: 100%; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ad5ac589d0f..7f5583c917a 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -376,3 +376,19 @@ table { } .hide-bottom-border { border-bottom: none !important; } + +.gl-accessibility { + &:focus { + top: 1px; + left: 1px; + width: auto; + height: 100%; + line-height: 50px; + padding: 0 10px; + clip: auto; + text-decoration: none; + color: $gl-title-color; + background: $gray-light; + z-index: 1; + } +} diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 07c8874bf03..909a0f4afda 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -11,7 +11,7 @@ border-radius: 0; font-family: $monospace_font; font-size: $code_font_size; - line-height: $code_line_height !important; + line-height: 19px; margin: 0; overflow: auto; overflow-y: hidden; @@ -47,7 +47,7 @@ font-family: $monospace_font; display: block; font-size: $code_font_size !important; - line-height: $code_line_height !important; + line-height: 19px; white-space: nowrap; i { diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page-header.scss new file mode 100644 index 00000000000..85c1385d5d9 --- /dev/null +++ b/app/assets/stylesheets/framework/page-header.scss @@ -0,0 +1,67 @@ +.page-content-header { + line-height: 34px; + padding: 10px 0; + margin-bottom: 0; + + @media (min-width: $screen-sm-min) { + display: flex; + align-items: center; + + .header-main-content { + flex: 1; + } + } + + .header-action-buttons { + i { + color: $gl-icon-color; + font-size: 13px; + margin-right: 3px; + } + + @media (max-width: $screen-xs-max) { + .btn { + width: 100%; + margin-top: 10px; + } + + .dropdown { + width: 100%; + } + } + } + + .avatar { + @extend .avatar-inline; + margin-left: 0; + + @media (min-width: $screen-sm-min) { + margin-left: 4px; + } + } + + .commit-committer-link, + .commit-author-link { + color: $gl-gray; + font-weight: bold; + } + + .fa-clipboard { + color: $dropdown-title-btn-color; + } + + .commit-info { + &.branches { + margin-left: 8px; + } + } + + .ci-status-link { + + svg { + position: relative; + top: 2px; + margin: 0 2px 0 3px; + } + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8bf5edfde50..92226f7432e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -90,8 +90,8 @@ $table-border-color: #f0f0f0; $background-color: $gray-light; $dark-background-color: #f5f5f5; $table-text-gray: #8f8f8f; -$widget-expand-item: #e8f2f7; -$widget-inner-border: #eef0f2; +$well-expand-item: #e8f2f7; +$well-inner-border: #eef0f2; /* * Text diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss new file mode 100644 index 00000000000..192939f4527 --- /dev/null +++ b/app/assets/stylesheets/framework/wells.scss @@ -0,0 +1,45 @@ +.info-well { + background: $background-color; + color: $gl-gray; + border: 1px solid $border-color; + border-radius: $border-radius-default; + + .well-segment { + padding: $gl-padding; + + &:not(:last-of-type) { + border-bottom: 1px solid $well-inner-border; + } + + &.branch-info { + .monospace, + .commit-info { + margin-left: 4px; + } + } + } + + .icon-container { + display: inline-block; + margin-right: 8px; + + svg { + position: relative; + top: 2px; + height: 16px; + width: 16px; + } + + &.commit-icon { + svg { + path { + fill: $gl-text-color; + } + } + } + } + + .label.label-gray { + background-color: $well-expand-item; + } +} diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss index 6cefafd8fc7..14812e171fd 100644 --- a/app/assets/stylesheets/pages/admin.scss +++ b/app/assets/stylesheets/pages/admin.scss @@ -160,3 +160,9 @@ } } } + +.admin-builds-table { + .ci-table td:last-child { + min-width: 120px; + } +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index f1d311cabbe..48f11eb2552 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -40,6 +40,19 @@ margin-bottom: 10px; } } + + .environment-information { + background-color: $background-color; + border: 1px solid $border-color; + padding: 12px $gl-padding; + border-radius: $border-radius-default; + + svg { + position: relative; + top: 1px; + margin-right: 5px; + } + } } .build-header { @@ -49,10 +62,6 @@ min-height: 58px; align-items: center; - .btn-inverted { - @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light); - } - @media (max-width: $screen-sm-max) { padding-right: 40px; @@ -63,14 +72,14 @@ .header-content { flex: 1; - } - a { - color: $gl-gray; + a { + color: $gl-gray; - &:hover { - color: $gl-link-color; - text-decoration: none; + &:hover { + color: $gl-link-color; + text-decoration: none; + } } } diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index 47d3e72679b..ddc9d0e2b1a 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -26,143 +26,12 @@ white-space: pre-wrap; } -.commit-info-row { - margin-bottom: 10px; - line-height: 24px; - padding-top: 6px; - - &.commit-info-row-header { - line-height: 34px; - padding: 10px 0; - margin-bottom: 0; - - @media (min-width: $screen-sm-min) { - display: flex; - align-items: center; - - .commit-meta { - flex: 1; - } - } - - .commit-hash-full { - @media (max-width: $screen-sm-max) { - width: 80px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-block; - vertical-align: bottom; - } - } - - .commit-action-buttons { - i { - color: $gl-icon-color; - font-size: 13px; - margin-right: 3px; - } - - @media (max-width: $screen-xs-max) { - .dropdown { - width: 100%; - margin-top: 10px; - } - - .dropdown-toggle { - width: 100%; - } - } - } - } - - .avatar { - @extend .avatar-inline; - margin-left: 0; - - @media (min-width: $screen-sm-min) { - margin-left: 4px; - } - } - - .commit-committer-link, - .commit-author-link { - color: $gl-gray; - font-weight: bold; - } - - .fa-clipboard { - color: $dropdown-title-btn-color; - } - - .commit-info { - &.branches { - margin-left: 8px; - } - } - - .ci-status-link { - - svg { - position: relative; - top: 2px; - margin: 0 2px 0 3px; - } - } -} - .js-details-expand { &:hover { text-decoration: none; } } -.commit-info-widget { - background: $background-color; - color: $gl-gray; - border: 1px solid $border-color; - border-radius: $border-radius-default; - - .widget-row { - padding: $gl-padding; - - &:not(:last-of-type) { - border-bottom: 1px solid $widget-inner-border; - } - - &.branch-info { - .monospace, - .commit-info { - margin-left: 4px; - } - } - } - - .icon-container { - display: inline-block; - margin-right: 8px; - - svg { - position: relative; - top: 2px; - height: 16px; - width: 16px; - } - - &.commit-icon { - svg { - path { - fill: $gl-text-color; - } - } - } - } - - .label.label-gray { - background-color: $widget-expand-item; - } -} - .ci-status-link { svg { overflow: visible; @@ -184,6 +53,17 @@ } } +.commit-hash-full { + @media (max-width: $screen-sm-max) { + width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + vertical-align: bottom; + } +} + .file-stats { ul { list-style: none; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 397f89f501a..e39ce19f846 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -90,7 +90,7 @@ @media (min-width: $screen-sm-min) { display: inline-block; - width: 40%; + width: 30%; margin-left: 10px; margin-bottom: 0; vertical-align: middle; @@ -222,6 +222,14 @@ width: 100%; } +.label-subscription { + vertical-align: middle; + + .dropdown-group-label a { + cursor: pointer; + } +} + .label-subscribe-button { .label-subscribe-button-icon { &[disabled] { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6cf43713fec..b6a82460f25 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -61,7 +61,7 @@ } .ci_widget { - border-bottom: 1px solid $widget-inner-border; + border-bottom: 1px solid $well-inner-border; svg { margin-right: 4px; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 881621a2655..a44a496c728 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -300,6 +300,8 @@ .pipeline-graph { width: 100%; + background-color: $background-color; + padding: $gl-padding; overflow: auto; white-space: nowrap; transition: max-height 0.3s, padding 0.3s; @@ -363,6 +365,7 @@ .build { border: 1px solid $border-color; + background-color: $white-light; position: relative; padding: 7px 10px 8px; border-radius: 30px; diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index daa82336208..5c44637fdee 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -55,7 +55,13 @@ class AutocompleteController < ApplicationController def find_users @users = if @project - @project.team.users + user_ids = @project.team.users.pluck(:id) + + if params[:author_id].present? + user_ids << params[:author_id] + end + + User.where(id: user_ids) elsif params[:group_id].present? group = Group.find(params[:group_id]) return render_404 unless can?(current_user, :read_group, group) diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb new file mode 100644 index 00000000000..2aaf8f2b451 --- /dev/null +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -0,0 +1,7 @@ +module CycleAnalyticsParams + extend ActiveSupport::Concern + + def start_date(params) + params[:start_date] == '30' ? 30.days.ago : 90.days.ago + end +end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index b5e79099e39..6247934f81e 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -10,11 +10,11 @@ module IssuableCollections private def issues_collection - issues_finder.execute + issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) end def merge_requests_collection - merge_requests_finder.execute + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) end def issues_finder diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index b89fb94be6e..b46adcceb60 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -7,7 +7,6 @@ module IssuesAction @issues = issues_collection .non_archived - .preload(:author, :project) .page(params[:page]) respond_to do |format| diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index a1b0eee37f9..6546a07b41c 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -7,7 +7,6 @@ module MergeRequestsAction @merge_requests = merge_requests_collection .non_archived - .preload(:author, :target_project) .page(params[:page]) end end diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb index 9e3b9be2ff4..92cb534343e 100644 --- a/app/controllers/concerns/toggle_subscription_action.rb +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -4,13 +4,17 @@ module ToggleSubscriptionAction def toggle_subscription return unless current_user - subscribable_resource.toggle_subscription(current_user) + subscribable_resource.toggle_subscription(current_user, subscribable_project) head :ok end private + def subscribable_project + @project || raise(NotImplementedError) + end + def subscribable_resource raise NotImplementedError end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 29528b2cfaa..587898a8634 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -1,4 +1,6 @@ class Groups::LabelsController < Groups::ApplicationController + include ToggleSubscriptionAction + before_action :label, only: [:edit, :update, :destroy] before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy] before_action :save_previous_label_path, only: [:edit] @@ -69,6 +71,11 @@ class Groups::LabelsController < Groups::ApplicationController def label @label ||= @group.labels.find(params[:id]) end + alias_method :subscribable_resource, :label + + def subscribable_project + nil + end def label_params params.require(:label).permit(:title, :description, :color) diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb new file mode 100644 index 00000000000..6a1f468ba5a --- /dev/null +++ b/app/controllers/profiles/chat_names_controller.rb @@ -0,0 +1,64 @@ +class Profiles::ChatNamesController < Profiles::ApplicationController + before_action :chat_name_token, only: [:new] + before_action :chat_name_params, only: [:new, :create, :deny] + + def index + @chat_names = current_user.chat_names + end + + def new + end + + def create + new_chat_name = current_user.chat_names.new(chat_name_params) + + if new_chat_name.save + flash[:notice] = "Authorized #{new_chat_name.chat_name}" + else + flash[:alert] = "Could not authorize chat nickname. Try again!" + end + + delete_chat_name_token + redirect_to profile_chat_names_path + end + + def deny + delete_chat_name_token + + flash[:notice] = "Denied authorization of chat nickname #{chat_name_params[:user_name]}." + + redirect_to profile_chat_names_path + end + + def destroy + @chat_name = chat_names.find(params[:id]) + + if @chat_name.destroy + flash[:notice] = "Deleted chat nickname: #{@chat_name.chat_name}!" + else + flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}." + end + + redirect_to profile_chat_names_path + end + + private + + def delete_chat_name_token + chat_name_token.delete + end + + def chat_name_params + @chat_name_params ||= chat_name_token.get || render_404 + end + + def chat_name_token + return render_404 unless params[:token] || render_404 + + @chat_name_token ||= Gitlab::ChatNameToken.new(params[:token]) + end + + def chat_names + @chat_names ||= current_user.chat_names + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index b78cc6585ba..56ced786311 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -42,7 +42,7 @@ class Projects::BlobController < Projects::ApplicationController after_edit_path = if from_merge_request && @target_branch == @ref diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) + - "#file-path-#{hexdigest(@path)}" + "##{hexdigest(@path)}" else namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @path)) end diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb new file mode 100644 index 00000000000..13b3eec761f --- /dev/null +++ b/app/controllers/projects/cycle_analytics/events_controller.rb @@ -0,0 +1,65 @@ +module Projects + module CycleAnalytics + class EventsController < Projects::ApplicationController + include CycleAnalyticsParams + + before_action :authorize_read_cycle_analytics! + before_action :authorize_read_build!, only: [:test, :staging] + before_action :authorize_read_issue!, only: [:issue, :production] + before_action :authorize_read_merge_request!, only: [:code, :review] + + def issue + render_events(events.issue_events) + end + + def plan + render_events(events.plan_events) + end + + def code + render_events(events.code_events) + end + + def test + options[:branch] = events_params[:branch_name] + + render_events(events.test_events) + end + + def review + render_events(events.review_events) + end + + def staging + render_events(events.staging_events) + end + + def production + render_events(events.production_events) + end + + private + + def render_events(events_list) + respond_to do |format| + format.html + format.json { render json: { events: events_list } } + end + end + + def events + @events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options) + end + + def options + @options ||= { from: start_date(events_params), current_user: current_user } + end + + def events_params + return {} unless params[:events].present? + + params[:events].slice(:start_date, :branch_name) + end + end + end +end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 16a7b1fc6e2..96eb75a0547 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -1,11 +1,12 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include ActionView::Helpers::DateHelper include ActionView::Helpers::TextHelper + include CycleAnalyticsParams before_action :authorize_read_cycle_analytics! def show - @cycle_analytics = CycleAnalytics.new(@project, from: parse_start_date) + @cycle_analytics = ::CycleAnalytics.new(@project, from: start_date(cycle_analytics_params)) respond_to do |format| format.html @@ -15,14 +16,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController private - def parse_start_date - case cycle_analytics_params[:start_date] - when '30' then 30.days.ago - when '90' then 90.days.ago - else 90.days.ago - end - end - def cycle_analytics_params return {} unless params[:cycle_analytics].present? diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index ade01c706a7..ba46e2528e6 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -4,6 +4,7 @@ class Projects::ForksController < Projects::ApplicationController # Authorize before_action :require_non_empty_project before_action :authorize_download_code! + before_action :authenticate_user!, only: [:new, :create] def index base_query = project.forks.includes(:creator) diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 42fd09e9b7e..824ed7be73e 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -3,7 +3,7 @@ class Projects::LabelsController < Projects::ApplicationController before_action :module_enabled before_action :label, only: [:edit, :update, :destroy] - before_action :find_labels, only: [:index, :set_priorities, :remove_priority] + before_action :find_labels, only: [:index, :set_priorities, :remove_priority, :toggle_subscription] before_action :authorize_read_label! before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :generate, :destroy, :remove_priority, @@ -123,7 +123,10 @@ class Projects::LabelsController < Projects::ApplicationController def label @label ||= @project.labels.find(params[:id]) end - alias_method :subscribable_resource, :label + + def subscribable_resource + @available_labels.find(params[:id]) + end def find_labels @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index dff0213411c..036fde87619 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -38,7 +38,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController def index @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.preload(:target_project) if params[:label_name].present? labels_params = { project_id: @project.id, title: params[:label_name] } diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 40a23a6f806..30c2a5d9982 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController end def test + return render_404 unless @service.can_test? + data = @service.test_data(project, current_user) outcome = @service.test(data) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7376c2bfeb7..a8a18b4fa16 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -144,15 +144,13 @@ class ProjectsController < Projects::ApplicationController autocomplete = ::Projects::AutocompleteService.new(@project, current_user) participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) - unlabels = autocomplete.unlabels(noteable) @suggestions = { emojis: Gitlab::AwardEmoji.urls, issues: autocomplete.issues, milestones: autocomplete.milestones, mergerequests: autocomplete.merge_requests, - labels: autocomplete.labels - unlabels, - unlabels: unlabels, + labels: autocomplete.labels, members: participants, commands: autocomplete.commands(noteable, params[:type]) } diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 3085ff33aba..04c36b3ebfe 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -12,7 +12,7 @@ class SentNotificationsController < ApplicationController def unsubscribe_and_redirect noteable = @sent_notification.noteable - noteable.unsubscribe(@sent_notification.recipient) + noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project) flash[:notice] = "You have been unsubscribed from this thread." diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c4508ccc3b9..6e29f1e8a65 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -86,7 +86,7 @@ class UsersController < ApplicationController end def exists - render json: { exists: Namespace.where(path: params[:username].downcase).any? } + render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) } end private diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb new file mode 100644 index 00000000000..27975b7ddb7 --- /dev/null +++ b/app/helpers/environment_helper.rb @@ -0,0 +1,29 @@ +module EnvironmentHelper + def environment_for_build(project, build) + return unless build.environment + + project.environments.find_by(name: build.expanded_environment_name) + end + + def environment_link_for_build(project, build) + environment = environment_for_build(project, build) + if environment + link_to environment.name, namespace_project_environment_path(project.namespace, project, environment) + else + content_tag :span, build.expanded_environment_name + end + end + + def deployment_link(deployment) + return unless deployment + + link_to "##{deployment.iid}", [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + end + + def last_deployment_link_for_environment_build(project, build) + environment = environment_for_build(project, build) + return unless environment + + deployment_link(environment.last_deployment) + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index ce2cabd7a3a..8bebda07787 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -171,9 +171,11 @@ module IssuablesHelper def issuables_count_for_state(issuable_type, state) issuables_finder = public_send("#{issuable_type}_finder") - issuables_finder.params[:state] = state + + params = issuables_finder.params.merge(state: state) + finder = issuables_finder.class.new(issuables_finder.current_user, params) - issuables_finder.execute.page(1).total_count + finder.execute.page(1).total_count end IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page] diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 221a84b042f..4f180456b16 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -68,14 +68,6 @@ module LabelsHelper end end - def toggle_subscription_data(label) - return unless label.is_a?(ProjectLabel) - - { - url: toggle_subscription_namespace_project_label_path(label.project.namespace, label.project, label) - } - end - def render_colored_label(label, label_suffix = '', tooltip: true) label_color = label.color || Label::DEFAULT_COLOR text_color = text_color_for_bg(label_color) @@ -148,20 +140,24 @@ module LabelsHelper end end - def label_subscription_status(label) - case label - when GroupLabel then 'Subscribing to group labels is currently not supported.' - when ProjectLabel then label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed' - end + def label_subscription_status(label, project) + return 'project-level' if label.subscribed?(current_user, project) + return 'group-level' if label.subscribed?(current_user) + + 'unsubscribed' end - def label_subscription_toggle_button_text(label) - case label - when GroupLabel then 'Subscribing to group labels is currently not supported.' - when ProjectLabel then label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe' + def group_label_unsubscribe_path(label, project) + case label_subscription_status(label, project) + when 'project-level' then toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) + when 'group-level' then toggle_subscription_group_label_path(label.group, label) end end + def label_subscription_toggle_button_text(label, project) + label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe' + end + def label_deletion_confirm_text(label) case label when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?' diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index a46f2c6e17d..6e68aad4cb7 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -50,7 +50,7 @@ module PreferencesHelper end def default_project_view - return 'readme' unless current_user + return anonymous_project_view unless current_user user_view = current_user.project_view @@ -66,4 +66,8 @@ module PreferencesHelper "customize_workflow" end end + + def anonymous_project_view + @project.empty_repo? || !can?(current_user, :download_code, @project) ? 'activity' : 'readme' + end end diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb index c41181bab3d..b0135ea2e95 100644 --- a/app/helpers/triggers_helper.rb +++ b/app/helpers/triggers_helper.rb @@ -6,4 +6,8 @@ module TriggersHelper "#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds" end end + + def service_trigger_url(service) + "#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger" + end end diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb new file mode 100644 index 00000000000..f321db75eeb --- /dev/null +++ b/app/models/chat_name.rb @@ -0,0 +1,12 @@ +class ChatName < ActiveRecord::Base + belongs_to :service + belongs_to :user + + validates :user, presence: true + validates :service, presence: true + validates :team_id, presence: true + validates :chat_id, presence: true + + validates :user_id, uniqueness: { scope: [:service_id] } + validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 33612256540..5d2e7d94190 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -7,6 +7,8 @@ module Ci belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' + has_many :deployments, as: :deployable + serialize :options serialize :yaml_variables @@ -125,6 +127,34 @@ module Ci !self.pipeline.statuses.latest.include?(self) end + def expanded_environment_name + ExpandVariables.expand(environment, variables) if environment + end + + def has_environment? + self.environment.present? + end + + def starts_environment? + has_environment? && self.environment_action == 'start' + end + + def stops_environment? + has_environment? && self.environment_action == 'stop' + end + + def environment_action + self.options.fetch(:environment, {}).fetch(:action, 'start') + end + + def outdated_deployment? + success? && !last_deployment.try(:last?) + end + + def last_deployment + deployments.last + end + def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 664bb594aa9..ec9e7e5ae2b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -215,7 +215,7 @@ module Issuable end end - def subscribed_without_subscriptions?(user) + def subscribed_without_subscriptions?(user, project) participants(user).include?(user) end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 083257f1005..83daa9b1a64 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -12,39 +12,71 @@ module Subscribable has_many :subscriptions, dependent: :destroy, as: :subscribable end - def subscribed?(user) - if subscription = subscriptions.find_by_user_id(user.id) + def subscribed?(user, project = nil) + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else - subscribed_without_subscriptions?(user) + subscribed_without_subscriptions?(user, project) end end # Override this method to define custom logic to consider a subscribable as # subscribed without an explicit subscription record. - def subscribed_without_subscriptions?(user) + def subscribed_without_subscriptions?(user, project) false end - def subscribers - subscriptions.where(subscribed: true).map(&:user) + def subscribers(project) + subscriptions_available(project). + where(subscribed: true). + map(&:user) end - def toggle_subscription(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: !subscribed?(user)) + def toggle_subscription(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project). + update(subscribed: !subscribed?(user, project)) + end + + def subscribe(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project) + .update(subscribed: true) + end + + def unsubscribe(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project) + .update(subscribed: false) end - def subscribe(user) + private + + def unsubscribe_from_other_levels(user, project) + other_subscriptions = subscriptions.where(user: user) + + other_subscriptions = + if project.blank? + other_subscriptions.where.not(project: nil) + else + other_subscriptions.where(project: nil) + end + + other_subscriptions.update_all(subscribed: false) + end + + def find_or_initialize_subscription(user, project) subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: true) + find_or_initialize_by(user_id: user.id, project_id: project.try(:id)) end - def unsubscribe(user) + def subscriptions_available(project) + t = Subscription.arel_table + subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: false) + where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id)))) end end diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb index 8ed4a56b19b..314a1ce9b63 100644 --- a/app/models/cycle_analytics.rb +++ b/app/models/cycle_analytics.rb @@ -1,12 +1,8 @@ class CycleAnalytics - include Gitlab::Database::Median - include Gitlab::Database::DateTime - - DEPLOYMENT_METRIC_STAGES = %i[production staging] - def initialize(project, from:) @project = project @from = from + @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil) end def summary @@ -14,90 +10,46 @@ class CycleAnalytics end def issue - calculate_metric(:issue, + @fetcher.calculate_metric(:issue, Issue.arel_table[:created_at], [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]]) end def plan - calculate_metric(:plan, + @fetcher.calculate_metric(:plan, [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]], Issue::Metrics.arel_table[:first_mentioned_in_commit_at]) end def code - calculate_metric(:code, + @fetcher.calculate_metric(:code, Issue::Metrics.arel_table[:first_mentioned_in_commit_at], MergeRequest.arel_table[:created_at]) end def test - calculate_metric(:test, + @fetcher.calculate_metric(:test, MergeRequest::Metrics.arel_table[:latest_build_started_at], MergeRequest::Metrics.arel_table[:latest_build_finished_at]) end def review - calculate_metric(:review, + @fetcher.calculate_metric(:review, MergeRequest.arel_table[:created_at], MergeRequest::Metrics.arel_table[:merged_at]) end def staging - calculate_metric(:staging, + @fetcher.calculate_metric(:staging, MergeRequest::Metrics.arel_table[:merged_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end def production - calculate_metric(:production, + @fetcher.calculate_metric(:production, Issue.arel_table[:created_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end - - private - - def calculate_metric(name, start_time_attrs, end_time_attrs) - cte_table = Arel::Table.new("cte_table_for_#{name}") - - # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). - # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). - # We compute the (end_time - start_time) interval, and give it an alias based on the current - # cycle analytics stage. - interval_query = Arel::Nodes::As.new( - cte_table, - subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s)) - - median_datetime(cte_table, interval_query, name) - end - - # Join table with a row for every <issue,merge_request> pair (where the merge request - # closes the given issue) with issue and merge request metrics included. The metrics - # are loaded with an inner join, so issues / merge requests without metrics are - # automatically excluded. - def base_query_for(name) - arel_table = MergeRequestsClosingIssues.arel_table - - # Load issues - query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])). - join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])). - where(Issue.arel_table[:project_id].eq(@project.id)). - where(Issue.arel_table[:deleted_at].eq(nil)). - where(Issue.arel_table[:created_at].gteq(@from)) - - # Load merge_requests - query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin). - on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])). - join(MergeRequest::Metrics.arel_table). - on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id])) - - if DEPLOYMENT_METRIC_STAGES.include?(name) - # Limit to merge requests that have been deployed to production after `@from` - query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from)) - end - - query - end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 73f415c0ef0..5278efd71d2 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -37,6 +37,10 @@ class Environment < ActiveRecord::Base state :stopped end + def recently_updated_on_branch?(ref) + ref.to_s == last_deployment.try(:ref) + end + def last_deployment deployments.last end @@ -92,6 +96,7 @@ class Environment < ActiveRecord::Base def stop!(current_user) return unless stoppable? + stop stop_action.play(current_user) end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 4a4017003d8..6e8f5d3c422 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -266,7 +266,7 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:subscribed] = subscribed?(options[:user]) if options.has_key?(:user) && options[:user] + json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user] if options.has_key?(:labels) json[:labels] = labels.as_json( diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d76feb9680e..9d3eab52189 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -692,12 +692,15 @@ class MergeRequest < ActiveRecord::Base def environments return [] unless diff_head_commit - @environments ||= - begin - envs = target_project.environments_for(target_branch, diff_head_commit, with_tags: true) - envs.concat(source_project.environments_for(source_branch, diff_head_commit)) if source_project - envs.uniq - end + @environments ||= begin + target_envs = target_project.environments_for( + target_branch, commit: diff_head_commit, with_tags: true) + + source_envs = source_project.environments_for( + source_branch, commit: diff_head_commit) if source_project + + (target_envs.to_a + source_envs.to_a).uniq + end end def state_human_name diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 99c49a020c9..cdc408738be 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,5 +1,6 @@ class MergeRequest::Metrics < ActiveRecord::Base belongs_to :merge_request + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id def record! if merge_request.merged? && self.merged_at.blank? diff --git a/app/models/project.rb b/app/models/project.rb index 4aedc91dc34..34b44f90f1c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -23,7 +23,9 @@ class Project < ActiveRecord::Base cache_markdown_field :description, pipeline: :description - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, + :merge_requests_enabled?, :issues_enabled?, to: :project_feature, + allow_nil: true default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level @@ -75,6 +77,7 @@ class Project < ActiveRecord::Base has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards, before_add: :validate_board_limit, dependent: :destroy + has_many :chat_services # Project services has_one :campfire_service, dependent: :destroy @@ -89,6 +92,7 @@ class Project < ActiveRecord::Base has_one :assembla_service, dependent: :destroy has_one :asana_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy + has_one :mattermost_slash_commands_service, dependent: :destroy has_one :slack_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy @@ -1323,22 +1327,30 @@ class Project < ActiveRecord::Base Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) } end - def environments_for(ref, commit, with_tags: false) - environment_ids = deployments.group(:environment_id). - select(:environment_id) + def environments_for(ref, commit: nil, with_tags: false) + deployments_query = with_tags ? 'ref = ? OR tag IS TRUE' : 'ref = ?' - environment_ids = - if with_tags - environment_ids.where('ref=? OR tag IS TRUE', ref) - else - environment_ids.where(ref: ref) - end + environment_ids = deployments + .where(deployments_query, ref.to_s) + .group(:environment_id) + .select(:environment_id) + + environments_found = environments.available + .where(id: environment_ids).to_a + + return environments_found unless commit - environments.available.where(id: environment_ids).select do |environment| + environments_found.select do |environment| environment.includes_commit?(commit) end end + def environments_recently_updated_on_branch(branch) + environments_for(branch).select do |environment| + environment.recently_updated_on_branch?(branch) + end + end + private def pushes_since_gc_redis_key diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 5c53c8f1ee5..03194fc2141 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base merge_requests_access_level > DISABLED end + def issues_enabled? + issues_access_level > DISABLED + end + private # Validates builds and merge requests access level diff --git a/app/models/project_services/chat_service.rb b/app/models/project_services/chat_service.rb new file mode 100644 index 00000000000..d36beff5fa6 --- /dev/null +++ b/app/models/project_services/chat_service.rb @@ -0,0 +1,21 @@ +# Base class for Chat services +# This class is not meant to be used directly, but only to inherrit from. +class ChatService < Service + default_value_for :category, 'chat' + + has_many :chat_names, foreign_key: :service_id + + def valid_token?(token) + self.respond_to?(:token) && + self.token.present? && + ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) + end + + def supported_events + [] + end + + def trigger(params) + raise NotImplementedError + end +end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 2dbe0075465..8915c06b633 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -70,7 +70,7 @@ class JiraService < IssueTrackerService end def jira_project - @jira_project ||= client.Project.find(project_key) + @jira_project ||= jira_request { client.Project.find(project_key) } end def help @@ -128,12 +128,19 @@ class JiraService < IssueTrackerService # we just want to test settings test_settings else - close_issue(push, issue) + jira_issue = jira_request { client.Issue.find(issue.iid) } + + return false unless jira_issue.present? + + close_issue(push, jira_issue) end end def create_cross_reference_note(mentioned, noteable, author) - issue_key = mentioned.id + jira_issue = jira_request { client.Issue.find(mentioned.id) } + + return false unless jira_issue.present? + project = self.project noteable_name = noteable.class.name.underscore.downcase noteable_id = if noteable.is_a?(Commit) @@ -160,7 +167,7 @@ class JiraService < IssueTrackerService } } - add_comment(data, issue_key) + add_comment(data, jira_issue) end # reason why service cannot be tested @@ -181,16 +188,14 @@ class JiraService < IssueTrackerService def test_settings return unless url.present? # Test settings by getting the project - jira_project - - rescue Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}." - false + jira_request { jira_project.present? } end private def close_issue(entity, issue) + return if issue.nil? || issue.resolution.present? + commit_id = if entity.is_a?(Commit) entity.id elsif entity.is_a?(MergeRequest) @@ -200,55 +205,85 @@ class JiraService < IssueTrackerService commit_url = build_entity_url(:commit, commit_id) # Depending on the JIRA project's workflow, a comment during transition - # may or may not be allowed. Split the operation in to two calls so the - # comment always works. - transition_issue(issue) - add_issue_solved_comment(issue, commit_id, commit_url) + # may or may not be allowed. Refresh the issue after transition and check + # if it is closed, so we don't have one comment for every commit. + issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue) + add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution end def transition_issue(issue) - issue = client.Issue.find(issue.iid) issue.transitions.build.save(transition: { id: jira_issue_transition_id }) end def add_issue_solved_comment(issue, commit_id, commit_url) - comment = "Issue solved with [#{commit_id}|#{commit_url}]." - send_message(issue.iid, comment) + link_title = "GitLab: Solved by commit #{commit_id}." + comment = "Issue solved with [#{commit_id}|#{commit_url}]." + link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) + send_message(issue, comment, link_props) end - def add_comment(data, issue_key) - user_name = data[:user][:name] - user_url = data[:user][:url] - entity_name = data[:entity][:name] - entity_url = data[:entity][:url] + def add_comment(data, issue) + user_name = data[:user][:name] + user_url = data[:user][:url] + entity_name = data[:entity][:name] + entity_url = data[:entity][:url] entity_title = data[:entity][:title] project_name = data[:project][:name] - message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}" + link_props = build_remote_link_props(url: entity_url, title: link_title) - unless comment_exists?(issue_key, message) - send_message(issue_key, message) + unless comment_exists?(issue, message) + send_message(issue, message, link_props) end end - def comment_exists?(issue_key, message) - comments = client.Issue.find(issue_key).comments - comments.map { |comment| comment.body.include?(message) }.any? + def comment_exists?(issue, message) + comments = jira_request { issue.comments } + + comments.present? && comments.any? { |comment| comment.body.include?(message) } end - def send_message(issue_key, message) + def send_message(issue, message, remote_link_props) return unless url.present? - issue = client.Issue.find(issue_key) + jira_request do + if issue.comments.build.save!(body: message) + remote_link = issue.remotelink.build + remote_link.save!(remote_link_props) + result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + end - if issue.comments.build.save!(body: message) - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + Rails.logger.info(result_message) + result_message end + end - Rails.logger.info(result_message) - result_message - rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + # Build remote link on JIRA properties + # Icons here must be available on WEB so JIRA can read the URL + # We are using a open word graphics icon which have LGPL license + def build_remote_link_props(url:, title:, resolved: false) + status = { + resolved: resolved + } + + if resolved + status[:icon] = { + title: 'Closed', + url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png' + } + end + + { + GlobalID: 'GitLab', + object: { + url: url, + title: title, + status: status, + icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' } + } + } end def resource_url(resource) @@ -256,16 +291,23 @@ class JiraService < IssueTrackerService end def build_entity_url(entity_name, entity_id) - resource_url( - polymorphic_url( - [ - self.project.namespace.becomes(Namespace), - self.project, - entity_name - ], - id: entity_id, - routing_type: :path - ) + polymorphic_url( + [ + self.project.namespace.becomes(Namespace), + self.project, + entity_name + ], + id: entity_id, + host: Settings.gitlab.base_url ) end + + # Handle errors when doing JIRA API calls + def jira_request + yield + + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e + Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + nil + end end diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb new file mode 100644 index 00000000000..67902329593 --- /dev/null +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -0,0 +1,56 @@ +class MattermostSlashCommandsService < ChatService + include TriggersHelper + + prop_accessor :token + + def can_test? + false + end + + def title + 'Mattermost Command' + end + + def description + "Perform common operations on GitLab in Mattermost" + end + + def to_param + 'mattermost_slash_commands' + end + + def help + "This service allows you to use slash commands with your Mattermost installation.<br/> + To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/> + <br/> + Create integration with URL #{service_trigger_url(self)} and enter the token below." + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '' } + ] + end + + def trigger(params) + return nil unless valid_token?(params[:token]) + + user = find_chat_user(params) + unless user + url = authorize_chat_name_url(params) + return Mattermost::Presenter.authorize_chat_name(url) + end + + Gitlab::ChatCommands::Command.new(project, user, params).execute + end + + private + + def find_chat_user(params) + ChatNames::FindUserService.new(self, params).execute + end + + def authorize_chat_name_url(params) + ChatNames::AuthorizeUserService.new(self, params).execute + end +end diff --git a/app/models/project_services/slack_service/note_message.rb b/app/models/project_services/slack_service/note_message.rb index 9e84e90f38c..797c5937f09 100644 --- a/app/models/project_services/slack_service/note_message.rb +++ b/app/models/project_services/slack_service/note_message.rb @@ -46,25 +46,25 @@ class SlackService commit_sha = commit[:id] commit_sha = Commit.truncate_sha(commit_sha) commented_on_message( - "[commit #{commit_sha}](#{@note_url})", + "commit #{commit_sha}", format_title(commit[:message])) end def create_issue_note(issue) commented_on_message( - "[issue ##{issue[:iid]}](#{@note_url})", + "issue ##{issue[:iid]}", format_title(issue[:title])) end def create_merge_note(merge_request) commented_on_message( - "[merge request !#{merge_request[:iid]}](#{@note_url})", + "merge request !#{merge_request[:iid]}", format_title(merge_request[:title])) end def create_snippet_note(snippet) commented_on_message( - "[snippet ##{snippet[:id]}](#{@note_url})", + "snippet ##{snippet[:id]}", format_title(snippet[:title])) end @@ -76,8 +76,8 @@ class SlackService "[#{@project_name}](#{@project_url})" end - def commented_on_message(target_link, title) - @message = "#{@user_name} commented on #{target_link} in #{project_link}: *#{title}*" + def commented_on_message(target, title) + @message = "#{@user_name} [commented on #{target}](#{@note_url}) in #{project_link}: *#{title}*" end end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 146424d2b1c..31be06be50c 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -176,11 +176,18 @@ class Repository options = { message: message, tagger: user_to_committer(user) } if message - GitHooksService.new.execute(user, path_to_repo, oldrev, target, ref) do - rugged.tags.create(tag_name, target, options) + rugged.tags.create(tag_name, target, options) + tag = find_tag(tag_name) + + GitHooksService.new.execute(user, path_to_repo, oldrev, tag.target, ref) do + # we already created a tag, because we need tag SHA to pass correct + # values to hooks end - find_tag(tag_name) + tag + rescue GitHooksService::PreReceiveError + rugged.tags.delete(tag_name) + raise end def rm_branch(user, branch_name) diff --git a/app/models/service.rb b/app/models/service.rb index 9d6ff190cdf..edd6b5329f3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -202,7 +202,6 @@ class Service < ActiveRecord::Base bamboo buildkite builds_email - pipelines_email bugzilla campfire custom_issue_tracker @@ -214,6 +213,8 @@ class Service < ActiveRecord::Base hipchat irker jira + mattermost_slash_commands + pipelines_email pivotaltracker pushover redmine diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 3b8aa1eb866..17869c8bac2 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,8 +1,9 @@ class Subscription < ActiveRecord::Base belongs_to :user + belongs_to :project belongs_to :subscribable, polymorphic: true - validates :user_id, - uniqueness: { scope: [:subscribable_id, :subscribable_type] }, - presence: true + validates :user, :subscribable, presence: true + + validates :project_id, uniqueness: { scope: [:subscribable_id, :subscribable_type, :user_id] } end diff --git a/app/models/user.rb b/app/models/user.rb index 5a2b232c4ed..519ed92e28b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,7 @@ class User < ActiveRecord::Base has_many :personal_access_tokens, dependent: :destroy has_many :identities, dependent: :destroy, autosave: true has_many :u2f_registrations, dependent: :destroy + has_many :chat_names, dependent: :destroy # Groups has_many :members, dependent: :destroy diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb new file mode 100644 index 00000000000..5fdf2bbf7c3 --- /dev/null +++ b/app/serializers/analytics_build_entity.rb @@ -0,0 +1,40 @@ +class AnalyticsBuildEntity < Grape::Entity + include RequestAwareEntity + include EntityDateHelper + + expose :name + expose :id + expose :ref, as: :branch + expose :short_sha + expose :author, using: UserEntity + + expose :started_at, as: :date do |build| + interval_in_words(build[:started_at]) + end + + expose :duration, as: :total_time do |build| + distance_of_time_as_hash(build[:duration].to_f) + end + + expose :branch do + expose :ref, as: :name + + expose :url do |build| + url_to(:namespace_project_tree, build, build.ref) + end + end + + expose :url do |build| + url_to(:namespace_project_build, build) + end + + expose :commit_url do |build| + url_to(:namespace_project_commit, build, build.sha) + end + + private + + def url_to(route, build, id = nil) + public_send("#{route}_url", build.project.namespace, build.project, id || build) + end +end diff --git a/app/serializers/analytics_build_serializer.rb b/app/serializers/analytics_build_serializer.rb new file mode 100644 index 00000000000..f172d67d356 --- /dev/null +++ b/app/serializers/analytics_build_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsBuildSerializer < BaseSerializer + entity AnalyticsBuildEntity +end diff --git a/app/serializers/analytics_commit_entity.rb b/app/serializers/analytics_commit_entity.rb new file mode 100644 index 00000000000..402cecbfd08 --- /dev/null +++ b/app/serializers/analytics_commit_entity.rb @@ -0,0 +1,13 @@ +class AnalyticsCommitEntity < CommitEntity + include EntityDateHelper + + expose :short_id, as: :short_sha + + expose :total_time do |commit| + distance_of_time_as_hash(request.total_time.to_f) + end + + unexpose :author_name + unexpose :author_email + unexpose :message +end diff --git a/app/serializers/analytics_commit_serializer.rb b/app/serializers/analytics_commit_serializer.rb new file mode 100644 index 00000000000..cdbfecf2b70 --- /dev/null +++ b/app/serializers/analytics_commit_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsCommitSerializer < BaseSerializer + entity AnalyticsCommitEntity +end diff --git a/app/serializers/analytics_generic_serializer.rb b/app/serializers/analytics_generic_serializer.rb new file mode 100644 index 00000000000..9f4859e8410 --- /dev/null +++ b/app/serializers/analytics_generic_serializer.rb @@ -0,0 +1,7 @@ +class AnalyticsGenericSerializer < BaseSerializer + def represent(resource, opts = {}) + resource.symbolize_keys! + + super(resource, opts) + end +end diff --git a/app/serializers/analytics_issue_entity.rb b/app/serializers/analytics_issue_entity.rb new file mode 100644 index 00000000000..44c50f18613 --- /dev/null +++ b/app/serializers/analytics_issue_entity.rb @@ -0,0 +1,29 @@ +class AnalyticsIssueEntity < Grape::Entity + include RequestAwareEntity + include EntityDateHelper + + expose :title + expose :author, using: UserEntity + + expose :iid do |object| + object[:iid].to_s + end + + expose :total_time do |object| + distance_of_time_as_hash(object[:total_time].to_f) + end + + expose(:created_at) do |object| + interval_in_words(object[:created_at]) + end + + expose :url do |object| + url_to(:namespace_project_issue, id: object[:iid].to_s) + end + + private + + def url_to(route, id) + public_send("#{route}_url", request.project.namespace, request.project, id) + end +end diff --git a/app/serializers/analytics_issue_serializer.rb b/app/serializers/analytics_issue_serializer.rb new file mode 100644 index 00000000000..4fb3e8f1bb4 --- /dev/null +++ b/app/serializers/analytics_issue_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsIssueSerializer < AnalyticsGenericSerializer + entity AnalyticsIssueEntity +end diff --git a/app/serializers/analytics_merge_request_entity.rb b/app/serializers/analytics_merge_request_entity.rb new file mode 100644 index 00000000000..888265eaa38 --- /dev/null +++ b/app/serializers/analytics_merge_request_entity.rb @@ -0,0 +1,7 @@ +class AnalyticsMergeRequestEntity < AnalyticsIssueEntity + expose :state + + expose :url do |object| + url_to(:namespace_project_merge_request, id: object[:iid].to_s) + end +end diff --git a/app/serializers/analytics_merge_request_serializer.rb b/app/serializers/analytics_merge_request_serializer.rb new file mode 100644 index 00000000000..4622a1dd855 --- /dev/null +++ b/app/serializers/analytics_merge_request_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsMergeRequestSerializer < AnalyticsGenericSerializer + entity AnalyticsMergeRequestEntity +end diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb new file mode 100644 index 00000000000..b333b3344c3 --- /dev/null +++ b/app/serializers/entity_date_helper.rb @@ -0,0 +1,35 @@ +module EntityDateHelper + include ActionView::Helpers::DateHelper + + def interval_in_words(diff) + "#{distance_of_time_in_words(diff.to_f)} ago" + end + + # Converts seconds into a hash such as: + # { days: 1, hours: 3, mins: 42, seconds: 40 } + # + # It returns 0 seconds for zero or negative numbers + # It rounds to nearest time unit and does not return zero + # i.e { min: 1 } instead of { mins: 1, seconds: 0 } + def distance_of_time_as_hash(diff) + diff = diff.abs.floor + + return { seconds: 0 } if diff == 0 + + mins = (diff / 60).floor + seconds = diff % 60 + hours = (mins / 60).floor + mins = mins % 60 + days = (hours / 24).floor + hours = hours % 24 + + duration_hash = {} + + duration_hash[:days] = days if days > 0 + duration_hash[:hours] = hours if hours > 0 + duration_hash[:mins] = mins if mins > 0 + duration_hash[:seconds] = seconds if seconds > 0 + + duration_hash + end +end diff --git a/app/services/after_branch_delete_service.rb b/app/services/after_branch_delete_service.rb new file mode 100644 index 00000000000..2be4d3e6ab5 --- /dev/null +++ b/app/services/after_branch_delete_service.rb @@ -0,0 +1,23 @@ +require_relative 'base_service' + +## +# Branch can be deleted either by DeleteBranchService +# or by GitPushService. +# +class AfterBranchDeleteService < BaseService + attr_reader :branch_name + + def execute(branch_name) + @branch_name = branch_name + + stop_environments + end + + private + + def stop_environments + Ci::StopEnvironmentsService + .new(project, current_user) + .execute(branch_name) + end +end diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb new file mode 100644 index 00000000000..321bf3a9205 --- /dev/null +++ b/app/services/chat_names/authorize_user_service.rb @@ -0,0 +1,38 @@ +module ChatNames + class AuthorizeUserService + include Gitlab::Routing.url_helpers + + def initialize(service, params) + @service = service + @params = params + end + + def execute + return unless chat_name_params.values.all?(&:present?) + + token = request_token + + new_profile_chat_name_url(token: token) if token + end + + private + + def request_token + chat_name_token.store!(chat_name_params) + end + + def chat_name_token + Gitlab::ChatNameToken.new + end + + def chat_name_params + { + service_id: @service.id, + team_id: @params[:team_id], + team_domain: @params[:team_domain], + chat_id: @params[:user_id], + chat_name: @params[:user_name] + } + end + end +end diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb new file mode 100644 index 00000000000..4f5c5567b42 --- /dev/null +++ b/app/services/chat_names/find_user_service.rb @@ -0,0 +1,26 @@ +module ChatNames + class FindUserService + def initialize(service, params) + @service = service + @params = params + end + + def execute + chat_name = find_chat_name + return unless chat_name + + chat_name.touch(:last_used_at) + chat_name.user + end + + private + + def find_chat_name + ChatName.find_by( + service: @service, + team_id: @params[:team_id], + chat_id: @params[:user_id] + ) + end + end +end diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb new file mode 100644 index 00000000000..cf590459cb2 --- /dev/null +++ b/app/services/ci/stop_environments_service.rb @@ -0,0 +1,29 @@ +module Ci + class StopEnvironmentsService < BaseService + attr_reader :ref + + def execute(branch_name) + @ref = branch_name + + return unless has_ref? + + environments.each do |environment| + next unless environment.stoppable? + next unless can?(current_user, :create_deployment, project) + + environment.stop!(current_user) + end + end + + private + + def has_ref? + @ref.present? + end + + def environments + @environments ||= project + .environments_recently_updated_on_branch(@ref) + end + end +end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 0081364b8aa..a880952e274 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -6,12 +6,10 @@ class DestroyGroupService end def async_execute - group.transaction do - # Soft delete via paranoia gem - group.destroy - job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) - Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") - end + # Soft delete via paranoia gem + group.destroy + job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) + Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") end def execute diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index de313095bed..77c6c81cc1b 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -49,10 +49,7 @@ class GitPushService < BaseService update_gitattributes if is_default_branch? end - # Update merge requests that may be affected by this push. A new branch - # could cause the last commit of a merge request to change. - update_merge_requests - + execute_related_hooks perform_housekeeping end @@ -62,14 +59,24 @@ class GitPushService < BaseService protected - def update_merge_requests - UpdateMergeRequestsWorker.perform_async(@project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) + def execute_related_hooks + # Update merge requests that may be affected by this push. A new branch + # could cause the last commit of a merge request to change. + # + UpdateMergeRequestsWorker + .perform_async(@project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) EventCreateService.new.push(@project, current_user, build_push_data) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute ProjectCacheWorker.perform_async(@project.id) + + if push_remove_branch? + AfterBranchDeleteService + .new(project, current_user) + .execute(branch_name) + end end def perform_housekeeping diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index bb92cd80cc9..575795788de 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -212,9 +212,9 @@ class IssuableBaseService < BaseService def change_subscription(issuable) case params.delete(:subscription_event) when 'subscribe' - issuable.subscribe(current_user) + issuable.subscribe(current_user, project) when 'unsubscribe' - issuable.unsubscribe(current_user) + issuable.unsubscribe(current_user, project) end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6697840cc26..ecdcbf08ee1 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -75,7 +75,7 @@ class NotificationService # * watchers of the issue's labels # def relabeled_issue(issue, added_labels, current_user) - relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email) + relabeled_resource_email(issue, issue.project, added_labels, current_user, :relabeled_issue_email) end # When create a merge request we should send an email to: @@ -118,7 +118,7 @@ class NotificationService # * watchers of the mr's labels # def relabeled_merge_request(merge_request, added_labels, current_user) - relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email) + relabeled_resource_email(merge_request, merge_request.target_project, added_labels, current_user, :relabeled_merge_request_email) end def close_mr(merge_request, current_user) @@ -205,7 +205,7 @@ class NotificationService recipients = reject_muted_users(recipients, note.project) - recipients = add_subscribed_users(recipients, note.noteable) + recipients = add_subscribed_users(recipients, note.project, note.noteable) recipients = reject_unsubscribed_users(recipients, note.noteable) recipients = reject_users_without_access(recipients, note.noteable) @@ -393,7 +393,7 @@ class NotificationService ) end - # Build a list of users based on project notifcation settings + # Build a list of users based on project notification settings def select_project_member_setting(project, global_setting, users_global_level_watch) users = notification_settings_for(project, :watch) @@ -505,17 +505,17 @@ class NotificationService end end - def add_subscribed_users(recipients, target) + def add_subscribed_users(recipients, project, target) return recipients unless target.respond_to? :subscribers - recipients + target.subscribers + recipients + target.subscribers(project) end - def add_labels_subscribers(recipients, target, labels: nil) + def add_labels_subscribers(recipients, project, target, labels: nil) return recipients unless target.respond_to? :labels (labels || target.labels).each do |label| - recipients += label.subscribers + recipients += label.subscribers(project) end recipients @@ -571,8 +571,8 @@ class NotificationService end end - def relabeled_resource_email(target, labels, current_user, method) - recipients = build_relabeled_recipients(target, current_user, labels: labels) + def relabeled_resource_email(target, project, labels, current_user, method) + recipients = build_relabeled_recipients(target, project, current_user, labels: labels) label_names = labels.map(&:name) recipients.each do |recipient| @@ -608,10 +608,10 @@ class NotificationService end recipients = reject_muted_users(recipients, project) - recipients = add_subscribed_users(recipients, target) + recipients = add_subscribed_users(recipients, project, target) if [:new_issue, :new_merge_request].include?(custom_action) - recipients = add_labels_subscribers(recipients, target) + recipients = add_labels_subscribers(recipients, project, target) end recipients = reject_unsubscribed_users(recipients, target) @@ -622,8 +622,8 @@ class NotificationService recipients.uniq end - def build_relabeled_recipients(target, current_user, labels:) - recipients = add_labels_subscribers([], target, labels: labels) + def build_relabeled_recipients(target, project, current_user, labels:) + recipients = add_labels_subscribers([], project, target, labels: labels) recipients = reject_unsubscribed_users(recipients, target) recipients = reject_users_without_access(recipients, target) recipients.delete(current_user) diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 223461e88b6..015f2828921 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -13,14 +13,7 @@ module Projects end def labels - LabelsFinder.new(current_user, project_id: project.id).execute. - pluck(:title, :color).map { |l| { title: l.first, color: l.second } } - end - - def unlabels(noteable) - return [] unless noteable && noteable.respond_to?(:labels) - - noteable.labels.pluck(:title, :color).map { |l| { title: l.first, color: l.second } } + LabelsFinder.new(current_user, project_id: project.id).execute.select([:title, :color]) end def commands(noteable, type) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 5a81194a5f4..d75c5b1800e 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -193,7 +193,7 @@ module SlashCommands desc 'Subscribe' condition do issuable.persisted? && - !issuable.subscribed?(current_user) + !issuable.subscribed?(current_user, project) end command :subscribe do @updates[:subscription_event] = 'subscribe' @@ -202,7 +202,7 @@ module SlashCommands desc 'Unsubscribe' condition do issuable.persisted? && - issuable.subscribed?(current_user) + issuable.subscribed?(current_user, project) end command :unsubscribe do @updates[:subscription_event] = 'unsubscribe' diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index 26a8846b609..5e3f105d41f 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -14,5 +14,5 @@ .row-content-block.second-block #{(@scope || 'all').capitalize} builds - %ul.content-list.builds-content-list + %ul.content-list.builds-content-list.admin-builds-table = render "projects/builds/table", builds: @builds, admin: true diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 817910f7ddf..589f4557b52 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -7,7 +7,7 @@ .col-sm-10 = render 'shared/choose_group_avatar_button', f: f - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/admin/groups/edit.html.haml b/app/views/admin/groups/edit.html.haml index eb09a6328ed..c2b9807015d 100644 --- a/app/views/admin/groups/edit.html.haml +++ b/app/views/admin/groups/edit.html.haml @@ -1,4 +1,4 @@ - page_title "Edit", @group.name, "Groups" %h3.page-title Edit group: #{@group.name} %hr -= render 'form' += render 'form', visibility_level: @group.visibility_level diff --git a/app/views/admin/groups/new.html.haml b/app/views/admin/groups/new.html.haml index c81ee552ac3..8f9fe96249f 100644 --- a/app/views/admin/groups/new.html.haml +++ b/app/views/admin/groups/new.html.haml @@ -1,4 +1,4 @@ - page_title "New Group" %h3.page-title New group %hr -= render 'form' += render 'form', visibility_level: default_group_visibility diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 7c68e3266e5..3133f6de2e8 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -8,7 +8,7 @@ = f.text_field :name, class: "form-control top", required: true, title: "This field is required." %div.username.form-group = f.label :username - = f.text_field :username, class: "form-control middle", pattern: "[a-zA-Z0-9]+", required: true, title: 'Please create a username with only alphanumeric characters.' + = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_SIMPLE, required: true, title: 'Please create a username with only alphanumeric characters.' %p.validation-error.hide Username is already taken. %p.validation-success.hide Username is available. %p.validation-pending.hide Checking username availability... diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 8aefdcb3d9b..a9a0b149049 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -26,5 +26,5 @@ = render "layouts/flash" = yield :flash_message %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } - .content + .content{ id: "content-body" } = yield diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 7a9859262f7..5456be77aab 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,5 @@ %header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class } + %a{ href: "#content-body", tabindex: "1", class: "sr-only gl-accessibility" } Skip to content %div{ class: "container-fluid" } .header-content %button.side-nav-toggle{ type: 'button', "aria-label" => "Toggle global navigation" } diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 6d514f669db..e06301bda14 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,6 +17,10 @@ = link_to applications_profile_path, title: 'Applications' do %span Applications + = nav_link(controller: :chat_names) do + = link_to profile_chat_names_path, title: 'Chat' do + %span + Chat = nav_link(controller: :personal_access_tokens) do = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do %span diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c0c07d65daa..307c5a11206 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -27,9 +27,9 @@ %h4 #{pluralize @message.diffs_count, "changed file"}: %ul - - @message.diffs.each_with_index do |diff, i| + - @message.diffs.each do |diff| %li.file-stats - %a{href: "#{@message.target_url if @message.disable_diffs?}#diff-#{i}" } + %a{href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff.file_path)}" } - if diff.deleted_file %span.deleted-file − @@ -52,9 +52,10 @@ %h5 The diff was not included because it is too large. - else %h4 Changes: - - diff_files.each_with_index do |diff_file, i| - %li{id: "diff-#{i}"} - %a{href: @message.target_url + "#diff-#{i}"}< + - diff_files.each do |diff_file| + - file_hash = hexdigest(diff_file.file_path) + %li{id: file_hash} + %a{href: @message.target_url + "##{file_hash}"}< - if diff_file.deleted_file %strong< = diff_file.old_path diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml new file mode 100644 index 00000000000..6b32d377e1a --- /dev/null +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -0,0 +1,27 @@ +- service = chat_name.service +- project = service.project +%tr + %td + %strong + - if can?(current_user, :read_project, project) + = link_to project.name_with_namespace, project_path(project) + - else + .light N/A + %td + %strong + - if can?(current_user, :admin_project, project) + = link_to service.title, edit_namespace_project_service_path(project.namespace, project, service) + - else + = service.title + %td + = chat_name.team_domain + %td + = chat_name.chat_name + %td + - if chat_name.last_used_at + time_ago_with_tooltip(chat_name.last_used_at) + - else + Never + + %td + = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger pull-right', data: { confirm: 'Are you sure you want to revoke this nickname?' } diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml new file mode 100644 index 00000000000..20cc636b2da --- /dev/null +++ b/app/views/profiles/chat_names/index.html.haml @@ -0,0 +1,30 @@ +- page_title 'Chat' += render 'profiles/head' + +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + You can see your Chat accounts. + + .col-lg-9 + %h5 Active chat names (#{@chat_names.size}) + + - if @chat_names.present? + .table-responsive + %table.table.chat-names + %thead + %tr + %th Project + %th Service + %th Team domain + %th Nickname + %th Last used + %th + %tbody + = render @chat_names + + - else + .settings-message.text-center + You don't have any active chat names. diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml new file mode 100644 index 00000000000..f635acf96e2 --- /dev/null +++ b/app/views/profiles/chat_names/new.html.haml @@ -0,0 +1,15 @@ +%h3.page-title Authorization required +%main{:role => "main"} + %p.h4 + Authorize + %strong.text-info= @chat_name_params[:chat_name] + to use your account? + + %hr + .actions + = form_tag profile_chat_names_path, method: :post do + = hidden_field_tag :token, @chat_name_token.token + = submit_tag "Authorize", class: "btn btn-success wide pull-left" + = form_tag deny_profile_chat_names_path, method: :delete do + = hidden_field_tag :token, @chat_name_token.token + = submit_tag "Deny", class: "btn btn-danger prepend-left-10" diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 9f69bd64f71..f6aa20c4579 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -17,6 +17,6 @@ = render "user" = time_ago_with_tooltip(@build.created_at) - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted pull-right', method: :post + = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary pull-right', method: :post %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index f533eec642e..d8cbfd7173a 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -26,6 +26,30 @@ = link_to namespace_project_runners_path(@build.project.namespace, @build.project) do Runners page + - if @build.starts_environment? + .prepend-top-default + .environment-information + - if @build.outdated_deployment? + = ci_icon_for_status('success_with_warnings') + - else + = ci_icon_for_status(@build.status) + + - environment = environment_for_build(@build.project, @build) + - if @build.success? && @build.last_deployment.present? + - if @build.last_deployment.last? + This build is the most recent deployment to #{environment_link_for_build(@build.project, @build)}. + - else + This build is an out-of-date deployment to #{environment_link_for_build(@build.project, @build)}. + - if environment.last_deployment + View the most recent deployment #{deployment_link(environment.last_deployment)}. + - elsif @build.complete? && !@build.success? + The deployment of this build to #{environment_link_for_build(@build.project, @build)} did not succeed. + - else + This build is creating a deployment to #{environment_link_for_build(@build.project, @build)} + - if environment.last_deployment + and will overwrite the + = link_to 'latest deployment', deployment_link(environment.last_deployment) + .prepend-top-default - if @build.erased? .erased.alert.alert-warning diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 0ebc38d16cf..503cbd13b5e 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,5 +1,5 @@ -.commit-info-row.commit-info-row-header - .commit-meta +.page-content-header + .header-main-content %strong Commit %strong.monospace.js-details-short= @commit.short_id = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do @@ -19,7 +19,8 @@ %strong = commit_committer_link(@commit, avatar: true, size: 24) #{time_ago_with_tooltip(@commit.committed_date)} - .commit-action-buttons + + .header-action-buttons - if defined?(@notes_count) && @notes_count > 0 %span.btn.disabled.btn-grouped.hidden-xs.append-right-10 = icon('comment') @@ -55,8 +56,8 @@ %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author)) -.commit-info-widget - .widget-row.branch-info +.info-well + .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") %span.cgray= pluralize(@commit.parents.count, "parent") @@ -66,7 +67,7 @@ %i.fa.fa-spinner.fa-spin - if @commit.status - .widget-row.pipeline-info + .well-segment.pipeline-info .icon-container = ci_icon_for_status(@commit.status) Pipeline diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index 062a8905a19..1174158eb65 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -1,10 +1,6 @@ .pipeline-graph-container .row-content-block.build-content.middle-block.pipeline-actions .pull-right - %button.btn.btn-grouped.btn-white.toggle-pipeline-btn - %span.toggle-btn-text Hide - %span pipeline graph - %span.caret - if can?(current_user, :update_pipeline, pipeline.project) - if pipeline.builds.latest.failed.any?(&:retryable?) = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 34855c54176..12096941209 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -36,7 +36,6 @@ %pre.commit-row-description.js-toggle-content = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) - .commit-row-info - = commit_author_link(commit, avatar: false, size: 24) - authored - #{time_ago_with_tooltip(commit.committed_date)} + = commit_author_link(commit, avatar: false, size: 24) + authored + #{time_ago_with_tooltip(commit.committed_date)} diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 067cf595da3..ab4a2dc36e5 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -22,11 +22,12 @@ = render 'projects/diffs/warning', diff_files: diff_files .files{ data: { can_create_note: can_create_note } } - - diff_files.each_with_index do |diff_file, index| + - diff_files.each_with_index do |diff_file| - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - next unless blob - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw? + - file_hash = hexdigest(diff_file.file_path) - = render 'projects/diffs/file', index: index, project: diffs.project, + = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, diff_file: diff_file, diff_commit: diff_commit, blob: blob diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 8f4f9ad4a80..120ba9ffcd2 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,6 +1,6 @@ -.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file.file_path, diff_commit.id)} +.diff-file.file-holder{id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id)} .file-title{id: "file-path-#{hexdigest(diff_file.file_path)}"} - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{index}" + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}" - unless diff_file.submodule? .file-actions.hidden-xs diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index e751dabdf99..66d6254aa1e 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -9,28 +9,29 @@ %strong.cred #{diff_files.sum(&:removed_lines)} deletions .file-stats.js-toggle-content.hide %ul - - diff_files.each_with_index do |diff_file, i| + - diff_files.each do |diff_file| + - file_hash = hexdigest(diff_file.file_path) %li - if diff_file.deleted_file %span.deleted-file - %a{href: "#diff-#{i}"} + %a{href: "##{file_hash}"} %i.fa.fa-minus = diff_file.old_path - elsif diff_file.renamed_file %span.renamed-file - %a{href: "#diff-#{i}"} + %a{href: "##{file_hash}"} %i.fa.fa-minus = diff_file.old_path → = diff_file.new_path - elsif diff_file.new_file %span.new-file - %a{href: "#diff-#{i}"} + %a{href: "##{file_hash}"} %i.fa.fa-plus = diff_file.new_path - else %span.edit-file - %a{href: "#diff-#{i}"} + %a{href: "##{file_hash}"} %i.fa.fa-adjust = diff_file.new_path diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 12408068834..9ffcc48eb80 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -54,15 +54,18 @@ = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do = icon('code-fork') = merge_request.target_branch + - if merge_request.milestone = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do = icon('clock-o') = merge_request.milestone.title + - if merge_request.labels.any? - merge_request.labels.each do |label| = link_to_label(label, subject: merge_request.project, type: :merge_request) + - if merge_request.tasks? %span.task-status diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index d288efc546f..a0de125d765 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,39 +1,49 @@ -%p -.commit-info-row - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @pipeline.id), class: "monospace" - with - = pluralize @pipeline.statuses.count(:id), "build" - - if @pipeline.ref - for - = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - - .pull-right +.page-content-header + .header-main-content = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do = ci_icon_for_status(@pipeline.status) = ci_label_for_status(@pipeline.status) + %strong Pipeline ##{@commit.pipelines.last.id} + triggered #{time_ago_with_tooltip(@commit.authored_date)} by + = author_avatar(@commit, size: 24) + = commit_author_link(@commit) + .header-action-buttons + - if can?(current_user, :update_pipeline, @pipeline.project) + - if @pipeline.builds.latest.failed.any?(&:retryable?) + = link_to "Retry failed", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'btn btn-inverted-secondary', method: :post + - if @pipeline.builds.running_or_pending.any? + = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - if @commit - .commit-info-row - %span.light Authored by - %strong - = commit_author_link(@commit, avatar: true, size: 24) - #{time_ago_with_tooltip(@commit.authored_date)} - -.commit-info-row - %span.light Commit - = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace" - = clipboard_button(clipboard_text: @pipeline.sha) - -- if @commit - .commit-box.content-block + .commit-box %h3.commit-title = markdown(@commit.title, pipeline: :single_line) - if @commit.description.present? %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line)) + +.info-well + - if @commit.status + .well-segment.pipeline-info + .icon-container + = ci_icon_for_status(@commit.status) + = pluralize @pipeline.statuses.count(:id), "build" + - if @pipeline.ref + from + = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + + .well-segment.branch-info + .icon-container.commit-icon + = custom_icon("icon_commit") + = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short" + = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do + %span.text-expander + \... + %span.js-details-content.hide + = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full" + = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml new file mode 100644 index 00000000000..718314701f9 --- /dev/null +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -0,0 +1,51 @@ +.tabs-holder + %ul.nav-links.no-top.no-bottom + %li.active + = link_to "Pipeline", "#js-tab-pipeline", data: { target: '#js-tab-pipeline', action: 'pipeline', toggle: 'tab' }, class: 'pipeline-tab' + %li + = link_to "#js-tab-builds", data: { target: '#js-tab-builds', action: 'build', toggle: 'tab' }, class: 'builds-tab' do + Builds + %span.badge= pipeline.statuses.count + +.tab-content + #js-tab-pipeline.tab-pane.active + .build-content.middle-block.pipeline-graph + .pipeline-visualization + %ul.stage-column-list + - stages = pipeline.stages_with_latest_statuses + - stages.each do |stage, statuses| + %li.stage-column + .stage-name + %a{name: stage} + - if stage + = stage.titleize + .builds-container + %ul + = render "projects/commit/pipeline_stage", statuses: statuses + + #js-tab-builds.tab-pane + - if pipeline.yaml_errors.present? + .bs-callout.bs-callout-danger + %h4 Found errors in your .gitlab-ci.yml: + %ul + - pipeline.yaml_errors.split(",").each do |error| + %li= error + You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path} + + - if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file + .bs-callout.bs-callout-warning + \.gitlab-ci.yml not found in this commit + + .table-holder.pipeline-holder + %table.table.ci-table.pipeline + %thead + %tr + %th Status + %th Build ID + %th Name + %th + - if pipeline.project.build_coverage_enabled? + %th Coverage + %th + - pipeline.statuses.relevant.stages.each do |stage| + = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage) diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 688535ad764..8c6652a5f90 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -3,9 +3,7 @@ = render "projects/pipelines/head" %div{ class: container_class } - .prepend-top-default - - if @commit - = render "projects/pipelines/info" - %div.block-connector + - if @commit + = render "projects/pipelines/info" - = render "projects/commit/pipeline", pipeline: @pipeline + = render "projects/pipelines/with_tabs", pipeline: @pipeline diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 6ccdef0df46..db324d8868e 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -1,6 +1,7 @@ - label_css_id = dom_id(label) - open_issues_count = label.open_issues_count(current_user) - open_merge_requests_count = label.open_merge_requests_count(current_user) +- status = label_subscription_status(label, @project).inquiry if current_user - subject = local_assigns[:subject] %li{id: label_css_id, data: { id: label.id } } @@ -18,10 +19,19 @@ %li = link_to_label(label, subject: subject) do = pluralize open_issues_count, 'open issue' - - if current_user - %li.label-subscription{ data: toggle_subscription_data(label) } - %a.js-subscribe-button.label-subscribe-button.subscription-status{ role: "button", href: "#", data: { toggle: "tooltip", status: label_subscription_status(label) } } - %span= label_subscription_toggle_button_text(label) + - if current_user && defined?(@project) + %li.label-subscription + - if label.is_a?(ProjectLabel) + %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', data: { status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } } + %span= label_subscription_toggle_button_text(label, @project) + - else + %a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: group_label_unsubscribe_path(label, @project) } } + %span Unsubscribe + %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } } + %span Subscribe at project level + %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } } + %span Subscribe at group level + - if can?(current_user, :admin_label, label) %li = link_to 'Edit', edit_label_path(label) @@ -34,12 +44,27 @@ = link_to_label(label, subject: subject, css_class: 'btn btn-transparent btn-action') do = pluralize open_issues_count, 'open issue' - - if current_user - .label-subscription.inline{ data: toggle_subscription_data(label) } - %button.js-subscribe-button.label-subscribe-button.btn.btn-transparent.btn-action.subscription-status{ type: "button", title: label_subscription_toggle_button_text(label), data: { toggle: "tooltip", status: label_subscription_status(label) } } - %span.sr-only= label_subscription_toggle_button_text(label) - = icon('eye', class: 'label-subscribe-button-icon', disabled: label.is_a?(GroupLabel)) - = icon('spinner spin', class: 'label-subscribe-button-loading') + - if current_user && defined?(@project) + .label-subscription.inline + - if label.is_a?(ProjectLabel) + %button.js-subscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', title: label_subscription_toggle_button_text(label, @project), data: { toggle: 'tooltip', status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } } + %span= label_subscription_toggle_button_text(label, @project) + = icon('spinner spin', class: 'label-subscribe-button-loading') + - else + %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', class: ('hidden' if status.unsubscribed?), title: 'Unsubscribe', data: { toggle: 'tooltip', url: group_label_unsubscribe_path(label, @project) } } + %span Unsubscribe + = icon('spinner spin', class: 'label-subscribe-button-loading') + + .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} + %span Subscribe + = icon('chevron-down') + %ul.dropdown-menu + %li + %a.js-subscribe-button{ data: { url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } } + Project level + %a.js-subscribe-button{ data: { url: toggle_subscription_group_label_path(label.group, label) } } + Group level - if can?(current_user, :admin_label, label) = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do @@ -49,6 +74,10 @@ %span.sr-only Delete = icon('trash-o') - - if current_user && label.is_a?(ProjectLabel) - :javascript - new Subscription('##{dom_id(label)} .label-subscription'); + - if current_user && defined?(@project) + - if label.is_a?(ProjectLabel) + :javascript + new gl.ProjectLabelSubscription('##{dom_id(label)} .label-subscription'); + - else + :javascript + new gl.GroupLabelSubscription('##{dom_id(label)} .label-subscription'); diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 5254d265918..601ef51737a 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -10,26 +10,27 @@ .col-sm-10 = form.check_box :active -.form-group - = form.label :url, "Trigger", class: 'control-label' - - .col-sm-10 - - @service.supported_events.each do |event| - %div - = form.check_box service_event_field_name(event), class: 'pull-left' - .prepend-left-20 - = form.label service_event_field_name(event), class: 'list-label' do - %strong - = event.humanize - - - field = @service.event_field(event) - - - if field - %p - = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] - - %p.light - = service_event_description(event) +- if @service.supported_events.present? + .form-group + = form.label :url, "Trigger", class: 'control-label' + + .col-sm-10 + - @service.supported_events.each do |event| + %div + = form.check_box service_event_field_name(event), class: 'pull-left' + .prepend-left-20 + = form.label service_event_field_name(event), class: 'list-label' do + %strong + = event.humanize + + - field = @service.event_field(event) + + - if field + %p + = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] + + %p.light + = service_event_description(event) - @service.global_fields.each do |field| - type = field[:type] diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg index 3420af411f6..e0a2d4282f0 100644 --- a/app/views/shared/icons/_icon_status_skipped.svg +++ b/app/views/shared/icons/_icon_status_skipped.svg @@ -1 +1 @@ -<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Group Copy 31</title><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg> +<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg> diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 3bc57d3d2ac..bd66f39fa59 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -9,7 +9,7 @@   .dropdown-label-color-input .dropdown-label-color-preview.js-dropdown-label-color-preview - %input#new_label_color.default-dropdown-input{ type: "text" } + %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" } .clearfix %button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" } Create diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 7363ead09ff..f166fac105d 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -140,7 +140,7 @@ = render "shared/issuable/participants", participants: issuable.participants(current_user) - if current_user - - subscribed = issuable.subscribed?(current_user) + - subscribed = issuable.subscribed?(current_user, @project) .block.light.subscription{data: {url: toggle_subscription_path(issuable)}} .sidebar-collapsed-icon = icon('rss') diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index e0ad5268664..e17add7421f 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -4,15 +4,13 @@ class BuildSuccessWorker def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| - create_deployment(build) + create_deployment(build) if build.has_environment? end end private def create_deployment(build) - return if build.environment.blank? - service = CreateDeploymentService.new( build.project, build.user, environment: build.environment, diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 34f6ef161fb..070943f1ecc 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -12,11 +12,11 @@ class PipelineMetricsWorker private def update_metrics_for_active_pipeline(pipeline) - metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil) + metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil, pipeline_id: pipeline.id) end def update_metrics_for_succeeded_pipeline(pipeline) - metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at) + metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at, pipeline_id: pipeline.id) end def metrics(pipeline) diff --git a/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml new file mode 100644 index 00000000000..18fb8a6ad45 --- /dev/null +++ b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml @@ -0,0 +1,4 @@ +--- +title: Make New Group form respect default visibility application setting +merge_request: 7454 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/22680-unlabel-limit-autocomplete-to-selected-items.yml b/changelogs/unreleased/22680-unlabel-limit-autocomplete-to-selected-items.yml deleted file mode 100644 index 95fd07c12e1..00000000000 --- a/changelogs/unreleased/22680-unlabel-limit-autocomplete-to-selected-items.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Limit autocomplete to currently selected items for unlabel slash command -merge_request: 22680 -author: Akram Fares diff --git a/changelogs/unreleased/23223-group-deletion-race-condition.yml b/changelogs/unreleased/23223-group-deletion-race-condition.yml new file mode 100644 index 00000000000..6f22e85fb4b --- /dev/null +++ b/changelogs/unreleased/23223-group-deletion-race-condition.yml @@ -0,0 +1,4 @@ +--- +title: Fix race condition during group deletion and remove stale records present due to this bug +merge_request: 7528 +author: Timothy Andrew diff --git a/changelogs/unreleased/23637-title-bar-pipelines.yml b/changelogs/unreleased/23637-title-bar-pipelines.yml new file mode 100644 index 00000000000..3d4cf88c54c --- /dev/null +++ b/changelogs/unreleased/23637-title-bar-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Redesign pipelines page +merge_request: +author: diff --git a/changelogs/unreleased/23824-activity-page-does-not-show-commits-comments.yml b/changelogs/unreleased/23824-activity-page-does-not-show-commits-comments.yml deleted file mode 100644 index 48f733f9c5e..00000000000 --- a/changelogs/unreleased/23824-activity-page-does-not-show-commits-comments.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Allow commit note to be visible if repo is visible -merge_request: -author: diff --git a/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml b/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml new file mode 100644 index 00000000000..8d4593d4df7 --- /dev/null +++ b/changelogs/unreleased/23990-project-show-error-when-empty-repo.yml @@ -0,0 +1,4 @@ +--- +title: fixes 500 error on project show when user is not logged in and project is still empty +merge_request: 7376 +author: diff --git a/changelogs/unreleased/24010-change-anchor-link-to-mr-diff.yml b/changelogs/unreleased/24010-change-anchor-link-to-mr-diff.yml new file mode 100644 index 00000000000..33ce18b2141 --- /dev/null +++ b/changelogs/unreleased/24010-change-anchor-link-to-mr-diff.yml @@ -0,0 +1,4 @@ +--- +title: Unify anchor link format for MR diff files +merge_request: 7298 +author: YarNayar diff --git a/changelogs/unreleased/24010-double-event-trigger.yml b/changelogs/unreleased/24010-double-event-trigger.yml new file mode 100644 index 00000000000..3c2f20d391f --- /dev/null +++ b/changelogs/unreleased/24010-double-event-trigger.yml @@ -0,0 +1,4 @@ +--- +title: Fix double event and ajax request call on MR page +merge_request: 7298 +author: YarNayar diff --git a/changelogs/unreleased/24038-fix-no-register-pane-if-ldap.yml b/changelogs/unreleased/24038-fix-no-register-pane-if-ldap.yml deleted file mode 100644 index 53f418b6b18..00000000000 --- a/changelogs/unreleased/24038-fix-no-register-pane-if-ldap.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix no "Register" tab if ldap auth is enabled (#24038) -merge_request: 7274 -author: Luc Didry diff --git a/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml b/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml new file mode 100644 index 00000000000..2c265960d67 --- /dev/null +++ b/changelogs/unreleased/24072-improve-importing-of-github-pull-requests.yml @@ -0,0 +1,4 @@ +--- +title: Reduce API calls needed when importing issues and pull requests from GitHub +merge_request: 7241 +author: Andrew Smith (EspadaV8) diff --git a/changelogs/unreleased/24107-slack-comment-link.yml b/changelogs/unreleased/24107-slack-comment-link.yml new file mode 100644 index 00000000000..9c17d6fd825 --- /dev/null +++ b/changelogs/unreleased/24107-slack-comment-link.yml @@ -0,0 +1,4 @@ +--- +title: Change slack notification comment link +merge_request: 7498 +author: Herbert Kagumba diff --git a/changelogs/unreleased/24276-usernames-with-dots.yml b/changelogs/unreleased/24276-usernames-with-dots.yml new file mode 100644 index 00000000000..9aeeb33e9ef --- /dev/null +++ b/changelogs/unreleased/24276-usernames-with-dots.yml @@ -0,0 +1,4 @@ +--- +title: Allow registering users whose username contains dots +merge_request: 7500 +author: Timothy Andrew diff --git a/changelogs/unreleased/24397-load-labels-on-mr-tabs.yml b/changelogs/unreleased/24397-load-labels-on-mr-tabs.yml deleted file mode 100644 index 6bfa7fa1a49..00000000000 --- a/changelogs/unreleased/24397-load-labels-on-mr-tabs.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix issue causing Labels not to appear in sidebar on MR page -merge_request: 7416 -author: Alex Sanford diff --git a/changelogs/unreleased/add-chat-names.yml b/changelogs/unreleased/add-chat-names.yml new file mode 100644 index 00000000000..6a1e05783a3 --- /dev/null +++ b/changelogs/unreleased/add-chat-names.yml @@ -0,0 +1,4 @@ +--- +title: Allow to connect Chat account with GitLab +merge_request: 7450 +author: diff --git a/changelogs/unreleased/assignee-dropdown-autocomplete.yml b/changelogs/unreleased/assignee-dropdown-autocomplete.yml new file mode 100644 index 00000000000..9d046b726b7 --- /dev/null +++ b/changelogs/unreleased/assignee-dropdown-autocomplete.yml @@ -0,0 +1,4 @@ +--- +title: Assignee dropdown now searches author of issue or merge request +merge_request: +author: diff --git a/changelogs/unreleased/bugfix-html-only-mail.yml b/changelogs/unreleased/bugfix-html-only-mail.yml new file mode 100644 index 00000000000..ea0d4e7396f --- /dev/null +++ b/changelogs/unreleased/bugfix-html-only-mail.yml @@ -0,0 +1,4 @@ +--- +title: Add support for reply-by-email when the email only contains HTML +merge_request: 7397 +author: diff --git a/changelogs/unreleased/changelog-update.yml b/changelogs/unreleased/changelog-update.yml new file mode 100644 index 00000000000..24fa34f4121 --- /dev/null +++ b/changelogs/unreleased/changelog-update.yml @@ -0,0 +1,4 @@ +--- +title: Add environment info to builds page +merge_request: +author: diff --git a/changelogs/unreleased/feature-cycle-analytics-events.yml b/changelogs/unreleased/feature-cycle-analytics-events.yml new file mode 100644 index 00000000000..e1211a3c774 --- /dev/null +++ b/changelogs/unreleased/feature-cycle-analytics-events.yml @@ -0,0 +1,4 @@ +--- +title: Add events per stage to cycle analytics +merge_request: +author: diff --git a/changelogs/unreleased/feature-environment-teardown-when-branch-deleted.yml b/changelogs/unreleased/feature-environment-teardown-when-branch-deleted.yml new file mode 100644 index 00000000000..0441b68e45f --- /dev/null +++ b/changelogs/unreleased/feature-environment-teardown-when-branch-deleted.yml @@ -0,0 +1,4 @@ +--- +title: Auto-close environment when branch is deleted +merge_request: 7355 +author: diff --git a/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml b/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml new file mode 100644 index 00000000000..ea336716dce --- /dev/null +++ b/changelogs/unreleased/feature-subscribe-to-group-level-labels.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to subscribe to group labels +merge_request: 7215 +author: diff --git a/changelogs/unreleased/fix-admin-ci-table.yml b/changelogs/unreleased/fix-admin-ci-table.yml new file mode 100644 index 00000000000..9a9e39ee94a --- /dev/null +++ b/changelogs/unreleased/fix-admin-ci-table.yml @@ -0,0 +1,4 @@ +--- +title: Fix misaligned buttons on admin builds page +merge_request: 7424 +author: Didem Acet diff --git a/changelogs/unreleased/fix-cache-for-commit-status.yml b/changelogs/unreleased/fix-cache-for-commit-status.yml deleted file mode 100644 index eb4e96e75ae..00000000000 --- a/changelogs/unreleased/fix-cache-for-commit-status.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix cache for commit status in commits list to respect branches -merge_request: 7372 -author: diff --git a/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml b/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml new file mode 100644 index 00000000000..e4cf8de8699 --- /dev/null +++ b/changelogs/unreleased/fix-singin-redirect-for-fork-new.yml @@ -0,0 +1,5 @@ +--- +title: Fixing the issue of the project fork url giving 500 when not signed instead + of being redirected to sign in page +merge_request: +author: Cagdas Gerede diff --git a/changelogs/unreleased/fix-uncheckable-label-for-force_remove_source_branch.yml b/changelogs/unreleased/fix-uncheckable-label-for-force_remove_source_branch.yml deleted file mode 100644 index 8b41063151b..00000000000 --- a/changelogs/unreleased/fix-uncheckable-label-for-force_remove_source_branch.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Clicking "force remove source branch" label now toggles the checkbox again -merge_request: -author: diff --git a/changelogs/unreleased/fix_saml_ldap_link.yml b/changelogs/unreleased/fix_saml_ldap_link.yml deleted file mode 100644 index 3b6f26d610e..00000000000 --- a/changelogs/unreleased/fix_saml_ldap_link.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Omniauth auto link LDAP user falls back to find by DN when user cannot be found - by UID -merge_request: 7002 -author: diff --git a/changelogs/unreleased/issue-24512.yml b/changelogs/unreleased/issue-24512.yml new file mode 100644 index 00000000000..a3a9bd9c3d1 --- /dev/null +++ b/changelogs/unreleased/issue-24512.yml @@ -0,0 +1,4 @@ +--- +title: Add placeholder for the example text for custom hex color on label creation popup +merge_request: +author: Luis Alonso Chavez Armendariz diff --git a/changelogs/unreleased/issue_13232.yml b/changelogs/unreleased/issue_13232.yml new file mode 100644 index 00000000000..6dc2de5afe4 --- /dev/null +++ b/changelogs/unreleased/issue_13232.yml @@ -0,0 +1,4 @@ +--- +title: Add JIRA remotelinks and prevent duplicated closing messages +merge_request: +author: diff --git a/changelogs/unreleased/issue_20245.yml b/changelogs/unreleased/issue_20245.yml deleted file mode 100644 index e5d09d85683..00000000000 --- a/changelogs/unreleased/issue_20245.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix project Visibility Level selector not using default values -merge_request: -author: diff --git a/changelogs/unreleased/jira_service_simplify.yml b/changelogs/unreleased/jira_service_simplify.yml new file mode 100644 index 00000000000..51cedd8ce5e --- /dev/null +++ b/changelogs/unreleased/jira_service_simplify.yml @@ -0,0 +1,4 @@ +--- +title: simplify url generation +merge_request: +author: Jarka Kadlecova diff --git a/changelogs/unreleased/mailroom_idle_timeout.yml b/changelogs/unreleased/mailroom_idle_timeout.yml new file mode 100644 index 00000000000..276b28a56dd --- /dev/null +++ b/changelogs/unreleased/mailroom_idle_timeout.yml @@ -0,0 +1,4 @@ +--- +title: Allow mail_room idle_timeout option to be configurable +merge_request: 7423 +author: diff --git a/changelogs/unreleased/namespace-validation.yml b/changelogs/unreleased/namespace-validation.yml new file mode 100644 index 00000000000..6ac461bf82e --- /dev/null +++ b/changelogs/unreleased/namespace-validation.yml @@ -0,0 +1,4 @@ +--- +title: Check all namespaces on validation of new username. +merge_request: 7537 +author: diff --git a/changelogs/unreleased/optimize-mr-index.yml b/changelogs/unreleased/optimize-mr-index.yml new file mode 100644 index 00000000000..1090b6d4528 --- /dev/null +++ b/changelogs/unreleased/optimize-mr-index.yml @@ -0,0 +1,4 @@ +--- +title: More aggressively preload on merge request and issue index pages +merge_request: +author: diff --git a/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml new file mode 100644 index 00000000000..5e868027ed6 --- /dev/null +++ b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml @@ -0,0 +1,4 @@ +--- +title: Pass correct tag target to post-receive hook when creating tag via UI +merge_request: 7556 +author: diff --git a/changelogs/unreleased/rs-issue-24527.yml b/changelogs/unreleased/rs-issue-24527.yml deleted file mode 100644 index a7b6358e60e..00000000000 --- a/changelogs/unreleased/rs-issue-24527.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Limit labels returned for a specific project as an administrator -merge_request: 7496 -author: diff --git a/changelogs/unreleased/sort-api-groups.yml b/changelogs/unreleased/sort-api-groups.yml new file mode 100644 index 00000000000..e3eead8c04f --- /dev/null +++ b/changelogs/unreleased/sort-api-groups.yml @@ -0,0 +1,4 @@ +--- +title: Allow sorting groups in the API +merge_request: +author: diff --git a/changelogs/unreleased/zj-slash-commands-mattermost.yml b/changelogs/unreleased/zj-slash-commands-mattermost.yml new file mode 100644 index 00000000000..996ffe954f3 --- /dev/null +++ b/changelogs/unreleased/zj-slash-commands-mattermost.yml @@ -0,0 +1,4 @@ +--- +title: Added Mattermost slash command +merge_request: 7438 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 699ab6075b6..327e4a7937c 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -138,6 +138,8 @@ production: &base # The mailbox where incoming mail will end up. Usually "inbox". mailbox: "inbox" + # The IDLE command timeout. + idle_timeout: 60 ## Build Artifacts artifacts: diff --git a/config/mail_room.yml b/config/mail_room.yml index b026d510f1b..774c5350a45 100644 --- a/config/mail_room.yml +++ b/config/mail_room.yml @@ -15,7 +15,7 @@ :start_tls: <%= config[:start_tls].to_json %> :email: <%= config[:user].to_json %> :password: <%= config[:password].to_json %> - :idle_timeout: 60 + :idle_timeout: <%= config[:idle_timeout].to_json %> :name: <%= config[:mailbox].to_json %> diff --git a/config/no_todos_messages.yml b/config/no_todos_messages.yml index 8372fb4ebe9..264a975b614 100644 --- a/config/no_todos_messages.yml +++ b/config/no_todos_messages.yml @@ -1,13 +1,11 @@ -# When the Todos list on the user's dashboard becomes empty, one of the messages below shows up randomly. +# When the todo list on the user's dashboard becomes empty, a random message +# from the list below will be shown. # # If you come up with a fun one, please feel free to contribute it to GitLab! # https://about.gitlab.com/contributing/ - --- - Good job! Looks like you don't have any todos left. -- Coffee really tastes better without any todos left. -- Isn't an empty To Do list beautiful? -- Time for a rewarding coffee break +- Isn't an empty todo list beautiful? - Give yourself a pat on the back! -- High five! -- Hence forth you shall be known as 'Todo Destroyer'
\ No newline at end of file +- Nothing left to do, high five! +- Henceforth you shall be known as "Todo Destroyer". diff --git a/config/routes/group.rb b/config/routes/group.rb index 3c392f77ef6..068e0b6e843 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -30,7 +30,10 @@ scope(path: 'groups/:group_id', module: :groups, as: :group) do resource :avatar, only: [:destroy] resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] - resources :labels, except: [:show], constraints: { id: /\d+/ } + + resources :labels, except: [:show], constraints: { id: /\d+/ } do + post :toggle_subscription, on: :member + end end # Must be last route in this file diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 52b9a565db8..6b91485da9e 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -23,6 +23,12 @@ resource :profile, only: [:show, :update] do resource :preferences, only: [:show, :update] resources :keys, only: [:index, :show, :new, :create, :destroy] resources :emails, only: [:index, :create, :destroy] + resources :chat_names, only: [:index, :new, :create, :destroy] do + collection do + delete :deny + end + end + resource :avatar, only: [:destroy] resources :personal_access_tokens, only: [:index, :create] do diff --git a/config/routes/project.rb b/config/routes/project.rb index 9cf8465dca8..d6eae1c9fce 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -153,6 +153,18 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: resource :cycle_analytics, only: [:show] + namespace :cycle_analytics do + scope :events, controller: 'events' do + get :issue + get :plan + get :code + get :test + get :review + get :staging + get :production + end + end + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do post :cancel_all diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index e882a492757..916ee8dbac8 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -203,6 +203,8 @@ class Gitlab::Seeder::CycleAnalytics pipeline.run! Timecop.travel rand(1..6).hours.from_now pipeline.succeed! + + PipelineMetricsWorker.new.perform(pipeline.id) end end diff --git a/db/fixtures/test/001_repo.rb b/db/fixtures/test/001_repo.rb deleted file mode 100644 index e69de29bb2d..00000000000 --- a/db/fixtures/test/001_repo.rb +++ /dev/null diff --git a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb new file mode 100644 index 00000000000..f49df6802a7 --- /dev/null +++ b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb @@ -0,0 +1,33 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPipelineIdToMergeRequestMetrics < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Adding a foreign key' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :merge_request_metrics, :pipeline_id, :integer + add_concurrent_index :merge_request_metrics, :pipeline_id + add_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id, on_delete: :cascade + end +end diff --git a/db/migrate/20161031171301_add_project_id_to_subscriptions.rb b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb new file mode 100644 index 00000000000..97534679b59 --- /dev/null +++ b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb @@ -0,0 +1,14 @@ +class AddProjectIdToSubscriptions < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_column :subscriptions, :project_id, :integer + add_foreign_key :subscriptions, :projects, column: :project_id, on_delete: :cascade + end + + def down + remove_column :subscriptions, :project_id + end +end diff --git a/db/migrate/20161031174110_migrate_subscriptions_project_id.rb b/db/migrate/20161031174110_migrate_subscriptions_project_id.rb new file mode 100644 index 00000000000..549145a0a65 --- /dev/null +++ b/db/migrate/20161031174110_migrate_subscriptions_project_id.rb @@ -0,0 +1,44 @@ +class MigrateSubscriptionsProjectId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + DOWNTIME_REASON = 'Subscriptions will not work as expected until this migration is complete.' + + def up + execute <<-EOF.strip_heredoc + UPDATE subscriptions + SET project_id = ( + SELECT issues.project_id + FROM issues + WHERE issues.id = subscriptions.subscribable_id + ) + WHERE subscriptions.subscribable_type = 'Issue'; + EOF + + execute <<-EOF.strip_heredoc + UPDATE subscriptions + SET project_id = ( + SELECT merge_requests.target_project_id + FROM merge_requests + WHERE merge_requests.id = subscriptions.subscribable_id + ) + WHERE subscriptions.subscribable_type = 'MergeRequest'; + EOF + + execute <<-EOF.strip_heredoc + UPDATE subscriptions + SET project_id = ( + SELECT projects.id + FROM labels INNER JOIN projects ON projects.id = labels.project_id + WHERE labels.id = subscriptions.subscribable_id + ) + WHERE subscriptions.subscribable_type = 'Label'; + EOF + end + + def down + execute <<-EOF.strip_heredoc + UPDATE subscriptions SET project_id = NULL; + EOF + end +end diff --git a/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb b/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb new file mode 100644 index 00000000000..4b1b29e1265 --- /dev/null +++ b/db/migrate/20161031181638_add_unique_index_to_subscriptions.rb @@ -0,0 +1,18 @@ +class AddUniqueIndexToSubscriptions < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + DOWNTIME_REASON = 'This migration requires downtime because it changes a column to not accept null values.' + + disable_ddl_transaction! + + def up + add_concurrent_index :subscriptions, [:subscribable_id, :subscribable_type, :user_id, :project_id], { unique: true, name: 'index_subscriptions_on_subscribable_and_user_id_and_project_id' } + remove_index :subscriptions, name: 'subscriptions_user_id_and_ref_fields' if index_name_exists?(:subscriptions, 'subscriptions_user_id_and_ref_fields', false) + end + + def down + add_concurrent_index :subscriptions, [:subscribable_id, :subscribable_type, :user_id], { unique: true, name: 'subscriptions_user_id_and_ref_fields' } + remove_index :subscriptions, name: 'index_subscriptions_on_subscribable_and_user_id_and_project_id' if index_name_exists?(:subscriptions, 'index_subscriptions_on_subscribable_and_user_id_and_project_id', false) + end +end diff --git a/db/migrate/20161113184239_create_user_chat_names_table.rb b/db/migrate/20161113184239_create_user_chat_names_table.rb new file mode 100644 index 00000000000..97b597654f7 --- /dev/null +++ b/db/migrate/20161113184239_create_user_chat_names_table.rb @@ -0,0 +1,21 @@ +class CreateUserChatNamesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :chat_names do |t| + t.integer :user_id, null: false + t.integer :service_id, null: false + t.string :team_id, null: false + t.string :team_domain + t.string :chat_id, null: false + t.string :chat_name + t.datetime :last_used_at + t.timestamps null: false + end + + add_index :chat_names, [:user_id, :service_id], unique: true + add_index :chat_names, [:service_id, :team_id, :chat_id], unique: true + end +end diff --git a/db/migrate/20161117114805_remove_undeleted_groups.rb b/db/migrate/20161117114805_remove_undeleted_groups.rb new file mode 100644 index 00000000000..ebc2d974ae0 --- /dev/null +++ b/db/migrate/20161117114805_remove_undeleted_groups.rb @@ -0,0 +1,16 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveUndeletedGroups < ActiveRecord::Migration + DOWNTIME = false + + def up + execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;" + end + + def down + # This is an irreversible migration; + # If someone is trying to rollback for other reasons, we should not throw an Exception. + # raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/schema.rb b/db/schema.rb index ed4dfc786f6..19bd6b63acb 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: 20161109150329) do +ActiveRecord::Schema.define(version: 20161117114805) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -152,6 +152,21 @@ ActiveRecord::Schema.define(version: 20161109150329) do t.text "message_html" end + create_table "chat_names", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "service_id", null: false + t.string "team_id", null: false + t.string "team_domain" + t.string "chat_id", null: false + t.string "chat_name" + t.datetime "last_used_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "chat_names", ["service_id", "team_id", "chat_id"], name: "index_chat_names_on_service_id_and_team_id_and_chat_id", unique: true, using: :btree + add_index "chat_names", ["user_id", "service_id"], name: "index_chat_names_on_user_id_and_service_id", unique: true, using: :btree + create_table "ci_application_settings", force: :cascade do |t| t.boolean "all_broken_builds" t.boolean "add_pusher" @@ -634,10 +649,12 @@ ActiveRecord::Schema.define(version: 20161109150329) do t.datetime "merged_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "pipeline_id" end add_index "merge_request_metrics", ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at", using: :btree add_index "merge_request_metrics", ["merge_request_id"], name: "index_merge_request_metrics", using: :btree + add_index "merge_request_metrics", ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id", using: :btree create_table "merge_requests", force: :cascade do |t| t.string "target_branch", null: false @@ -1052,9 +1069,10 @@ ActiveRecord::Schema.define(version: 20161109150329) do t.boolean "subscribed" t.datetime "created_at" t.datetime "updated_at" + t.integer "project_id" end - add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id"], name: "subscriptions_user_id_and_ref_fields", unique: true, using: :btree + add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree create_table "taggings", force: :cascade do |t| t.integer "tag_id" @@ -1244,12 +1262,14 @@ ActiveRecord::Schema.define(version: 20161109150329) do add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "lists", "boards" add_foreign_key "lists", "labels" + add_foreign_key "merge_request_metrics", "ci_commits", column: "pipeline_id", on_delete: :cascade add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade add_foreign_key "personal_access_tokens", "users" add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" + add_foreign_key "subscriptions", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" end diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index f9bc4f59345..f532a106bc6 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -30,38 +30,6 @@ Omnibus GitLab packages. [Available configuration setups](#available-configuration-setups) section below. -<!-- START doctoc generated TOC please keep comment here to allow auto update --> -<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> -**Table of Contents** - -- [Overview](#overview) - - [High Availability with Sentinel](#high-availability-with-sentinel) - - [Recommended setup](#recommended-setup) - - [Redis setup overview](#redis-setup-overview) - - [Sentinel setup overview](#sentinel-setup-overview) - - [Available configuration setups](#available-configuration-setups) -- [Configuring Redis HA](#configuring-redis-ha) - - [Prerequisites](#prerequisites) - - [Step 1. Configuring the master Redis instance](#step-1-configuring-the-master-redis-instance) - - [Step 2. Configuring the slave Redis instances](#step-2-configuring-the-slave-redis-instances) - - [Step 3. Configuring the Redis Sentinel instances](#step-3-configuring-the-redis-sentinel-instances) - - [Step 4. Configuring the GitLab application](#step-4-configuring-the-gitlab-application) -- [Switching from an existing single-machine installation to Redis HA](#switching-from-an-existing-single-machine-installation-to-redis-ha) -- [Example of a minimal configuration with 1 master, 2 slaves and 3 Sentinels](#example-of-a-minimal-configuration-with-1-master-2-slaves-and-3-sentinels) - - [Example configuration for Redis master and Sentinel 1](#example-configuration-for-redis-master-and-sentinel-1) - - [Example configuration for Redis slave 1 and Sentinel 2](#example-configuration-for-redis-slave-1-and-sentinel-2) - - [Example configuration for Redis slave 2 and Sentinel 3](#example-configuration-for-redis-slave-2-and-sentinel-3) - - [Example configuration for the GitLab application](#example-configuration-for-the-gitlab-application) -- [Advanced configuration](#advanced-configuration) - - [Control running services](#control-running-services) -- [Troubleshooting](#troubleshooting) - - [Troubleshooting Redis replication](#troubleshooting-redis-replication) - - [Troubleshooting Sentinel](#troubleshooting-sentinel) -- [Changelog](#changelog) -- [Further reading](#further-reading) - -<!-- END doctoc generated TOC please keep comment here to allow auto update --> - ## Overview Before diving into the details of setting up Redis and Redis Sentinel for HA, @@ -107,10 +75,12 @@ to help keep servers online with minimal to no downtime. Redis Sentinel: - Promotes a **Slave** to **Master** when the **Master** fails - Demotes a **Master** to **Slave** when the failed **Master** comes back online (to prevent data-partitioning) -- Can be queried by clients to always connect to the current **Master** server +- Can be queried by the application to always connect to the current **Master** + server -When a **Master** fails to respond, it's the client's responsibility to handle -timeout and reconnect (querying a **Sentinel** for a new **Master**). +When a **Master** fails to respond, it's the application's responsibility +(in our case GitLab) to handle timeout and reconnect (querying a **Sentinel** +for a new **Master**). To get a better understanding on how to correctly setup Sentinel, please read the [Redis Sentinel documentation](http://redis.io/topics/sentinel) first, as @@ -289,12 +259,7 @@ The prerequisites for a HA Redis setup are the following: ### Step 1. Configuring the master Redis instance -1. SSH into the **master** Redis server and login as root: - - ``` - sudo -i - ``` - +1. SSH into the **master** Redis server. 1. [Download/install](https://about.gitlab.com/installation) the Omnibus GitLab package you want using **steps 1 and 2** from the GitLab downloads page. - Make sure you select the correct Omnibus package, with the same version @@ -334,12 +299,7 @@ The prerequisites for a HA Redis setup are the following: ### Step 2. Configuring the slave Redis instances -1. SSH into the **slave** Redis server and login as root: - - ``` - sudo -i - ``` - +1. SSH into the **slave** Redis server. 1. [Download/install](https://about.gitlab.com/installation) the Omnibus GitLab package you want using **steps 1 and 2** from the GitLab downloads page. - Make sure you select the correct Omnibus package, with the same version @@ -417,12 +377,7 @@ multiple machines with the Sentinel daemon. --- -1. SSH into the server that will host Redis Sentinel and login as root: - - ``` - sudo -i - ``` - +1. SSH into the server that will host Redis Sentinel. 1. **You can omit this step if the Sentinels will be hosted in the same node as the other Redis instances.** @@ -437,7 +392,6 @@ multiple machines with the Sentinel daemon. Sentinels in the same node as the other Redis instances, some values might be duplicate below): - ```ruby redis_sentinel_role['enable'] = true @@ -530,6 +484,7 @@ it needs to access at least one of the listed. The following steps should be performed in the [GitLab application server](gitlab.md) which ideally should not have Redis or Sentinels on it for a HA setup. +1. SSH into the server where the GitLab application is installed. 1. Edit `/etc/gitlab/gitlab.rb` and add/change the following lines: ``` diff --git a/doc/administration/high_availability/redis_source.md b/doc/administration/high_availability/redis_source.md index 8558ba82d63..3629772b8af 100644 --- a/doc/administration/high_availability/redis_source.md +++ b/doc/administration/high_availability/redis_source.md @@ -17,27 +17,6 @@ If you're not sure whether this guide is for you, please refer to [Available configuration setups](redis.md#available-configuration-setups) in the Omnibus Redis HA documentation. ---- - -<!-- START doctoc generated TOC please keep comment here to allow auto update --> -<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> -**Table of Contents** - -- [Configuring your own Redis server](#configuring-your-own-redis-server) - - [Prerequisites](#prerequisites) - - [Step 1. Configuring the master Redis instance](#step-1-configuring-the-master-redis-instance) - - [Step 2. Configuring the slave Redis instances](#step-2-configuring-the-slave-redis-instances) - - [Step 3. Configuring the Redis Sentinel instances](#step-3-configuring-the-redis-sentinel-instances) - - [Step 4. Configuring the GitLab application](#step-4-configuring-the-gitlab-application) -- [Example of minimal configuration with 1 master, 2 slaves and 3 Sentinels](#example-of-minimal-configuration-with-1-master-2-slaves-and-3-sentinels) - - [Example configuration for Redis master and Sentinel 1](#example-configuration-for-redis-master-and-sentinel-1) - - [Example configuration for Redis slave 1 and Sentinel 2](#example-configuration-for-redis-slave-1-and-sentinel-2) - - [Example configuration for Redis slave 2 and Sentinel 3](#example-configuration-for-redis-slave-2-and-sentinel-3) - - [Example configuration of the GitLab application](#example-configuration-of-the-gitlab-application) -- [Troubleshooting](#troubleshooting) - -<!-- END doctoc generated TOC please keep comment here to allow auto update --> - ## Configuring your own Redis server This is the section where we install and setup the new Redis instances. diff --git a/doc/administration/reply_by_email.md b/doc/administration/reply_by_email.md index 5a9a1582877..14cd7a03826 100644 --- a/doc/administration/reply_by_email.md +++ b/doc/administration/reply_by_email.md @@ -105,6 +105,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow # The mailbox where incoming mail will end up. Usually "inbox". gitlab_rails['incoming_email_mailbox_name'] = "inbox" + # The IDLE command timeout. + gitlab_rails['incoming_email_idle_timeout'] = 60 ``` ```ruby @@ -133,6 +135,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow # The mailbox where incoming mail will end up. Usually "inbox". gitlab_rails['incoming_email_mailbox_name'] = "inbox" + # The IDLE command timeout. + gitlab_rails['incoming_email_idle_timeout'] = 60 ``` 1. Reconfigure GitLab and restart mailroom for the changes to take effect: @@ -192,6 +196,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow # The mailbox where incoming mail will end up. Usually "inbox". mailbox: "inbox" + # The IDLE command timeout. + idle_timeout: 60 ``` ```yaml @@ -221,6 +227,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow # The mailbox where incoming mail will end up. Usually "inbox". mailbox: "inbox" + # The IDLE command timeout. + idle_timeout: 60 ``` 1. Enable `mail_room` in the init script at `/etc/default/gitlab`: @@ -277,6 +285,8 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow # The mailbox where incoming mail will end up. Usually "inbox". mailbox: "inbox" + # The IDLE command timeout. + idle_timeout: 60 ``` As mentioned, the part after `+` is ignored, and this will end up in the mailbox for `gitlab-incoming@gmail.com`. diff --git a/doc/api/groups.md b/doc/api/groups.md index 45a3118f27a..5e6f498c365 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -6,8 +6,13 @@ Get a list of groups. (As user: my groups or all available, as admin: all groups Parameters: -- `all_available` (optional) - if passed, show all groups you have access to -- `skip_groups` (optional)(array of group IDs) - if passed, skip groups +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `skip_groups` | array of integers | no | Skip the group IDs passes | +| `all_available` | boolean | no | Show all the groups you have access to | +| `search` | string | no | Return list of authorized groups matching the search criteria | +| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` | +| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` | ``` GET /groups diff --git a/doc/api/projects.md b/doc/api/projects.md index bbb3bfb4995..467a880ac13 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -850,7 +850,7 @@ POST /projects/:id/archive | `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/archive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/archive" ``` Example response: @@ -939,7 +939,7 @@ POST /projects/:id/unarchive | `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/unarchive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/unarchive" ``` Example response: diff --git a/doc/ci/README.md b/doc/ci/README.md index 6b90940c047..545cc72682d 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -1,6 +1,6 @@ -## GitLab CI Documentation +# GitLab CI Documentation -### CI User documentation +## CI User documentation - [Get started with GitLab CI](quick_start/README.md) - [CI examples for various languages](examples/README.md) @@ -20,4 +20,8 @@ - [API](../api/ci/README.md) - [CI services (linked docker containers)](services/README.md) - [CI/CD pipelines settings](../user/project/pipelines/settings.md) -- [**New CI build permissions model**](../user/project/new_ci_build_permissions_model.md) Read about what changed in GitLab 8.12 and how that affects your builds. There's a new way to access your Git submodules and LFS objects in builds. +- [Review Apps](review_apps/index.md) + +## Breaking changes + +- [New CI build permissions model](../user/project/new_ci_build_permissions_model.md) Read about what changed in GitLab 8.12 and how that affects your builds. There's a new way to access your Git submodules and LFS objects in builds. diff --git a/doc/ci/environments.md b/doc/ci/environments.md index e070302fb82..096c567c992 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -3,69 +3,523 @@ >**Note:** Introduced in GitLab 8.9. -## Environments +During the development of software, there can be many stages until it's ready +for public consumption. You sure want to first test your code and then deploy it +in a testing or staging environment before you release it to the public. That +way you can prevent bugs not only in your software, but in the deployment +process as well. -Environments are places where code gets deployed, such as staging or production. -CI/CD [Pipelines] usually have one or more [jobs] that deploy to an environment. -Defining environments in a project's `.gitlab-ci.yml` lets developers track -[deployments] to these environments. +GitLab CI is capable of not only testing or building your projects, but also +deploying them in your infrastructure, with the added benefit of giving you a +way to track your deployments. In other words, you can always know what is +currently being deployed or has been deployed on your servers. -## Deployments +## Overview -Deployments are created when [jobs] deploy versions of code to [environments]. +With environments, you can control the Continuous Deployment of your software +all within GitLab. All you need to do is define them in your project's +[`.gitlab-ci.yml`][yaml] as we will explore below. GitLab provides a full +history of your deployments per every environment. -### Checkout deployments locally +Environments are like tags for your CI jobs, describing where code gets deployed. +Deployments are created when [jobs] deploy versions of code to environments, +so every environment can have one or more deployments. GitLab keeps track of +your deployments, so you always know what is currently being deployed on your +servers. -Since 8.13, a reference in the git repository is saved for each deployment. So -knowing what the state is of your current environments is only a `git fetch` -away. +To better understand how environments and deployments work, let's consider an +example. We assume that you have already created a project in GitLab and set up +a Runner. The example will cover the following: -In your git config, append the `[remote "<your-remote>"]` block with an extra -fetch line: +- We are developing an application +- We want to run tests and build our app on all branches +- Our default branch is `master` +- We deploy the app only when a pipeline on `master` branch is run + +Let's see how it all ties together. + +## Defining environments +Let's consider the following `.gitlab-ci.yml` example: + +```yaml +stages: + - test + - build + - deploy + +test: + stage: test + script: echo "Running tests" + +build: + stage: build + script: echo "Building the app" + +deploy_staging: + stage: deploy + script: + - echo "Deploy to staging server" + environment: + name: staging + url: https://staging.example.com + only: + - master ``` -fetch = +refs/environments/*:refs/remotes/origin/environments/* + +We have defined 3 [stages](yaml/README.md#stages): + +- test +- build +- deploy + +The jobs assigned to these stages will run in this order. If a job fails, then +the builds that are assigned to the next stage won't run, rendering the pipeline +as failed. In our case, the `test` job will run first, then the `build` and +lastly the `deploy_staging`. With this, we ensure that first the tests pass, +then our app is able to be built successfully, and lastly we deploy to the +staging server. + +The `environment` keyword is just a hint for GitLab that this job actually +deploys to this environment's `name`. It can also have a `url` which, as we +will later see, is exposed in various places within GitLab. Each time a job that +has an environment specified and succeeds, a deployment is recorded, remembering +the Git SHA and environment name. + +To sum up, with the above `.gitlab-ci.yml` we have achieved that: + +- All branches will run the `test` and `build` jobs. +- The `deploy_staging` job will run [only](yaml/README.md#only) on the `master` + branch which means all merge requests that are created from branches don't + get to deploy to the staging server +- When a merge request is merged, all jobs will run and the `deploy_staging` + in particular will deploy our code to a staging server while the deployment + will be recorded in an environment named `staging`. + +Let's now see how that information is exposed within GitLab. + +## Viewing the current status of an environment + +The environment list under your project's **Pipelines âž” Environments**, is +where you can find information of the last deployment status of an environment. + +Here's how the Environments page looks so far. + +![Staging environment view](img/environments_available_staging.png) + +There's a bunch of information there, specifically you can see: + +- The environment's name with a link to its deployments +- The last deployment ID number and who performed it +- The build ID of the last deployment with its respective job name +- The commit information of the last deployment such as who committed, to what + branch and the Git SHA of the commit +- The exact time the last deployment was performed +- A button that takes you to the URL that you have defined under the + `environment` keyword in `.gitlab-ci.yml` +- A button that re-deploys the latest deployment, meaning it runs the job + defined by the environment name for that specific commit + +>**Notes:** +- While you can create environments manually in the web interface, we recommend + that you define your environments in `.gitlab-ci.yml` first. They will + be automatically created for you after the first deploy. +- The environments page can only be viewed by Reporters and above. For more + information on the permissions, see the [permissions documentation][permissions]. +- Only deploys that happen after your `.gitlab-ci.yml` is properly configured + will show up in the "Environment" and "Last deployment" lists. + +The information shown in the Environments page is limited to the latest +deployments, but as you may have guessed an environment can have multiple +deployments. + +## Viewing the deployment history of an environment + +GitLab keeps track of your deployments, so you always know what is currently +being deployed on your servers. That way you can have the full history of your +deployments per every environment right in your browser. Clicking on an +environment will show the history of its deployments. Assuming you have deployed +multiple times already, here's how a specific environment's page looks like. + +![Deployments](img/deployments_view.png) + +We can see the same information as when in the Environments page, but this time +all deployments are shown. As you may have noticed, apart from the **Re-deploy** +button there are now **Rollback** buttons for each deployment. Let's see how +that works. + +## Rolling back changes + +You can't control everything, so sometimes things go wrong. When that unfortunate +time comes GitLab has you covered. Simply by clicking the **Rollback** button +that can be found in the deployments page +(**Pipelines âž” Environments âž” `environment name`**) you can relaunch the +job with the commit associated with it. + +>**Note:** +Bare in mind that your mileage will vary and it's entirely up to how you define +the deployment process in the job's `script` whether the rollback succeeds or not. +GitLab CI is just following orders. + +Thankfully that was the staging server that we had to rollback, and since we +learn from our mistakes, we decided to not make the same again when we deploy +to the production server. Enter manual actions for deployments. + +## Manually deploying to environments + +Turning a job from running automatically to a manual action is as simple as +adding `when: manual` to it. To expand on our previous example, let's add +another job that this time deploys our app to a production server and is +tracked by a `production` environment. The `.gitlab-ci.yml` looks like this +so far: + +```yaml +stages: + - test + - build + - deploy + +test: + stage: test + script: echo "Running tests" + +build: + stage: build + script: echo "Building the app" + +deploy_staging: + stage: deploy + script: + - echo "Deploy to staging server" + environment: + name: staging + url: https://staging.example.com + only: + - master + +deploy_prod: + stage: deploy + script: + - echo "Deploy to production server" + environment: + name: production + url: https://example.com + when: manual + only: + - master ``` -## Defining environments +The `when: manual` action exposes a play button in GitLab's UI and the +`deploy_prod` job will only be triggered if and when we click that play button. +You can find it in the pipeline, build, environment, and deployment views. + +| Pipelines | Single pipeline | Environments | Deployments | Builds | +| --------- | ----------------| ------------ | ----------- | -------| +| ![Pipelines manual action](img/environments_manual_action_pipelines.png) | ![Pipelines manual action](img/environments_manual_action_single_pipeline.png) | ![Environments manual action](img/environments_manual_action_environments.png) | ![Deployments manual action](img/environments_manual_action_deployments.png) | ![Builds manual action](img/environments_manual_action_builds.png) | + +Clicking on the play button in either of these places will trigger the +`deploy_prod` job, and the deployment will be recorded under a new +environment named `production`. + +>**Note:** +Remember that if your environment's name is `production` (all lowercase), then +it will get recorded in [Cycle Analytics](../user/project/cycle_analytics.md). +Double the benefit! + +While this is fine for deploying to some stable environments like staging or +production, what happens for branches? So far we haven't defined anything +regarding deployments for branches other than `master`. Dynamic environments +will help us achieve that. + +## Dynamic environments -You can create and delete environments manually in the web interface, but we -recommend that you define your environments in `.gitlab-ci.yml` first, which -will automatically create environments for you after the first deploy. +As the name suggests, it is possible to create environments on the fly by just +declaring their names dynamically in `.gitlab-ci.yml`. Dynamic environments is +the basis of [Review apps](review_apps.md). -The `environment` is just a hint for GitLab that this job actually deploys to -this environment. Each time the job succeeds, a deployment is recorded, -remembering the git SHA and environment. +GitLab Runner exposes various [environment variables][variables] when a job runs, +and as such, you can use them as environment names. Let's add another job in +our example which will deploy to all branches except `master`: -Add something like this to your `.gitlab-ci.yml`: +```yaml +deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_BUILD_REF_NAME + url: https://$CI_BUILD_REF_NAME.example.com + only: + - branches + except: + - master ``` -production: + +Let's break it down in pieces. The job's name is `deploy_review` and it runs +on the `deploy` stage. The `script` at this point is fictional, you'd have to +use your own based on your deployment. Then, we set the `environment` with the +`environment:name` being `review/$CI_BUILD_REF_NAME`. Now that's an interesting +one. Since the [environment name][env-name] can contain also slashes (`/`), we +can use this pattern to distinguish between dynamic environments and the regular +ones. + +So, the first part is `review`, followed by a `/` and then `$CI_BUILD_REF_NAME` +which takes the value of the branch name. We also use the same +`$CI_BUILD_REF_NAME` value in the `environment:url` so that the environment +can get a specific and distinct URL for each branch. Again, the way you set up +the webserver to serve these requests is based on your setup. + +Last but not least, we tell the job to run [`only`][only] on branches +[`except`][only] master. + +>**Note:** +You are not bound to use the same prefix or only slashes in the dynamic +environments' names (`/`), but as we will see later, this will enable the +[grouping similar environments](#grouping-similar-environments) feature. + +The whole `.gitlab-ci.yml` looks like this so far: + +```yaml +stages: + - test + - build + - deploy + +test: + stage: test + script: echo "Running tests" + +build: + stage: build + script: echo "Building the app" + +deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_BUILD_REF_NAME + url: https://$CI_BUILD_REF_NAME.example.com + only: + - branches + except: + - master + +deploy_staging: + stage: deploy + script: + - echo "Deploy to staging server" + environment: + name: staging + url: https://staging.example.com + only: + - master + +deploy_prod: stage: deploy - script: dpl... - environment: production + script: + - echo "Deploy to production server" + environment: + name: production + url: https://example.com + when: manual + only: + - master ``` -See full [documentation](yaml/README.md#environment). +A more realistic example would include copying files to a location where a +webserver (NGINX) could then read and serve. The example below will copy the +`public` directory to `/srv/nginx/$CI_BUILD_REF_NAME/public`: -## Seeing environment status +```yaml +review_app: + stage: deploy + script: + - rsync -av --delete public /srv/nginx/$CI_BUILD_REF_NAME + environment: + name: review/$CI_BUILD_REF_NAME + url: https://$CI_BUILD_REF_NAME.example.com +``` -You can find the environment list under **Pipelines > Environments** for your -project. You'll see the git SHA and date of the last deployment to each -environment defined. +It is assumed that the user has already setup NGINX and GitLab Runner in the +server this job will run on. >**Note:** -Only deploys that happen after your `.gitlab-ci.yml` is properly configured will -show up in the environments and deployments lists. +Be sure to check out the [limitations](#limitations) section for some edge +cases regarding naming of you branches and Review Apps. + +--- + +The development workflow would now be: + +- Developer creates a branch locally +- Developer makes changes, commits and pushes the branch to GitLab +- Developer creates a merge request + +Behind the scenes: + +- GitLab Runner picks up the changes and starts running the jobs +- The jobs run sequentially as defined in `stages` + - First, the tests pass + - Then, the build begins and successfully also passes + - Lastly, the app is deployed to an environment with a name specific to the + branch + +So now, every branch gets its own environment and is deployed to its own place +with the added benefit of having a [history of deployments](#viewing-the-deployment-history-of-an-environment) +and also being able to [rollback changes](#rolling-back-changes) if needed. +Let's briefly see where URL that's defined in the environments is exposed. + +## Making use of the environment URL + +The environment URL is exposed in a few places within GitLab. + +| In a merge request widget as a link | In the Environments view as a button | In the Deployments view as a button | +| -------------------- | ------------ | ----------- | +| ![Environment URL in merge request](img/environments_mr_review_app.png) | ![Environment URL in environments](img/environments_link_url.png) | ![Environment URL in deployments](img/environments_link_url_deployments.png) | + +If a merge request is eventually merged to the default branch (in our case +`master`) and that branch also deploys to an environment (in our case `staging` +and/or `production`) you can see this information in the merge request itself. + +![Environment URLs in merge request](img/environments_link_url_mr.png) + +--- + +We now have a full development cycle, where our app is tested, built, deployed +as a Review app, deployed to a staging server once the merge request is merged, +and finally manually deployed to the production server. What we just described +is a single workflow, but imagine tens of developers working on a project +at the same time. They each push to their branches, and dynamic environments are +created all the time. In that case, we probably need to do some clean up. Read +next how environments can be stopped. + +## Stopping an environment + +By stopping an environment, you are effectively terminating its recording of the +deployments that happen in it. + +A branch is associated with an environment when the CI pipeline that is created +for this branch, was recently deployed to this environment. You can think of +the CI pipeline as the glue between the branch and the environment: +`branch âž” CI pipeline âž” environment`. + +There is a special case where environments can be manually stopped. That can +happen if you provide another job for that matter. The syntax is a little +tricky since a job calls another job to do the job. + +Consider the following example where the `deploy_review` calls the `stop_review` +to clean up and stop the environment: + +```yaml +deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_BUILD_REF_NAME + url: https://$CI_BUILD_REF_NAME.example.com + on_stop: stop_review + only: + - branches + except: + - master -## Seeing deployment history +stop_review: + variables: + GIT_STRATEGY: none + script: + - echo "Remove review app" + when: manual + environment: + name: review/$CI_BUILD_REF_NAME + action: stop +``` -Clicking on an environment will show the history of deployments. +Setting the [`GIT_STRATEGY`][git-strategy] to `none` is necessary on the +`stop_review` job so that the [GitLab Runner] won't try to checkout the code +after the branch is deleted. >**Note:** -Only deploys that happen after your `.gitlab-ci.yml` is properly configured will -show up in the environments and deployments lists. +Starting with GitLab 8.14, dynamic environments will be stopped automatically +when their associated branch is deleted. + +When you have an environment that has a stop action defined (typically when +the environment describes a review app), GitLab will automatically trigger a +stop action when the associated branch is deleted. + +You can read more in the [`.gitlab-ci.yml` reference][onstop]. + +## Grouping similar environments + +> [Introduced][ce-7015] in GitLab 8.14. + +As we've seen in the [dynamic environments](#dynamic-environments), you can +prepend their name with a word, then followed by a `/` and finally the branch +name which is automatically defined by the `CI_BUILD_REF_NAME` variable. + +In short, environments that are named like `type/foo` are presented under a +group named `type`. + +In our minimal example, we name the environments `review/$CI_BUILD_REF_NAME` +where `$CI_BUILD_REF_NAME` is the branch name: + +```yaml +deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_BUILD_REF_NAME +``` + +In that case, if you visit the Environments page, and provided the branches +exist, you should see something like: + +![Environment groups](img/environments_dynamic_groups.png) + +## Checkout deployments locally + +Since 8.13, a reference in the git repository is saved for each deployment. So +knowing what the state is of your current environments is only a `git fetch` +away. + +In your git config, append the `[remote "<your-remote>"]` block with an extra +fetch line: + +``` +fetch = +refs/environments/*:refs/remotes/origin/environments/* +``` + +## Limitations + +1. If the branch name contains special characters (`/`), and you use the + `$CI_BUILD_REF_NAME` variable to dynamically create environments, there might + be complications during your Review Apps deployment. Follow the + [issue 22849][ce-22849] for more information. +1. You are limited to use only the [CI predefined variables][variables] in the + `environment: name`. If you try to re-use variables defined inside `script` + as part of the environment name, it will not work. + +## Further reading + +Below are some links you may find interesting: + +- [The `.gitlab-ci.yml` definition of environments](yaml/README.md#environment) +- [A blog post on Deployments & Environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) +- [Review Apps - Use dynamic environments to deploy your code for every branch](review_apps/index.md) [Pipelines]: pipelines.md [jobs]: yaml/README.md#jobs +[yaml]: yaml/README.md [environments]: #environments [deployments]: #deployments +[permissions]: ../user/permissions.md +[variables]: variables/README.md +[env-name]: yaml/README.md#environment-name +[only]: yaml/README.md#only-and-except +[onstop]: yaml/README.md#environment-on_stop +[ce-7015]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7015 +[ce-22849]: https://gitlab.com/gitlab-org/gitlab-ce/issues/22849 +[gitlab runner]: https://docs.gitlab.com/runner/ +[git-strategy]: yaml/README.md#git-strategy diff --git a/doc/ci/examples/test-scala-application.md b/doc/ci/examples/test-scala-application.md index 7412fdbbc78..85f8849fa99 100644 --- a/doc/ci/examples/test-scala-application.md +++ b/doc/ci/examples/test-scala-application.md @@ -1,11 +1,11 @@ -## Test a Scala application +# Test and deploy to Heroku a Scala application This example demonstrates the integration of Gitlab CI with Scala applications using SBT. Checkout the example [project](https://gitlab.com/gitlab-examples/scala-sbt) and [build status](https://gitlab.com/gitlab-examples/scala-sbt/builds). -### Add `.gitlab-ci.yml` file to project +## Add `.gitlab-ci.yml` file to project The following `.gitlab-ci.yml` should be added in the root of your repository to trigger CI: @@ -13,10 +13,14 @@ repository to trigger CI: ``` yaml image: java:8 +stages: + - test + - deploy + before_script: - apt-get update -y - apt-get install apt-transport-https -y - # Install SBT + ## Install SBT - echo "deb http://dl.bintray.com/sbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list - apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 642AC823 - apt-get update -y @@ -24,8 +28,17 @@ before_script: - sbt sbt-version test: + stage: test script: - sbt clean coverage test coverageReport + +deploy: + stage: deploy + script: + - apt-get update -yq + - apt-get install rubygems ruby-dev -y + - gem install dpl + - dpl --provider=heroku --app=gitlab-play-sample-app --api-key=$HEROKU_API_KEY ``` The `before_script` installs [SBT](http://www.scala-sbt.org/) and @@ -33,15 +46,31 @@ displays the version that is being used. The `test` stage executes SBT to compile and test the project. [scoverage](https://github.com/scoverage/sbt-scoverage) is used as an SBT plugin to measure test coverage. +The `deploy` stage automatically deploys the project to Heroku using dpl. You can use other versions of Scala and SBT by defining them in `build.sbt`. -### Display test coverage in build +## Display test coverage in build Add the `Coverage was \[\d+.\d+\%\]` regular expression in the -**Settings > Edit Project > Test coverage parsing** project setting to -retrieve the test coverage rate from the build trace and have it +**Settings âž” Edit Project âž” Test coverage parsing** project setting to +retrieve the [test coverage] rate from the build trace and have it displayed with your builds. **Builds** must be enabled for this option to appear. + +## Heroku application + +A Heroku application is required. You can create one through the +[Dashboard](https://dashboard.heroku.com/). Substitute `gitlab-play-sample-app` +in the `.gitlab-ci.yml` file with your application's name. + +## Heroku API key + +You can look up your Heroku API key in your +[account](https://dashboard.heroku.com/account). Add a secure [variable] with +this value in **Project âž” Variables** with key `HEROKU_API_KEY`. + +[variable]: ../variables/README.md#user-defined-variables-secure-variables +[test coverage]: ../../user/project/pipelines/settings.md#test-coverage-report-badge diff --git a/doc/ci/img/deployments_view.png b/doc/ci/img/deployments_view.png Binary files differnew file mode 100644 index 00000000000..ca6097cbea4 --- /dev/null +++ b/doc/ci/img/deployments_view.png diff --git a/doc/ci/img/environments_available_staging.png b/doc/ci/img/environments_available_staging.png Binary files differnew file mode 100644 index 00000000000..784c4fd944c --- /dev/null +++ b/doc/ci/img/environments_available_staging.png diff --git a/doc/ci/img/environments_dynamic_groups.png b/doc/ci/img/environments_dynamic_groups.png Binary files differnew file mode 100644 index 00000000000..e89b66c502c --- /dev/null +++ b/doc/ci/img/environments_dynamic_groups.png diff --git a/doc/ci/img/environments_link_url.png b/doc/ci/img/environments_link_url.png Binary files differnew file mode 100644 index 00000000000..224c21adfb5 --- /dev/null +++ b/doc/ci/img/environments_link_url.png diff --git a/doc/ci/img/environments_link_url_deployments.png b/doc/ci/img/environments_link_url_deployments.png Binary files differnew file mode 100644 index 00000000000..9419668a9bd --- /dev/null +++ b/doc/ci/img/environments_link_url_deployments.png diff --git a/doc/ci/img/environments_link_url_mr.png b/doc/ci/img/environments_link_url_mr.png Binary files differnew file mode 100644 index 00000000000..3276dfb6096 --- /dev/null +++ b/doc/ci/img/environments_link_url_mr.png diff --git a/doc/ci/img/environments_manual_action_builds.png b/doc/ci/img/environments_manual_action_builds.png Binary files differnew file mode 100644 index 00000000000..d4bb7ccdbae --- /dev/null +++ b/doc/ci/img/environments_manual_action_builds.png diff --git a/doc/ci/img/environments_manual_action_deployments.png b/doc/ci/img/environments_manual_action_deployments.png Binary files differnew file mode 100644 index 00000000000..c2477381c80 --- /dev/null +++ b/doc/ci/img/environments_manual_action_deployments.png diff --git a/doc/ci/img/environments_manual_action_environments.png b/doc/ci/img/environments_manual_action_environments.png Binary files differnew file mode 100644 index 00000000000..56601c0db2d --- /dev/null +++ b/doc/ci/img/environments_manual_action_environments.png diff --git a/doc/ci/img/environments_manual_action_pipelines.png b/doc/ci/img/environments_manual_action_pipelines.png Binary files differnew file mode 100644 index 00000000000..eb6e87cd956 --- /dev/null +++ b/doc/ci/img/environments_manual_action_pipelines.png diff --git a/doc/ci/img/environments_manual_action_single_pipeline.png b/doc/ci/img/environments_manual_action_single_pipeline.png Binary files differnew file mode 100644 index 00000000000..9713ad212e2 --- /dev/null +++ b/doc/ci/img/environments_manual_action_single_pipeline.png diff --git a/doc/ci/img/environments_mr_review_app.png b/doc/ci/img/environments_mr_review_app.png Binary files differnew file mode 100644 index 00000000000..a2ae25d62fa --- /dev/null +++ b/doc/ci/img/environments_mr_review_app.png diff --git a/doc/ci/img/environments_view.png b/doc/ci/img/environments_view.png Binary files differnew file mode 100644 index 00000000000..131a9718cc4 --- /dev/null +++ b/doc/ci/img/environments_view.png diff --git a/doc/ci/review_apps/img/review_apps_preview_in_mr.png b/doc/ci/review_apps/img/review_apps_preview_in_mr.png Binary files differnew file mode 100644 index 00000000000..15bcb90518c --- /dev/null +++ b/doc/ci/review_apps/img/review_apps_preview_in_mr.png diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md new file mode 100644 index 00000000000..a66165dc973 --- /dev/null +++ b/doc/ci/review_apps/index.md @@ -0,0 +1,124 @@ +# Getting started with Review Apps + +> +- [Introduced][ce-21971] in GitLab 8.12. Further additions were made in GitLab + 8.13 and 8.14. +- Inspired by [Heroku's Review Apps][heroku-apps] which itself was inspired by + [Fourchette]. + +The basis of Review Apps is the [dynamic environments] which allow you to create +a new environment (dynamically) for each one of your branches. + +A Review App can then be visible as a link when you visit the [merge request] +relevant to the branch. That way, you are able to see live all changes introduced +by the merge request changes. Reviewing anything, from performance to interface +changes, becomes much easier with a live environment and as such, Review Apps +can make a huge impact on your development flow. + +They mostly make sense to be used with web applications, but you can use them +any way you'd like. + +## Overview + +Simply put, a Review App is a mapping of a branch with an environment as there +is a 1:1 relation between them. + +Here's an example of what it looks like when viewing a merge request with a +dynamically set environment. + +![Review App in merge request](img/review_apps_preview_in_mr.png) + +In the image above you can see that the `add-new-line` branch was successfully +built and deployed under a dynamic environment and can be previewed with an +also dynamically URL. + +The details of the Review Apps implementation depend widely on your real +technology stack and on your deployment process. The simplest case it to +deploy a simple static HTML website, but it will not be that straightforward +when your app is using a database for example. To make a branch be deployed +on a temporary instance and booting up this instance with all required software +and services automatically on the fly is not a trivial task. However, it is +doable, especially if you use Docker, or at least a configuration management +tool like Chef, Puppet, Ansible or Salt. + +## Prerequisites + +To get a better understanding of Review Apps, you must first learn how +environments and deployments work. The following docs will help you grasp that +knowledge: + +1. First, learn about [environments][] and their role in the development workflow. +1. Then make a small stop to learn about [CI variables][variables] and how they + can be used in your CI jobs. +1. Next, explore the [`environment` syntax][yaml-env] as defined in `.gitlab-ci.yml`. + This will be your primary reference when you are finally comfortable with + how environments work. +1. Additionally, find out about [manual actions][] and how you can use them to + deploy to critical environments like production with the push of a button. +1. And as a last step, follow the [example tutorials](#examples) which will + guide you step by step to set up the infrastructure and make use of + Review Apps. + +## Configuration + +The configuration of Review apps depends on your technology stack and your +infrastructure. Read the [dynamic environments] documentation to understand +how to define and create them. + +## Creating and destroying Review Apps + +The creation and destruction of a Review App is defined in `.gitlab-ci.yml` +at a job level under the `environment` keyword. + +Check the [environments] documentation how to do so. + +## A simple workflow + +The process of adding Review Apps in your workflow would look like: + +1. Set up the infrastructure to host and deploy the Review Apps. +1. [Install][install-runner] and [configure][conf-runner] a Runner that does + the deployment. +1. Set up a job in `.gitlab-ci.yml` that uses the predefined + [predefined CI environment variable][variables] `${CI_BUILD_REF_NAME}` to + create dynamic environments and restrict it to run only on branches. +1. Optionally set a job that [manually stops][manual-env] the Review Apps. + +From there on, you would follow the branched Git flow: + +1. Push a branch and let the Runner deploy the Review App based on the `script` + definition of the dynamic environment job. +1. Wait for the Runner to build and/or deploy your web app. +1. Click on the link that's present in the MR related to the branch and see the + changes live. + +## Limitations + +Check the [environments limitations](../environments.md#limitations). + +## Examples + +A list of examples used with Review Apps can be found below: + +- [Use with NGINX][app-nginx] - Use NGINX and the shell executor of GitLab Runner + to deploy a simple HTML website. + +And below is a soon to be added examples list: + +- Use with Amazon S3 +- Use on Heroku with dpl +- Use with OpenShift/kubernetes + +[app-nginx]: https://gitlab.com/gitlab-examples/review-apps-nginx +[ce-21971]: https://gitlab.com/gitlab-org/gitlab-ce/issues/21971 +[dynamic environments]: ../environments.md#dynamic-environments +[environments]: ../environments.md +[fourchette]: https://github.com/rainforestapp/fourchette +[heroku-apps]: https://devcenter.heroku.com/articles/github-integration-review-apps +[manual actions]: ../environments.md#manual-actions +[merge request]: ../../user/project/merge_requests.md +[variables]: ../variables/README.md +[yaml-env]: ../yaml/README.md#environment +[install-runner]: https://docs.gitlab.com/runner/install/ +[conf-runner]: https://docs.gitlab.com/runner/commands/ +[manual-env]: ../environments.md#stopping-an-environment diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5c0e1c44e3f..338c9a27789 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -541,6 +541,8 @@ same manual action multiple times. An example usage of manual actions is deployment to production. +Read more at the [environments documentation][env-manual]. + ### environment > Introduced in GitLab 8.9. @@ -552,28 +554,14 @@ An example usage of manual actions is deployment to production. If `environment` is specified and no environment under that name exists, a new one will be created automatically. -The `environment` name can contain: - -- letters -- digits -- spaces -- `-` -- `_` -- `/` -- `$` -- `{` -- `}` - -Common names are `qa`, `staging`, and `production`, but you can use whatever -name works with your workflow. - In its simplest form, the `environment` keyword can be defined like: ``` deploy to production: stage: deploy script: git push production HEAD:master - environment: production + environment: + name: production ``` In the above example, the `deploy to production` job will be marked as doing a @@ -588,6 +576,21 @@ Before GitLab 8.11, the name of an environment could be defined as a string like `environment: production`. The recommended way now is to define it under the `name` keyword. +The `environment` name can contain: + +- letters +- digits +- spaces +- `-` +- `_` +- `/` +- `$` +- `{` +- `}` + +Common names are `qa`, `staging`, and `production`, but you can use whatever +name works with your workflow. + Instead of defining the name of the environment right after the `environment` keyword, it is also possible to define it as a separate value. For that, use the `name` keyword under `environment`: @@ -626,7 +629,12 @@ deploy to production: #### environment:on_stop -> [Introduced][ce-6669] in GitLab 8.13. +> +**Notes:** +- [Introduced][ce-6669] in GitLab 8.13. +- Starting with GitLab 8.14, when you have an environment that has a stop action + defined, GitLab will automatically trigger a stop action when the associated + branch is deleted. Closing (stoping) environments can be achieved with the `on_stop` keyword defined under `environment`. It declares a different job that runs in order to close @@ -681,6 +689,13 @@ The `stop_review_app` job is **required** to have the following keywords defined These parameters can use any of the defined [CI variables](#variables) (including predefined, secure variables and `.gitlab-ci.yml` variables). +>**Note:** +Be aware than if the branch name contains special characters and you use the +`$CI_BUILD_REF_NAME` variable to dynamically create environments, there might +be complications during deployment. Follow the +[issue 22849](https://gitlab.com/gitlab-org/gitlab-ce/issues/22849) for more +information. + For example: ``` @@ -745,6 +760,15 @@ artifacts: - binaries/ ``` +To disable artifact passing, define the job with empty [dependencies](#dependencies): + +```yaml +job: + stage: build + script: make build + dependencies: [] +``` + You may want to create artifacts only for tagged releases to avoid filling the build server storage with temporary build artifacts. @@ -1210,6 +1234,7 @@ capitalization, the commit will be created but the builds will be skipped. Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. +[env-manual]: ../environments.md#manually-deploying-to-environments [examples]: ../examples/README.md [ce-6323]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6323 [environment]: ../environments.md diff --git a/doc/development/README.md b/doc/development/README.md index f88456a7a7a..371bb55c127 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -22,6 +22,7 @@ ## Process - [Generate a changelog entry with `bin/changelog`](changelog.md) +- [Limit conflicts with EE when developing on CE](limit_ee_conflicts.md) - [Code review guidelines](code_review.md) for reviewing code and having code reviewed. - [Merge request performance guidelines](merge_request_performance_guidelines.md) for ensuring merge requests do not negatively impact GitLab performance diff --git a/doc/development/frontend.md b/doc/development/frontend.md index ec8f2d6531c..9e782ab977f 100644 --- a/doc/development/frontend.md +++ b/doc/development/frontend.md @@ -205,6 +205,57 @@ command line. Please note: Not all of the frontend fixtures are generated. Some are still static files. These will not be touched by `rake teaspoon:fixtures`. +## Design Patterns + +### Singletons + +When exactly one object is needed for a given task, prefer to define it as a +`class` rather than as an object literal. Prefer also to explicitly restrict +instantiation, unless flexibility is important (e.g. for testing). + +``` +// bad + +gl.MyThing = { + prop1: 'hello', + method1: () => {} +}; + +// good + +class MyThing { + constructor() { + this.prop1 = 'hello'; + } + method1() {} +} + +gl.MyThing = new MyThing(); + +// best + +let singleton; + +class MyThing { + constructor() { + if (!singleton) { + singleton = this; + singleton.init(); + } + return singleton; + } + + init() { + this.prop1 = 'hello'; + } + + method1() {} +} + +gl.MyThing = MyThing; + +``` + ## Supported browsers For our currently-supported browsers, see our [requirements][requirements]. diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index b25ce79e89f..7bfc9cb361f 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -32,6 +32,95 @@ spec/models/user_spec.rb|6 error| Failure/Error: u = described_class.new NoMeth Except for the top-level `describe` block, always provide a String argument to `describe`. +## Don't assert against the absolute value of a sequence-generated attribute + +Consider the following factory: + +```ruby +FactoryGirl.define do + factory :label do + sequence(:title) { |n| "label#{n}" } + end +end +``` + +Consider the following API spec: + +```ruby +require 'rails_helper' + +describe API::Labels do + it 'creates a first label' do + create(:label) + + get api("/projects/#{project.id}/labels", user) + + expect(response).to have_http_status(200) + expect(json_response.first['name']).to eq('label1') + end + + it 'creates a second label' do + create(:label) + + get api("/projects/#{project.id}/labels", user) + + expect(response).to have_http_status(200) + expect(json_response.first['name']).to eq('label1') + end +end +``` + +When run, this spec doesn't do what we might expect: + +```sh +1) API::API reproduce sequence issue creates a second label + Failure/Error: expect(json_response.first['name']).to eq('label1') + + expected: "label1" + got: "label2" + + (compared using ==) +``` + +That's because FactoryGirl sequences are not reseted for each example. + +Please remember that sequence-generated values exist only to avoid having to +explicitly set attributes that have a uniqueness constraint when using a factory. + +### Solution + +If you assert against a sequence-generated attribute's value, you should set it +explicitly. Also, the value you set shouldn't match the sequence pattern. + +For instance, using our `:label` factory, writing `create(:label, title: 'foo')` +is ok, but `create(:label, title: 'label1')` is not. + +Following is the fixed API spec: + +```ruby +require 'rails_helper' + +describe API::Labels do + it 'creates a first label' do + create(:label, title: 'foo') + + get api("/projects/#{project.id}/labels", user) + + expect(response).to have_http_status(200) + expect(json_response.first['name']).to eq('foo') + end + + it 'creates a second label' do + create(:label, title: 'bar') + + get api("/projects/#{project.id}/labels", user) + + expect(response).to have_http_status(200) + expect(json_response.first['name']).to eq('bar') + end +end +``` + ## Don't `rescue Exception` See ["Why is it bad style to `rescue Exception => e` in Ruby?"][Exception]. diff --git a/doc/development/limit_ee_conflicts.md b/doc/development/limit_ee_conflicts.md new file mode 100644 index 00000000000..b7e6387838e --- /dev/null +++ b/doc/development/limit_ee_conflicts.md @@ -0,0 +1,272 @@ +# Limit conflicts with EE when developing on CE + +This guide contains best-practices for avoiding conflicts between CE and EE. + +## Context + +Usually, GitLab Community Edition is merged into the Enterprise Edition once a +week. During these merges, it's very common to get conflicts when some changes +in CE do not apply cleanly to EE. + +There are a few things that can help you as a developer to: + +- know when your merge request to CE will conflict when merged to EE +- avoid such conflicts in the first place +- ease future conflict resolutions if conflict is inevitable + +## Check the `rake ee_compat_check` in your merge requests + +For each commit (except on `master`), the `rake ee_compat_check` CI job tries to +detect if the current branch's changes will conflict during the CE->EE merge. + +The job reports what files are conflicting and how to setup a merge request +against EE. Here is roughly how it works: + +1. Generates the diff between your branch and current CE `master` +1. Tries to apply it to current EE `master` +1. If it applies cleanly, the job succeeds, otherwise... +1. Detects a branch with the `-ee` suffix in EE +1. If it exists, generate the diff between this branch and current EE `master` +1. Tries to apply it to current EE `master` +1. If it applies cleanly, the job succeeds + +In the case where the job fails, it means you should create a `<ce_branch>-ee` +branch, push it to EE and open a merge request against EE `master`. At this +point if you retry the failing job in your CE merge request, it should now pass. + +Notes: + +- This task is not a silver-bullet, its current goal is to bring awareness to + developers that their work needs to be ported to EE. +- Community contributors shouldn't submit merge requests against EE, but + reviewers should take actions by either creating such EE merge request or + asking a GitLab developer to do it once the merge request is merged. +- If you branch is more than 500 commits behind `master`, the job will fail and + you should rebase your branch upon latest `master`. + +## Possible type of conflicts + +### Controllers + +#### List or arrays are augmented in EE + +In controllers, the most common type of conflict is with `before_action` that +has a list of actions in CE but EE adds some actions to that list. + +The same problem often occurs for `params.require` / `params.permit` calls. + +##### Mitigations + +Separate CE and EE actions/keywords. For instance for `params.require` in +`ProjectsController`: + +```ruby +def project_params + params.require(:project).permit(project_params_ce) + # On EE, this is always: + # params.require(:project).permit(project_params_ce << project_params_ee) +end + +# Always returns an array of symbols, created however best fits the use case. +# It _should_ be sorted alphabetically. +def project_params_ce + %i[ + description + name + path + ] +end + +# (On EE) +def project_params_ee + %i[ + approvals_before_merge + approver_group_ids + approver_ids + ... + ] +end +``` + +#### Additional condition(s) in EE + +For instance for LDAP: + +```diff + def destroy + @key = current_user.keys.find(params[:id]) + - @key.destroy + + @key.destroy unless @key.is_a? LDAPKey + + respond_to do |format| +``` + +Or for Geo: + +```diff +def after_sign_out_path_for(resource) +- current_application_settings.after_sign_out_path.presence || new_user_session_path ++ if Gitlab::Geo.secondary? ++ Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state) ++ else ++ current_application_settings.after_sign_out_path.presence || new_user_session_path ++ end +end +``` + +Or even for audit log: + +```diff +def approve_access_request +- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute ++ member = Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute ++ ++ log_audit_event(member, action: :create) + + redirect_to polymorphic_url([membershipable, :members]) +end +``` + +### Views + +#### Additional view code in EE + +A block of code added in CE conflicts because there is already another block +at the same place in EE + +##### Mitigations + +Blocks of code that are EE-specific should be moved to partials as much as +possible to avoid conflicts with big chunks of HAML code that that are not fun +to resolve when you add the indentation to the equation. + +For instance this kind of thing: + +```haml +- if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) + - has_due_date = issuable.has_attribute?(:due_date) + %hr + .row + %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } + .form-group.issue-assignee + = f.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + - if issuable.assignee_id + = f.hidden_field :assignee_id + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + .form-group.issue-milestone + = f.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" + .form-group + - has_labels = @labels && @labels.any? + = f.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" + = f.hidden_field :label_ids, multiple: true, value: '' + .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } + .issuable-form-select-holder + = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false, show_menu_above: 'true' }, dropdown_title: "Select label" + + - if issuable.respond_to?(:weight) + .form-group + = f.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do + Weight + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + = f.select :weight, issues_weight_options(issuable.weight, edit: true), { include_blank: true }, + { class: 'select2 js-select2', data: { placeholder: "Select weight" }} + + - if has_due_date + .col-lg-6 + .form-group + = f.label :due_date, "Due date", class: "control-label" + .col-sm-10 + .issuable-form-select-holder + = f.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date" +``` + +could be simplified by using partials: + +```haml += render 'metadata_form', issuable: issuable +``` + +and then the `_metadata_form.html.haml` could be as follows: + +```haml +- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) + +- has_due_date = issuable.has_attribute?(:due_date) +%hr +.row + %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } + .form-group.issue-assignee + = f.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + - if issuable.assignee_id + = f.hidden_field :assignee_id + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + .form-group.issue-milestone + = f.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" + .form-group + - has_labels = @labels && @labels.any? + = f.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" + = f.hidden_field :label_ids, multiple: true, value: '' + .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } + .issuable-form-select-holder + = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false, show_menu_above: 'true' }, dropdown_title: "Select label" + + = render 'weight_form', issuable: issuable, has_due_date: has_due_date + + - if has_due_date + .col-lg-6 + .form-group + = f.label :due_date, "Due date", class: "control-label" + .col-sm-10 + .issuable-form-select-holder + = f.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date" +``` + +and then the `_weight_form.html.haml` could be as follows: + +```haml +- return unless issuable.respond_to?(:weight) + +- has_due_date = issuable.has_attribute?(:due_date) + +.form-group + = f.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do + Weight + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + = f.select :weight, issues_weight_options(issuable.weight, edit: true), { include_blank: true }, + { class: 'select2 js-select2', data: { placeholder: "Select weight" }} +``` + +Note: + +- The safeguards at the top allow to get rid of an unneccessary indentation level +- Here we only moved the 'Weight' code to a partial since this is the only + EE-specific code in that view, so it's the most likely to conflict, but you + are encouraged to use partials even for code that's in CE to logically split + big views into several smaller files. + +#### Indentation issue + +Sometimes a code block is indented more or less in EE because there's an +additional condition. + +##### Mitigations + +Blocks of code that are EE-specific should be moved to partials as much as +possible to avoid conflicts with big chunks of HAML code that that are not fun +to resolve when you add the indentation in the equation. + +--- + +[Return to Development documentation](README.md) diff --git a/doc/development/testing.md b/doc/development/testing.md index b0b26ccf57a..6106e47daa0 100644 --- a/doc/development/testing.md +++ b/doc/development/testing.md @@ -64,11 +64,13 @@ the command line via `bundle exec teaspoon`, or via a web browser at methods. - Use `context` to test branching logic. - Don't `describe` symbols (see [Gotchas](gotchas.md#dont-describe-symbols)). +- Don't assert against the absolute value of a sequence-generated attribute (see [Gotchas](gotchas.md#dont-assert-against-the-absolute-value-of-a-sequence-generated-attribute)). - Don't supply the `:each` argument to hooks since it's the default. - Prefer `not_to` to `to_not` (_this is enforced by Rubocop_). - Try to match the ordering of tests to the ordering within the class. - Try to follow the [Four-Phase Test][four-phase-test] pattern, using newlines to separate phases. +- Try to use `Gitlab.config.gitlab.host` rather than hard coding `'localhost'` [four-phase-test]: https://robots.thoughtbot.com/four-phase-test diff --git a/doc/development/ux_guide/copy.md b/doc/development/ux_guide/copy.md index 03392a003ee..227b4fd3451 100644 --- a/doc/development/ux_guide/copy.md +++ b/doc/development/ux_guide/copy.md @@ -7,7 +7,7 @@ We are currently inconsistent with this guidance. Images below are created to il ## Contents * [Brevity](#brevity) -* [Forms](#forms) +* [Sentence case](#sentence-case) * [Terminology](#terminology) --- @@ -27,52 +27,73 @@ Preferrably use context and placement of controls to make it obvious what clicki --- -## Forms +## Sentence case +Use sentence case for all titles, headings, labels, menu items, and buttons. -### Adding items +--- + +## Terminology +Only use the terms in the tables below. + +### Issues + +#### Adjectives (states) + +| Term | +| ---- | +| Open | +| Closed | +| Deleted | + +>**Example:** +Use `5 open issues` and don't use `5 pending issues`. + +#### Verbs (actions) + +| Term | Use | Don't | +| ---- | --- | --- | +| Add | Add an issue | Don't use `create` or `new` | +| View | View an open or closed issue || +| Edit | Edit an open or closed issue | Don't use `update` | +| Close | Close an open issue || +| Re-open | Re-open a closed issue | There should never be a need to use `open` as a verb | +| Delete | Delete an open or closed issue || + +#### Add issue When viewing a list of issues, there is a button that is labeled `Add`. Given the context in the example, it is clearly referring to issues. If the context were not clear enough, the label could be `Add issue`. Clicking the button will bring you to the `Add issue` form. Other add flows should be similar. ![Add issue button](img/copy-form-addissuebutton.png) -The form should be titled `Add issue`. The submit button should be labeled `Save` or `Submit`. Do not use `Add`, `Create`, `New`, or `Save Changes`. The cancel button should be labeled `Cancel`. Do not use `Back`. +The form should be titled `Add issue`. The submit button should be labeled `Submit`. Don't use `Add`, `Create`, `New`, or `Save changes`. The cancel button should be labeled `Cancel`. Don't use `Back`. ![Add issue form](img/copy-form-addissueform.png) -### Editing items +#### Edit issue When in context of an issue, the affordance to edit it is labeled `Edit`. If the context is not clear enough, `Edit issue` could be considered. Other edit flows should be similar. ![Edit issue button](img/copy-form-editissuebutton.png) -The form should be titled `Edit Issue`. The submit button should be labeled `Save`. Do not use `Edit`, `Update`, `New`, or `Save Changes`. The cancel button should be labeled `Cancel`. Do not use `Back`. +The form should be titled `Edit issue`. The submit button should be labeled `Save`. Don't use `Edit`, `Update`, `Submit`, or `Save changes`. The cancel button should be labeled `Cancel`. Don't use `Back`. ![Edit issue form](img/copy-form-editissueform.png) ---- - -## Terminology -### Issues +### Merge requests #### Adjectives (states) -| Term | Use | -| ---- | --- | -| Open | Issue is active | -| Closed | Issue is no longer active | - ->**Example:** -Use `5 open issues` and do not use `5 pending issues`. -Only use the adjectives in the table above. +| Term | +| ---- | +| Open | +| Merged | #### Verbs (actions) -| Term | Use | -| ---- | --- | -| Add | For adding an issue. Do not use `create` or `new` | -| View | View an issue | -| Edit | Edit an issue. Do not use `update` | -| Close | Closing an issue | -| Re-open | Re-open an issue. There should never be a need to use `open` as a verb | -| Delete | Deleting an issue. Do not use `remove` |
\ No newline at end of file +| Term | Use | Don't | +| ---- | --- | --- | +| Add | Add a merge request | Do not use `create` or `new` | +| View | View an open or merged merge request || +| Edit | Edit an open or merged merge request| Do not use `update` | +| Merge | Merge an open merge request ||
\ No newline at end of file diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 766a7119943..42b515761e0 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -143,9 +143,6 @@ On a very active server (10,000 active users) the Sidekiq process can use 1GB+ o ## Supported web browsers -- Chrome (Latest stable version) -- Firefox (Latest released version and [latest ESR version](https://www.mozilla.org/en-US/firefox/organizations/)) -- Safari 7+ (known problem: required fields in html5 do not work) -- Opera (Latest released version) -- Internet Explorer (IE) 11+ but please make sure that you have the `Compatibility View` mode disabled. -- Edge (Latest stable version) +We support the current and the previous major release of Firefox, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11). + +Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. diff --git a/doc/user/markdown.md b/doc/user/markdown.md index dbc7e0f14e3..162d1bd7ed4 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -1,43 +1,5 @@ # Markdown -## Table of Contents - -**[GitLab Flavored Markdown](#gitlab-flavored-markdown-gfm)** - -* [Newlines](#newlines) -* [Multiple underscores in words](#multiple-underscores-in-words) -* [URL auto-linking](#url-auto-linking) -* [Multiline Blockquote](#multiline-blockquote) -* [Code and Syntax Highlighting](#code-and-syntax-highlighting) -* [Inline Diff](#inline-diff) -* [Emoji](#emoji) -* [Special GitLab references](#special-gitlab-references) -* [Task Lists](#task-lists) -* [Videos](#videos) - -**[Standard Markdown](#standard-markdown)** - -* [Headers](#headers) -* [Emphasis](#emphasis) -* [Lists](#lists) -* [Links](#links) -* [Images](#images) -* [Blockquotes](#blockquotes) -* [Inline HTML](#inline-html) -* [Horizontal Rule](#horizontal-rule) -* [Line Breaks](#line-breaks) -* [Tables](#tables) -* [Footnotes](#footnotes) - -**[Wiki-Specific Markdown](#wiki-specific-markdown)** - -* [Wiki - Direct page link](#wiki-direct-page-link) -* [Wiki - Direct file link](#wiki-direct-file-link) -* [Wiki - Hierarchical link](#wiki-hierarchical-link) -* [Wiki - Root link](#wiki-root-link) - -**[References](#references)** - ## GitLab Flavored Markdown (GFM) > **Note:** @@ -64,7 +26,7 @@ You can use GFM in the following areas: You can also use other rich text files in GitLab. You might have to install a dependency to do so. Please see the [github-markup gem readme](https://github.com/gitlabhq/markup#markups) for more information. -## Newlines +### Newlines > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#newlines @@ -84,7 +46,7 @@ Violets are blue Sugar is sweet -## Multiple underscores in words +### Multiple underscores in words > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#multiple-underscores-in-words @@ -99,7 +61,7 @@ perform_complicated_task do_this_and_do_that_and_another_thing -## URL auto-linking +### URL auto-linking > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#url-auto-linking @@ -120,7 +82,7 @@ GFM will autolink almost any URL you copy and paste into your text: * irc://irc.freenode.net/gitlab * http://localhost:3000 -## Multiline Blockquote +### Multiline Blockquote > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#multiline-blockquote @@ -154,7 +116,7 @@ multiple lines, you can quote that without having to manually prepend `>` to every line! >>> -## Code and Syntax Highlighting +### Code and Syntax Highlighting > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting @@ -224,7 +186,7 @@ s = "There is no highlighting for this." But let's throw in a <b>tag</b>. ``` -## Inline Diff +### Inline Diff > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#inline-diff @@ -240,7 +202,7 @@ However the wrapping tags cannot be mixed as such: - {- deletions -] - [- deletions -} -## Emoji +### Emoji > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#emoji @@ -265,7 +227,7 @@ If you are new to this, don't be :fearful:. You can easily join the emoji :famil Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup: -## Special GitLab References +### Special GitLab References GFM recognizes special references. @@ -305,7 +267,7 @@ GFM also recognizes certain cross-project references: | `namespace/project@9ba12248...b19a04f5` | commit range comparison | | `namespace/project~"Some label"` | issues with given label | -## Task Lists +### Task Lists > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#task-lists @@ -328,7 +290,7 @@ You can add task lists to issues, merge requests and comments. To create a task Task lists can only be created in descriptions, not in titles. Task item state can be managed by editing the description's Markdown or by toggling the rendered check boxes. -## Videos +### Videos > If this is not rendered correctly, see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#videos @@ -345,9 +307,9 @@ Here's a sample video: ![Sample Video](img/markdown_video.mp4) -# Standard Markdown +## Standard Markdown -## Headers +### Headers ```no-highlight # H1 @@ -366,21 +328,6 @@ Alt-H2 ------ ``` -# H1 -## H2 -### H3 -#### H4 -##### H5 -###### H6 - -Alternatively, for H1 and H2, an underline-ish style: - -Alt-H1 -====== - -Alt-H2 ------- - ### Header IDs and links All Markdown-rendered headers automatically get IDs, except in comments. @@ -416,7 +363,7 @@ Would generate the following link IDs: Note that the Emoji processing happens before the header IDs are generated, so the Emoji is converted to an image which then gets removed from the ID. -## Emphasis +### Emphasis ```no-highlight Emphasis, aka italics, with *asterisks* or _underscores_. @@ -436,7 +383,7 @@ Combined emphasis with **asterisks and _underscores_**. Strikethrough uses two tildes. ~~Scratch this.~~ -## Lists +### Lists ```no-highlight 1. First ordered list item @@ -492,7 +439,7 @@ the second list item will be incorrectly labeled as `1`. Second paragraph of first item. 2. Another item -## Links +### Links There are two ways to create links, inline-style and reference-style. @@ -501,9 +448,9 @@ There are two ways to create links, inline-style and reference-style. [I'm a reference-style link][Arbitrary case-insensitive reference text] [I'm a relative reference to a repository file](LICENSE) - + [I am an absolute reference within the repository](/doc/user/markdown.md) - + [I link to the Milestones page](/../milestones) [You can use numbers for reference-style link definitions][1] @@ -523,9 +470,9 @@ There are two ways to create links, inline-style and reference-style. [I'm a relative reference to a repository file](LICENSE)[^1] [I am an absolute reference within the repository](/doc/user/markdown.md) - + [I link to the Milestones page](/../milestones) - + [You can use numbers for reference-style link definitions][1] Or leave it empty and use the [link text itself][] @@ -544,7 +491,8 @@ Relative links do not allow referencing project files in a wiki page or wiki pag will point the link to `wikis/style` when the link is inside of a wiki markdown file. -## Images + +### Images Here's our logo (hover to see the title text): @@ -568,7 +516,7 @@ Reference-style: [logo]: img/markdown_logo.png -## Blockquotes +### Blockquotes ```no-highlight > Blockquotes are very handy in email to emulate reply text. @@ -586,7 +534,7 @@ Quote break. > This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. -## Inline HTML +### Inline HTML You can also use raw HTML in your Markdown, and it'll mostly work pretty well. @@ -610,7 +558,7 @@ See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubyd <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd> </dl> -## Horizontal Rule +### Horizontal Rule ``` Three or more... @@ -642,7 +590,7 @@ ___ Underscores -## Line Breaks +### Line Breaks My basic recommendation for learning how line breaks work is to experiment and discover -- hit <Enter> once (i.e., insert one newline), then hit it twice (i.e., insert two newlines), see what happens. You'll soon learn to get what you want. "Markdown Toggle" is your friend. @@ -672,7 +620,7 @@ This line is also a separate paragraph, and... This line is on its own line, because the previous line ends with two spaces. -## Tables +### Tables Tables aren't part of the core Markdown spec, but they are part of GFM and Markdown Here supports them. @@ -708,16 +656,15 @@ By including colons in the header row, you can align the text within that column | Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | | Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | -## Footnotes - -You can add footnotes to your text as follows.[^1] -[^1]: This is my awesome footnote. +### Footnotes ``` -You can add footnotes to your text as follows.[^1] -[^1]: This is my awesome footnote. +You can add footnotes to your text as follows.[^2] +[^2]: This is my awesome footnote. ``` +You can add footnotes to your text as follows.[^2] + ## Wiki-specific Markdown The following examples show how links inside wikis behave. @@ -752,30 +699,30 @@ A link can be constructed relative to the current wiki page using `./<page>`, - If this snippet was placed on a page at `<your_wiki>/documentation/main`, it would link to `<your_wiki>/documentation/related`: - ```markdown - [Link to Related Page](./related) - ``` + ```markdown + [Link to Related Page](./related) + ``` - If this snippet was placed on a page at `<your_wiki>/documentation/related/content`, it would link to `<your_wiki>/documentation/main`: - ```markdown - [Link to Related Page](../main) - ``` + ```markdown + [Link to Related Page](../main) + ``` - If this snippet was placed on a page at `<your_wiki>/documentation/main`, it would link to `<your_wiki>/documentation/related.md`: - ```markdown - [Link to Related Page](./related.md) - ``` + ```markdown + [Link to Related Page](./related.md) + ``` - If this snippet was placed on a page at `<your_wiki>/documentation/related/content`, it would link to `<your_wiki>/documentation/main.md`: - ```markdown - [Link to Related Page](../main.md) - ``` + ```markdown + [Link to Related Page](../main.md) + ``` ### Wiki - Root link @@ -783,22 +730,25 @@ A link starting with a `/` is relative to the wiki root. - This snippet links to `<wiki_root>/documentation`: - ```markdown - [Link to Related Page](/documentation) - ``` + ```markdown + [Link to Related Page](/documentation) + ``` - This snippet links to `<wiki_root>/miscellaneous.md`: - ```markdown - [Link to Related Page](/miscellaneous.md) - ``` + ```markdown + [Link to Related Page](/miscellaneous.md) + ``` + ## References - This document leveraged heavily from the [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). - The [Markdown Syntax Guide](https://daringfireball.net/projects/markdown/syntax) at Daring Fireball is an excellent resource for a detailed explanation of standard markdown. - [Dillinger.io](http://dillinger.io) is a handy tool for testing standard markdown. +[^1]: This link will be broken if you see this document from the Help page or docs.gitlab.com +[^2]: This is my awesome footnote. + [markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md [rouge]: http://rouge.jneen.net/ "Rouge website" [redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" -[^1]: This link will be broken if you see this document from the Help page or docs.gitlab.com diff --git a/doc/user/permissions.md b/doc/user/permissions.md index d6216a8dd50..cea78864df2 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -32,6 +32,8 @@ The following table depicts the various user permission levels in a project. | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | | See environments | | ✓ | ✓ | ✓ | ✓ | +| Create new environments | | | ✓ | ✓ | ✓ | +| Stop environments | | | ✓ | ✓ | ✓ | | See a list of merge requests | | ✓ | ✓ | ✓ | ✓ | | Manage/Accept merge requests | | | ✓ | ✓ | ✓ | | Create new merge request | | | ✓ | ✓ | ✓ | @@ -45,7 +47,6 @@ The following table depicts the various user permission levels in a project. | Create or update commit status | | | ✓ | ✓ | ✓ | | Update a container registry | | | ✓ | ✓ | ✓ | | Remove a container registry image | | | ✓ | ✓ | ✓ | -| Create new environments | | | ✓ | ✓ | ✓ | | Create new milestones | | | | ✓ | ✓ | | Add new team members | | | | ✓ | ✓ | | Push to protected branches | | | | ✓ | ✓ | @@ -58,7 +59,6 @@ The following table depicts the various user permission levels in a project. | Manage runners | | | | ✓ | ✓ | | Manage build triggers | | | | ✓ | ✓ | | Manage variables | | | | ✓ | ✓ | -| Delete environments | | | | ✓ | ✓ | | Switch visibility level | | | | | ✓ | | Transfer project to another namespace | | | | | ✓ | | Remove project | | | | | ✓ | diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 2ccab4334eb..f728d243cdc 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -413,37 +413,37 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I click link "Hide inline discussion" of the third file' do - page.within '.files [id^=diff]:nth-child(3)' do + page.within '.files>div:nth-child(3)' do find('.js-toggle-diff-comments').trigger('click') end end step 'I click link "Show inline discussion" of the third file' do - page.within '.files [id^=diff]:nth-child(3)' do + page.within '.files>div:nth-child(3)' do find('.js-toggle-diff-comments').trigger('click') end end step 'I should not see a comment like "Line is wrong" in the third file' do - page.within '.files [id^=diff]:nth-child(3)' do + page.within '.files>div:nth-child(3)' do expect(page).not_to have_visible_content "Line is wrong" end end step 'I should see a comment like "Line is wrong" in the third file' do - page.within '.files [id^=diff]:nth-child(3) .note-body > .note-text' do + page.within '.files>div:nth-child(3) .note-body > .note-text' do expect(page).to have_visible_content "Line is wrong" end end step 'I should not see a comment like "Line is wrong here" in the third file' do - page.within '.files [id^=diff]:nth-child(3)' do + page.within '.files>div:nth-child(3)' do expect(page).not_to have_visible_content "Line is wrong here" end end step 'I should see a comment like "Line is wrong here" in the third file' do - page.within '.files [id^=diff]:nth-child(3) .note-body > .note-text' do + page.within '.files>div:nth-child(3) .note-body > .note-text' do expect(page).to have_visible_content "Line is wrong here" end end @@ -456,7 +456,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end - page.within ".files [id^=diff]:nth-child(2) .note-body > .note-text" do + page.within ".files>div:nth-child(2) .note-body > .note-text" do expect(page).to have_content "Line is correct" end end @@ -471,7 +471,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should still see a comment like "Line is correct" in the second file' do - page.within '.files [id^=diff]:nth-child(2) .note-body > .note-text' do + page.within '.files>div:nth-child(2) .note-body > .note-text' do expect(page).to have_visible_content "Line is correct" end end @@ -494,7 +494,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should see comments on the side-by-side diff page' do - page.within '.files [id^=diff]:nth-child(2) .parallel .note-body > .note-text' do + page.within '.files>div:nth-child(2) .parallel .note-body > .note-text' do expect(page).to have_visible_content "Line is correct" end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 6e370e961c4..33cb6fd3704 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -218,7 +218,7 @@ module API expose :assignee, :author, using: Entities::UserBasic expose :subscribed do |issue, options| - issue.subscribed?(options[:current_user]) + issue.subscribed?(options[:current_user], options[:project] || issue.project) end expose :user_notes_count expose :upvotes, :downvotes @@ -248,7 +248,7 @@ module API expose :diff_head_sha, as: :sha expose :merge_commit_sha expose :subscribed do |merge_request, options| - merge_request.subscribed?(options[:current_user]) + merge_request.subscribed?(options[:current_user], options[:project]) end expose :user_notes_count expose :should_remove_source_branch?, as: :should_remove_source_branch @@ -454,7 +454,7 @@ module API end expose :subscribed do |label, options| - label.subscribed?(options[:current_user]) + label.subscribed?(options[:current_user], options[:project]) end end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 3f57b9ab5bc..48ad3b80ae0 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -19,6 +19,8 @@ module API optional :skip_groups, type: Array[Integer], desc: 'Array of group ids to exclude from list' optional :all_available, type: Boolean, desc: 'Show all group that you have access to' optional :search, type: String, desc: 'Search for a specific group' + optional :order_by, type: String, values: %w[name path], default: 'name', desc: 'Order by name or path' + optional :sort, type: String, values: %w[asc desc], default: 'asc', desc: 'Sort by asc (ascending) or desc (descending)' end get do groups = if current_user.admin @@ -31,6 +33,8 @@ module API groups = groups.search(params[:search]) if params[:search].present? groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? + groups = groups.reorder(params[:order_by] => params[:sort].to_sym) + present paginate(groups), with: Entities::Group end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 84cc9200d1b..2c593dbb4ea 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -85,8 +85,8 @@ module API end end - def project_service - @project_service ||= user_project.find_or_initialize_service(params[:service_slug].underscore) + def project_service(project = user_project) + @project_service ||= project.find_or_initialize_service(params[:service_slug].underscore) @project_service || not_found!("Service") end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c9689e6f8ef..eea5b91d4f9 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -120,7 +120,7 @@ module API issues = issues.reorder(issuable_order_by => issuable_sort) - present paginate(issues), with: Entities::Issue, current_user: current_user + present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project end # Get a single project issue @@ -132,7 +132,7 @@ module API # GET /projects/:id/issues/:issue_id get ":id/issues/:issue_id" do @issue = find_project_issue(params[:issue_id]) - present @issue, with: Entities::Issue, current_user: current_user + present @issue, with: Entities::Issue, current_user: current_user, project: user_project end # Create a new project issue @@ -174,7 +174,7 @@ module API end if issue.valid? - present issue, with: Entities::Issue, current_user: current_user + present issue, with: Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end @@ -217,7 +217,7 @@ module API issue = ::Issues::UpdateService.new(user_project, current_user, attrs).execute(issue) if issue.valid? - present issue, with: Entities::Issue, current_user: current_user + present issue, with: Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end @@ -239,7 +239,7 @@ module API begin issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) - present issue, with: Entities::Issue, current_user: current_user + present issue, with: Entities::Issue, current_user: current_user, project: user_project rescue ::Issues::MoveService::MoveError => error render_api_error!(error.message, 400) end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index f9720786e63..4176c7eec06 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -60,7 +60,7 @@ module API end merge_requests = merge_requests.reorder(issuable_order_by => issuable_sort) - present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user + present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user, project: user_project end desc 'Create a merge request' do @@ -87,7 +87,7 @@ module API merge_request = ::MergeRequests::CreateService.new(user_project, current_user, mr_params).execute if merge_request.valid? - present merge_request, with: Entities::MergeRequest, current_user: current_user + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project else handle_merge_request_errors! merge_request.errors end @@ -120,7 +120,7 @@ module API get path do merge_request = user_project.merge_requests.find(params[:merge_request_id]) authorize! :read_merge_request, merge_request - present merge_request, with: Entities::MergeRequest, current_user: current_user + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project end desc 'Get the commits of a merge request' do @@ -167,7 +167,7 @@ module API merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request) if merge_request.valid? - present merge_request, with: Entities::MergeRequest, current_user: current_user + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project else handle_merge_request_errors! merge_request.errors end @@ -212,7 +212,7 @@ module API execute(merge_request) end - present merge_request, with: Entities::MergeRequest, current_user: current_user + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project end desc 'Cancel merge if "Merge when build succeeds" is enabled' do diff --git a/lib/api/milestones.rb b/lib/api/milestones.rb index ba4a84275bc..937c118779d 100644 --- a/lib/api/milestones.rb +++ b/lib/api/milestones.rb @@ -114,7 +114,7 @@ module API } issues = IssuesFinder.new(current_user, finder_params).execute - present paginate(issues), with: Entities::Issue, current_user: current_user + present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project end end end diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 0bb2f74809a..c287ee34a68 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -1,11 +1,13 @@ require 'mime/types' module API - # Projects API class Repositories < Grape::API before { authenticate! } before { authorize! :download_code, user_project } + params do + requires :id, type: String, desc: 'The ID of a project' + end resource :projects do helpers do def handle_project_member_errors(errors) @@ -16,43 +18,35 @@ module API end end - # Get a project repository tree - # - # Parameters: - # id (required) - The ID of a project - # ref_name (optional) - The name of a repository branch or tag, if not given the default branch is used - # recursive (optional) - Used to get a recursive tree - # Example Request: - # GET /projects/:id/repository/tree + desc 'Get a project repository tree' do + success Entities::RepoTreeObject + end + params do + optional :ref_name, type: String, desc: 'The name of a repository branch or tag, if not given the default branch is used' + optional :path, type: String, desc: 'The path of the tree' + optional :recursive, type: Boolean, default: false, desc: 'Used to get a recursive tree' + end get ':id/repository/tree' do ref = params[:ref_name] || user_project.try(:default_branch) || 'master' path = params[:path] || nil - recursive = to_boolean(params[:recursive]) commit = user_project.commit(ref) not_found!('Tree') unless commit - tree = user_project.repository.tree(commit.id, path, recursive: recursive) + tree = user_project.repository.tree(commit.id, path, recursive: params[:recursive]) present tree.sorted_entries, with: Entities::RepoTreeObject end - # Get a raw file contents - # - # Parameters: - # id (required) - The ID of a project - # sha (required) - The commit or branch name - # filepath (required) - The path to the file to display - # Example Request: - # GET /projects/:id/repository/blobs/:sha + desc 'Get a raw file contents' + params do + requires :sha, type: String, desc: 'The commit, branch name, or tag name' + requires :filepath, type: String, desc: 'The path to the file to display' + end get [ ":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob" ] do - required_attributes! [:filepath] - - ref = params[:sha] - repo = user_project.repository - commit = repo.commit(ref) + commit = repo.commit(params[:sha]) not_found! "Commit" unless commit blob = Gitlab::Git::Blob.find(repo, commit.id, params[:filepath]) @@ -61,20 +55,15 @@ module API send_git_blob repo, blob end - # Get a raw blob contents by blob sha - # - # Parameters: - # id (required) - The ID of a project - # sha (required) - The blob's sha - # Example Request: - # GET /projects/:id/repository/raw_blobs/:sha + desc 'Get a raw blob contents by blob sha' + params do + requires :sha, type: String, desc: 'The commit, branch name, or tag name' + end get ':id/repository/raw_blobs/:sha' do - ref = params[:sha] - repo = user_project.repository begin - blob = Gitlab::Git::Blob.raw(repo, ref) + blob = Gitlab::Git::Blob.raw(repo, params[:sha]) rescue not_found! 'Blob' end @@ -84,15 +73,12 @@ module API send_git_blob repo, blob end - # Get a an archive of the repository - # - # Parameters: - # id (required) - The ID of a project - # sha (optional) - the commit sha to download defaults to the tip of the default branch - # Example Request: - # GET /projects/:id/repository/archive - get ':id/repository/archive', - requirements: { format: Gitlab::Regex.archive_formats_regex } do + desc 'Get an archive of the repository' + params do + optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded' + optional :format, type: String, desc: 'The archive format' + end + get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do authorize! :download_code, user_project begin @@ -102,27 +88,22 @@ module API end end - # Compare two branches, tags or commits - # - # Parameters: - # id (required) - The ID of a project - # from (required) - the commit sha or branch name - # to (required) - the commit sha or branch name - # Example Request: - # GET /projects/:id/repository/compare?from=master&to=feature + desc 'Compare two branches, tags, or commits' do + success Entities::Compare + end + params do + requires :from, type: String, desc: 'The commit, branch name, or tag name to start comparison' + requires :to, type: String, desc: 'The commit, branch name, or tag name to stop comparison' + end get ':id/repository/compare' do authorize! :download_code, user_project - required_attributes! [:from, :to] compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to]) present compare, with: Entities::Compare end - # Get repository contributors - # - # Parameters: - # id (required) - The ID of a project - # Example Request: - # GET /projects/:id/repository/contributors + desc 'Get repository contributors' do + success Entities::Contributor + end get ':id/repository/contributors' do authorize! :download_code, user_project diff --git a/lib/api/services.rb b/lib/api/services.rb index fc8598daa32..4d23499aa39 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -1,10 +1,10 @@ module API # Projects API class Services < Grape::API - before { authenticate! } - before { authorize_admin_project } - resource :projects do + before { authenticate! } + before { authorize_admin_project } + # Set <service_slug> service for project # # Example Request: @@ -59,5 +59,28 @@ module API present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin? end end + + resource :projects do + desc 'Trigger a slash command' do + detail 'Added in GitLab 8.13' + end + post ':id/services/:service_slug/trigger' do + project = Project.find_with_namespace(params[:id]) || Project.find_by(id: params[:id]) + + # This is not accurate, but done to prevent leakage of the project names + not_found!('Service') unless project + + service = project_service(project) + + result = service.try(:active?) && service.try(:trigger, params) + + if result + status result[:status] || 200 + present result + else + not_found!('Service') + end + end + end end end diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index 00a79c24f96..10749b34004 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -24,11 +24,11 @@ module API post ":id/#{type}/:subscribable_id/subscription" do resource = instance_exec(params[:subscribable_id], &finder) - if resource.subscribed?(current_user) + if resource.subscribed?(current_user, user_project) not_modified! else - resource.subscribe(current_user) - present resource, with: entity_class, current_user: current_user + resource.subscribe(current_user, user_project) + present resource, with: entity_class, current_user: current_user, project: user_project end end @@ -38,11 +38,11 @@ module API delete ":id/#{type}/:subscribable_id/subscription" do resource = instance_exec(params[:subscribable_id], &finder) - if !resource.subscribed?(current_user) + if !resource.subscribed?(current_user, user_project) not_modified! else - resource.unsubscribe(current_user) - present resource, with: entity_class, current_user: current_user + resource.unsubscribe(current_user, user_project) + present resource, with: entity_class, current_user: current_user, project: user_project end end end diff --git a/lib/gitlab/chat_commands/base_command.rb b/lib/gitlab/chat_commands/base_command.rb new file mode 100644 index 00000000000..e59d69b72b9 --- /dev/null +++ b/lib/gitlab/chat_commands/base_command.rb @@ -0,0 +1,49 @@ +module Gitlab + module ChatCommands + class BaseCommand + QUERY_LIMIT = 5 + + def self.match(_text) + raise NotImplementedError + end + + def self.help_message + raise NotImplementedError + end + + def self.available?(_project) + raise NotImplementedError + end + + def self.allowed?(_user, _ability) + true + end + + def self.can?(object, action, subject) + Ability.allowed?(object, action, subject) + end + + def execute(_) + raise NotImplementedError + end + + def collection + raise NotImplementedError + end + + attr_accessor :project, :current_user, :params + + def initialize(project, user, params = {}) + @project, @current_user, @params = project, user, params.dup + end + + private + + def find_by_iid(iid) + resource = collection.find_by(iid: iid) + + readable?(resource) ? resource : nil + end + end + end +end diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb new file mode 100644 index 00000000000..5f131703d40 --- /dev/null +++ b/lib/gitlab/chat_commands/command.rb @@ -0,0 +1,61 @@ +module Gitlab + module ChatCommands + class Command < BaseCommand + COMMANDS = [ + Gitlab::ChatCommands::IssueShow, + Gitlab::ChatCommands::IssueCreate, + ].freeze + + def execute + command, match = match_command + + if command + if command.allowed?(project, current_user) + present command.new(project, current_user, params).execute(match) + else + access_denied + end + else + help(help_messages) + end + end + + private + + def match_command + match = nil + service = available_commands.find do |klass| + match = klass.match(command) + end + + [service, match] + end + + def help_messages + available_commands.map(&:help_message) + end + + def available_commands + COMMANDS.select do |klass| + klass.available?(project) + end + end + + def command + params[:text] + end + + def help(messages) + Mattermost::Presenter.help(messages, params[:command]) + end + + def access_denied + Mattermost::Presenter.access_denied + end + + def present(resource) + Mattermost::Presenter.present(resource) + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_command.rb b/lib/gitlab/chat_commands/issue_command.rb new file mode 100644 index 00000000000..f1bc36239d5 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_command.rb @@ -0,0 +1,17 @@ +module Gitlab + module ChatCommands + class IssueCommand < BaseCommand + def self.available?(project) + project.issues_enabled? && project.default_issues_tracker? + end + + def collection + project.issues + end + + def readable?(issue) + self.class.can?(current_user, :read_issue, issue) + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_create.rb b/lib/gitlab/chat_commands/issue_create.rb new file mode 100644 index 00000000000..98338ebfa27 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_create.rb @@ -0,0 +1,24 @@ +module Gitlab + module ChatCommands + class IssueCreate < IssueCommand + def self.match(text) + /\Aissue\s+create\s+(?<title>[^\n]*)\n*(?<description>.*)\z/.match(text) + end + + def self.help_message + 'issue create <title>\n<description>' + end + + def self.allowed?(project, user) + can?(user, :create_issue, project) + end + + def execute(match) + title = match[:title] + description = match[:description] + + Issues::CreateService.new(project, current_user, title: title, description: description).execute + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_show.rb b/lib/gitlab/chat_commands/issue_show.rb new file mode 100644 index 00000000000..f5bceb038e5 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_show.rb @@ -0,0 +1,17 @@ +module Gitlab + module ChatCommands + class IssueShow < IssueCommand + def self.match(text) + /\Aissue\s+show\s+(?<iid>\d+)/.match(text) + end + + def self.help_message + "issue show <id>" + end + + def execute(match) + find_by_iid(match[:iid]) + end + end + end +end diff --git a/lib/gitlab/chat_name_token.rb b/lib/gitlab/chat_name_token.rb new file mode 100644 index 00000000000..1b081aa9b1d --- /dev/null +++ b/lib/gitlab/chat_name_token.rb @@ -0,0 +1,45 @@ +require 'json' + +module Gitlab + class ChatNameToken + attr_reader :token + + TOKEN_LENGTH = 50 + EXPIRY_TIME = 10.minutes + + def initialize(token = new_token) + @token = token + end + + def get + Gitlab::Redis.with do |redis| + data = redis.get(redis_key) + JSON.parse(data, symbolize_names: true) if data + end + end + + def store!(params) + Gitlab::Redis.with do |redis| + params = params.to_json + redis.set(redis_key, params, ex: EXPIRY_TIME) + token + end + end + + def delete + Gitlab::Redis.with do |redis| + redis.del(redis_key) + end + end + + private + + def new_token + Devise.friendly_token(TOKEN_LENGTH) + end + + def redis_key + "gitlab:chat_names:#{token}" + end + end +end diff --git a/lib/gitlab/cycle_analytics/base_event.rb b/lib/gitlab/cycle_analytics/base_event.rb new file mode 100644 index 00000000000..486139b1687 --- /dev/null +++ b/lib/gitlab/cycle_analytics/base_event.rb @@ -0,0 +1,57 @@ +module Gitlab + module CycleAnalytics + class BaseEvent + include MetricsTables + + attr_reader :stage, :start_time_attrs, :end_time_attrs, :projections, :query + + def initialize(project:, options:) + @query = EventsQuery.new(project: project, options: options) + @project = project + @options = options + end + + def fetch + update_author! + + event_result.map do |event| + serialize(event) if has_permission?(event['id']) + end + end + + def custom_query(_base_query); end + + def order + @order || @start_time_attrs + end + + private + + def update_author! + return unless event_result.any? && event_result.first['author_id'] + + Updater.update!(event_result, from: 'author_id', to: 'author', klass: User) + end + + def event_result + @event_result ||= @query.execute(self).to_a + end + + def serialize(_event) + raise NotImplementedError.new("Expected #{self.name} to implement serialize(event)") + end + + def has_permission?(id) + allowed_ids.nil? || allowed_ids.include?(id.to_i) + end + + def allowed_ids + nil + end + + def event_result_ids + event_result.map { |event| event['id'] } + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/code_event.rb b/lib/gitlab/cycle_analytics/code_event.rb new file mode 100644 index 00000000000..2afdf0b8518 --- /dev/null +++ b/lib/gitlab/cycle_analytics/code_event.rb @@ -0,0 +1,28 @@ +module Gitlab + module CycleAnalytics + class CodeEvent < BaseEvent + include MergeRequestAllowed + + def initialize(*args) + @stage = :code + @start_time_attrs = issue_metrics_table[:first_mentioned_in_commit_at] + @end_time_attrs = mr_table[:created_at] + @projections = [mr_table[:title], + mr_table[:iid], + mr_table[:id], + mr_table[:created_at], + mr_table[:state], + mr_table[:author_id]] + @order = mr_table[:created_at] + + super(*args) + end + + private + + def serialize(event) + AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/events.rb b/lib/gitlab/cycle_analytics/events.rb new file mode 100644 index 00000000000..2d703d76cbb --- /dev/null +++ b/lib/gitlab/cycle_analytics/events.rb @@ -0,0 +1,38 @@ +module Gitlab + module CycleAnalytics + class Events + def initialize(project:, options:) + @project = project + @options = options + end + + def issue_events + IssueEvent.new(project: @project, options: @options).fetch + end + + def plan_events + PlanEvent.new(project: @project, options: @options).fetch + end + + def code_events + CodeEvent.new(project: @project, options: @options).fetch + end + + def test_events + TestEvent.new(project: @project, options: @options).fetch + end + + def review_events + ReviewEvent.new(project: @project, options: @options).fetch + end + + def staging_events + StagingEvent.new(project: @project, options: @options).fetch + end + + def production_events + ProductionEvent.new(project: @project, options: @options).fetch + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/events_query.rb b/lib/gitlab/cycle_analytics/events_query.rb new file mode 100644 index 00000000000..2418832ccc2 --- /dev/null +++ b/lib/gitlab/cycle_analytics/events_query.rb @@ -0,0 +1,37 @@ +module Gitlab + module CycleAnalytics + class EventsQuery + attr_reader :project + + def initialize(project:, options: {}) + @project = project + @from = options[:from] + @branch = options[:branch] + @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: @from, branch: @branch) + end + + def execute(stage_class) + @stage_class = stage_class + + ActiveRecord::Base.connection.exec_query(query.to_sql) + end + + private + + def query + base_query = @fetcher.base_query_for(@stage_class.stage) + diff_fn = @fetcher.subtract_datetimes_diff(base_query, @stage_class.start_time_attrs, @stage_class.end_time_attrs) + + @stage_class.custom_query(base_query) + + base_query.project(extract_epoch(diff_fn).as('total_time'), *@stage_class.projections).order(@stage_class.order.desc) + end + + def extract_epoch(arel_attribute) + return arel_attribute unless Gitlab::Database.postgresql? + + Arel.sql(%Q{EXTRACT(EPOCH FROM (#{arel_attribute.to_sql}))}) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/issue_allowed.rb b/lib/gitlab/cycle_analytics/issue_allowed.rb new file mode 100644 index 00000000000..a7652a70641 --- /dev/null +++ b/lib/gitlab/cycle_analytics/issue_allowed.rb @@ -0,0 +1,9 @@ +module Gitlab + module CycleAnalytics + module IssueAllowed + def allowed_ids + @allowed_ids ||= IssuesFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/issue_event.rb b/lib/gitlab/cycle_analytics/issue_event.rb new file mode 100644 index 00000000000..705b7e5ce24 --- /dev/null +++ b/lib/gitlab/cycle_analytics/issue_event.rb @@ -0,0 +1,27 @@ +module Gitlab + module CycleAnalytics + class IssueEvent < BaseEvent + include IssueAllowed + + def initialize(*args) + @stage = :issue + @start_time_attrs = issue_table[:created_at] + @end_time_attrs = [issue_metrics_table[:first_associated_with_milestone_at], + issue_metrics_table[:first_added_to_board_at]] + @projections = [issue_table[:title], + issue_table[:iid], + issue_table[:id], + issue_table[:created_at], + issue_table[:author_id]] + + super(*args) + end + + private + + def serialize(event) + AnalyticsIssueSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/merge_request_allowed.rb b/lib/gitlab/cycle_analytics/merge_request_allowed.rb new file mode 100644 index 00000000000..28f6db44759 --- /dev/null +++ b/lib/gitlab/cycle_analytics/merge_request_allowed.rb @@ -0,0 +1,9 @@ +module Gitlab + module CycleAnalytics + module MergeRequestAllowed + def allowed_ids + @allowed_ids ||= MergeRequestsFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/metrics_fetcher.rb b/lib/gitlab/cycle_analytics/metrics_fetcher.rb new file mode 100644 index 00000000000..b71e8735e27 --- /dev/null +++ b/lib/gitlab/cycle_analytics/metrics_fetcher.rb @@ -0,0 +1,60 @@ +module Gitlab + module CycleAnalytics + class MetricsFetcher + include Gitlab::Database::Median + include Gitlab::Database::DateTime + include MetricsTables + + DEPLOYMENT_METRIC_STAGES = %i[production staging] + + def initialize(project:, from:, branch:) + @project = project + @project = project + @from = from + @branch = branch + end + + def calculate_metric(name, start_time_attrs, end_time_attrs) + cte_table = Arel::Table.new("cte_table_for_#{name}") + + # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). + # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). + # We compute the (end_time - start_time) interval, and give it an alias based on the current + # cycle analytics stage. + interval_query = Arel::Nodes::As.new( + cte_table, + subtract_datetimes(base_query_for(name), start_time_attrs, end_time_attrs, name.to_s)) + + median_datetime(cte_table, interval_query, name) + end + + # Join table with a row for every <issue,merge_request> pair (where the merge request + # closes the given issue) with issue and merge request metrics included. The metrics + # are loaded with an inner join, so issues / merge requests without metrics are + # automatically excluded. + def base_query_for(name) + # Load issues + query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])). + join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])). + where(issue_table[:project_id].eq(@project.id)). + where(issue_table[:deleted_at].eq(nil)). + where(issue_table[:created_at].gteq(@from)) + + query = query.where(build_table[:ref].eq(@branch)) if name == :test && @branch + + # Load merge_requests + query = query.join(mr_table, Arel::Nodes::OuterJoin). + on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])). + join(mr_metrics_table). + on(mr_table[:id].eq(mr_metrics_table[:merge_request_id])) + + if DEPLOYMENT_METRIC_STAGES.include?(name) + # Limit to merge requests that have been deployed to production after `@from` + query.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@from)) + end + + query + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/metrics_tables.rb b/lib/gitlab/cycle_analytics/metrics_tables.rb new file mode 100644 index 00000000000..9d25ef078e8 --- /dev/null +++ b/lib/gitlab/cycle_analytics/metrics_tables.rb @@ -0,0 +1,37 @@ +module Gitlab + module CycleAnalytics + module MetricsTables + def mr_metrics_table + MergeRequest::Metrics.arel_table + end + + def mr_table + MergeRequest.arel_table + end + + def mr_diff_table + MergeRequestDiff.arel_table + end + + def mr_closing_issues_table + MergeRequestsClosingIssues.arel_table + end + + def issue_table + Issue.arel_table + end + + def issue_metrics_table + Issue::Metrics.arel_table + end + + def user_table + User.arel_table + end + + def build_table + ::CommitStatus.arel_table + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/plan_event.rb b/lib/gitlab/cycle_analytics/plan_event.rb new file mode 100644 index 00000000000..b1ae215f348 --- /dev/null +++ b/lib/gitlab/cycle_analytics/plan_event.rb @@ -0,0 +1,44 @@ +module Gitlab + module CycleAnalytics + class PlanEvent < BaseEvent + def initialize(*args) + @stage = :plan + @start_time_attrs = issue_metrics_table[:first_associated_with_milestone_at] + @end_time_attrs = [issue_metrics_table[:first_added_to_board_at], + issue_metrics_table[:first_mentioned_in_commit_at]] + @projections = [mr_diff_table[:st_commits].as('commits'), + issue_metrics_table[:first_mentioned_in_commit_at]] + + super(*args) + end + + def custom_query(base_query) + base_query.join(mr_diff_table).on(mr_diff_table[:merge_request_id].eq(mr_table[:id])) + end + + private + + def serialize(event) + st_commit = first_time_reference_commit(event.delete('commits'), event) + + return unless st_commit + + serialize_commit(event, st_commit, query) + end + + def first_time_reference_commit(commits, event) + YAML.load(commits).find do |commit| + next unless commit[:committed_date] && event['first_mentioned_in_commit_at'] + + commit[:committed_date].to_i == DateTime.parse(event['first_mentioned_in_commit_at'].to_s).to_i + end + end + + def serialize_commit(event, st_commit, query) + commit = Commit.new(Gitlab::Git::Commit.new(st_commit), @project) + + AnalyticsCommitSerializer.new(project: @project, total_time: event['total_time']).represent(commit).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/production_event.rb b/lib/gitlab/cycle_analytics/production_event.rb new file mode 100644 index 00000000000..4868c3c6237 --- /dev/null +++ b/lib/gitlab/cycle_analytics/production_event.rb @@ -0,0 +1,26 @@ +module Gitlab + module CycleAnalytics + class ProductionEvent < BaseEvent + include IssueAllowed + + def initialize(*args) + @stage = :production + @start_time_attrs = issue_table[:created_at] + @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at] + @projections = [issue_table[:title], + issue_table[:iid], + issue_table[:id], + issue_table[:created_at], + issue_table[:author_id]] + + super(*args) + end + + private + + def serialize(event) + AnalyticsIssueSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/review_event.rb b/lib/gitlab/cycle_analytics/review_event.rb new file mode 100644 index 00000000000..b394a02cc52 --- /dev/null +++ b/lib/gitlab/cycle_analytics/review_event.rb @@ -0,0 +1,25 @@ +module Gitlab + module CycleAnalytics + class ReviewEvent < BaseEvent + include MergeRequestAllowed + + def initialize(*args) + @stage = :review + @start_time_attrs = mr_table[:created_at] + @end_time_attrs = mr_metrics_table[:merged_at] + @projections = [mr_table[:title], + mr_table[:iid], + mr_table[:id], + mr_table[:created_at], + mr_table[:state], + mr_table[:author_id]] + + super(*args) + end + + def serialize(event) + AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/staging_event.rb b/lib/gitlab/cycle_analytics/staging_event.rb new file mode 100644 index 00000000000..a1f30b716f6 --- /dev/null +++ b/lib/gitlab/cycle_analytics/staging_event.rb @@ -0,0 +1,31 @@ +module Gitlab + module CycleAnalytics + class StagingEvent < BaseEvent + def initialize(*args) + @stage = :staging + @start_time_attrs = mr_metrics_table[:merged_at] + @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at] + @projections = [build_table[:id]] + @order = build_table[:created_at] + + super(*args) + end + + def fetch + Updater.update!(event_result, from: 'id', to: 'build', klass: ::Ci::Build) + + super + end + + def custom_query(base_query) + base_query.join(build_table).on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id])) + end + + private + + def serialize(event) + AnalyticsBuildSerializer.new.represent(event['build']).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/test_event.rb b/lib/gitlab/cycle_analytics/test_event.rb new file mode 100644 index 00000000000..d553d0b5aec --- /dev/null +++ b/lib/gitlab/cycle_analytics/test_event.rb @@ -0,0 +1,13 @@ +module Gitlab + module CycleAnalytics + class TestEvent < StagingEvent + def initialize(*args) + super(*args) + + @stage = :test + @start_time_attrs = mr_metrics_table[:latest_build_started_at] + @end_time_attrs = mr_metrics_table[:latest_build_finished_at] + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/updater.rb b/lib/gitlab/cycle_analytics/updater.rb new file mode 100644 index 00000000000..953268ebd46 --- /dev/null +++ b/lib/gitlab/cycle_analytics/updater.rb @@ -0,0 +1,30 @@ +module Gitlab + module CycleAnalytics + class Updater + def self.update!(*args) + new(*args).update! + end + + def initialize(event_result, from:, to:, klass:) + @event_result = event_result + @klass = klass + @from = from + @to = to + end + + def update! + @event_result.each do |event| + event[@to] = items[event.delete(@from).to_i].first + end + end + + def result_ids + @event_result.map { |event| event[@from] } + end + + def items + @items ||= @klass.find(result_ids).group_by { |item| item['id'] } + end + end + end +end diff --git a/lib/gitlab/database/date_time.rb b/lib/gitlab/database/date_time.rb index b6a89f715fd..25e56998038 100644 --- a/lib/gitlab/database/date_time.rb +++ b/lib/gitlab/database/date_time.rb @@ -7,21 +7,25 @@ module Gitlab # # Note: For MySQL, the interval is returned in seconds. # For PostgreSQL, the interval is returned as an INTERVAL type. - def subtract_datetimes(query_so_far, end_time_attrs, start_time_attrs, as) - diff_fn = if Gitlab::Database.postgresql? - Arel::Nodes::Subtraction.new( - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs))) - elsif Gitlab::Database.mysql? - Arel::Nodes::NamedFunction.new( - "TIMESTAMPDIFF", - [Arel.sql('second'), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))]) - end + def subtract_datetimes(query_so_far, start_time_attrs, end_time_attrs, as) + diff_fn = subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs) query_so_far.project(diff_fn.as(as)) end + + def subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs) + if Gitlab::Database.postgresql? + Arel::Nodes::Subtraction.new( + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs))) + elsif Gitlab::Database.mysql? + Arel::Nodes::NamedFunction.new( + "TIMESTAMPDIFF", + [Arel.sql('second'), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))]) + end + end end end end diff --git a/lib/gitlab/email/html_parser.rb b/lib/gitlab/email/html_parser.rb new file mode 100644 index 00000000000..a4ca62bfc41 --- /dev/null +++ b/lib/gitlab/email/html_parser.rb @@ -0,0 +1,34 @@ +module Gitlab + module Email + class HTMLParser + def self.parse_reply(raw_body) + new(raw_body).filtered_text + end + + attr_reader :raw_body + def initialize(raw_body) + @raw_body = raw_body + end + + def document + @document ||= Nokogiri::HTML.parse(raw_body) + end + + def filter_replies! + document.xpath('//blockquote').each(&:remove) + document.xpath('//table').each(&:remove) + end + + def filtered_html + @filtered_html ||= begin + filter_replies! + document.inner_html + end + end + + def filtered_text + @filtered_text ||= Html2Text.convert(filtered_html) + end + end + end +end diff --git a/lib/gitlab/email/reply_parser.rb b/lib/gitlab/email/reply_parser.rb index 3411eb1d9ce..85402c2a278 100644 --- a/lib/gitlab/email/reply_parser.rb +++ b/lib/gitlab/email/reply_parser.rb @@ -23,19 +23,26 @@ module Gitlab private def select_body(message) - text = message.text_part if message.multipart? - text ||= message if message.content_type !~ /text\/html/ + if message.multipart? + part = message.text_part || message.html_part || message + else + part = message + end - return "" unless text + decoded = fix_charset(part) - text = fix_charset(text) + return "" unless decoded # Certain trigger phrases that means we didn't parse correctly - if text =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/ + if decoded =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/ return "" end - text + if (part.content_type || '').include? 'text/html' + HTMLParser.parse_reply(decoded) + else + decoded + end end # Force encoding to UTF-8 on a Mail::Message or Mail::Part diff --git a/lib/gitlab/github_import/importer.rb b/lib/gitlab/github_import/importer.rb index 90cf38a8513..281b65bdeba 100644 --- a/lib/gitlab/github_import/importer.rb +++ b/lib/gitlab/github_import/importer.rb @@ -20,10 +20,18 @@ module Gitlab end def execute + # The ordering of importing is important here due to the way GitHub structures their data + # 1. Labels are required by other items while not having a dependency on anything else + # so need to be first + # 2. Pull requests must come before issues. Every pull request is also an issue but not + # all issues are pull requests. Only the issue entity has labels defined in GitHub. GitLab + # doesn't structure data like this so we need to make sure that we've created the MRs + # before we attempt to add the labels defined in the GitHub issue for the related, already + # imported, pull request import_labels import_milestones - import_issues import_pull_requests + import_issues import_comments(:issues) import_comments(:pull_requests) import_wiki @@ -79,13 +87,17 @@ module Gitlab issues.each do |raw| gh_issue = IssueFormatter.new(project, raw) - if gh_issue.valid? - begin - issue = gh_issue.create! - apply_labels(issue, raw) - rescue => e - errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } - end + begin + issuable = + if gh_issue.pull_request? + MergeRequest.find_by_iid(gh_issue.number) + else + gh_issue.create! + end + + apply_labels(issuable, raw) + rescue => e + errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } end end end @@ -101,8 +113,7 @@ module Gitlab restore_source_branch(pull_request) unless pull_request.source_branch_exists? restore_target_branch(pull_request) unless pull_request.target_branch_exists? - merge_request = pull_request.create! - apply_labels(merge_request, raw) + pull_request.create! rescue => e errors << { type: :pull_request, url: Gitlab::UrlSanitizer.sanitize(pull_request.url), errors: e.message } ensure @@ -133,21 +144,14 @@ module Gitlab remove_branch(pull_request.target_branch_name) unless pull_request.target_branch_exists? end - def apply_labels(issuable, raw_issuable) - # GH returns labels for issues but not for pull requests! - labels = if issuable.is_a?(MergeRequest) - client.labels_for_issue(repo, raw_issuable.number) - else - raw_issuable.labels - end + def apply_labels(issuable, raw) + return unless raw.labels.count > 0 - if labels.count > 0 - label_ids = labels - .map { |attrs| @labels[attrs.name] } - .compact + label_ids = raw.labels + .map { |attrs| @labels[attrs.name] } + .compact - issuable.update_attribute(:label_ids, label_ids) - end + issuable.update_attribute(:label_ids, label_ids) end def import_comments(issuable_type) diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 8c32ac59fc5..887690bcc7c 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -32,8 +32,8 @@ module Gitlab raw_data.number end - def valid? - raw_data.pull_request.nil? + def pull_request? + raw_data.pull_request.present? end private diff --git a/lib/gitlab/mail_room.rb b/lib/gitlab/mail_room.rb index a5220d92312..3503fac40e8 100644 --- a/lib/gitlab/mail_room.rb +++ b/lib/gitlab/mail_room.rb @@ -31,6 +31,7 @@ module Gitlab config[:ssl] = false if config[:ssl].nil? config[:start_tls] = false if config[:start_tls].nil? config[:mailbox] = 'inbox' if config[:mailbox].nil? + config[:idle_timeout] = 60 if config[:idle_timeout].nil? if config[:enabled] && config[:address] gitlab_redis = Gitlab::Redis.new(rails_env) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 155ca47e04c..47ea8b7e82e 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -2,7 +2,14 @@ module Gitlab module Regex extend self - NAMESPACE_REGEX_STR = '(?:[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_])(?<!\.git|\.atom)'.freeze + # The namespace regex is used in Javascript to validate usernames in the "Register" form. However, Javascript + # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`. + # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to + # allow non-regex validatiions, etc), `NAMESPACE_REGEX_STR_SIMPLE` serves as a Javascript-compatible version of + # `NAMESPACE_REGEX_STR`, with the negative lookbehind assertion removed. This means that the client-side validation + # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation. + NAMESPACE_REGEX_STR_SIMPLE = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze + NAMESPACE_REGEX_STR = "(?:#{NAMESPACE_REGEX_STR_SIMPLE})(?<!\.git|\.atom)".freeze def namespace_regex @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze diff --git a/lib/mattermost/presenter.rb b/lib/mattermost/presenter.rb new file mode 100644 index 00000000000..bfbb089eb02 --- /dev/null +++ b/lib/mattermost/presenter.rb @@ -0,0 +1,117 @@ +module Mattermost + class Presenter + class << self + include Gitlab::Routing.url_helpers + + def authorize_chat_name(url) + message = if url + ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{url})." + else + ":sweat_smile: Couldn't identify you, nor can I autorize you!" + end + + ephemeral_response(message) + end + + def help(commands, trigger) + if commands.none? + ephemeral_response("No commands configured") + else + commands.map! { |command| "#{trigger} #{command}" } + message = header_with_list("Available commands", commands) + + ephemeral_response(message) + end + end + + def present(resource) + return not_found unless resource + + if resource.respond_to?(:count) + if resource.count > 1 + return multiple_resources(resource) + elsif resource.count == 0 + return not_found + else + resource = resource.first + end + end + + single_resource(resource) + end + + def access_denied + ephemeral_response("Whoops! That action is not allowed. This incident will be [reported](https://xkcd.com/838/).") + end + + private + + def not_found + ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:") + end + + def single_resource(resource) + return error(resource) if resource.errors.any? || !resource.persisted? + + message = "### #{title(resource)}" + message << "\n\n#{resource.description}" if resource.description + + in_channel_response(message) + end + + def multiple_resources(resources) + resources.map! { |resource| title(resource) } + + message = header_with_list("Multiple results were found:", resources) + + ephemeral_response(message) + end + + def error(resource) + message = header_with_list("The action was not successful, because:", resource.errors.messages) + + ephemeral_response(message) + end + + def title(resource) + "[#{resource.to_reference} #{resource.title}](#{url(resource)})" + end + + def header_with_list(header, items) + message = [header] + + items.each do |item| + message << "- #{item}" + end + + message.join("\n") + end + + def url(resource) + url_for( + [ + resource.project.namespace.becomes(Namespace), + resource.project, + resource + ] + ) + end + + def ephemeral_response(message) + { + response_type: :ephemeral, + text: message, + status: 200 + } + end + + def in_channel_response(message) + { + response_type: :in_channel, + text: message, + status: 200 + } + end + end + end +end diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake index 3117075b08b..7db0779def8 100644 --- a/lib/tasks/gitlab/dev.rake +++ b/lib/tasks/gitlab/dev.rake @@ -4,10 +4,7 @@ namespace :gitlab do task :ee_compat_check, [:branch] => :environment do |_, args| opts = if ENV['CI'] - { - branch: ENV['CI_BUILD_REF_NAME'], - ce_repo: ENV['CI_BUILD_REPO'] - } + { branch: ENV['CI_BUILD_REF_NAME'] } else unless args[:branch] puts "Must specify a branch as an argument".color(:red) diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 22bf3055538..294fae95752 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -47,6 +47,7 @@ describe 'mail_room.yml' do expect(mailbox[:email]).to eq('gitlab-incoming@gmail.com') expect(mailbox[:password]).to eq('[REDACTED]') expect(mailbox[:name]).to eq('inbox') + expect(mailbox[:idle_timeout]).to eq(60) redis_url = gitlab_redis.url sentinels = gitlab_redis.sentinels diff --git a/spec/controllers/groups/labels_controller_spec.rb b/spec/controllers/groups/labels_controller_spec.rb new file mode 100644 index 00000000000..899d8ebd12b --- /dev/null +++ b/spec/controllers/groups/labels_controller_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Groups::LabelsController do + let(:group) { create(:group) } + let(:user) { create(:user) } + + before do + group.add_owner(user) + + sign_in(user) + end + + describe 'POST #toggle_subscription' do + it 'allows user to toggle subscription on group labels' do + label = create(:group_label, group: group) + + post :toggle_subscription, group_id: group.to_param, id: label.to_param + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index cbe0417a4a7..299d2c981d3 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -25,7 +25,7 @@ describe Projects::Boards::IssuesController do create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) create(:labeled_issue, project: project, labels: [development], assignee: johndoe) - issue.subscribe(johndoe) + issue.subscribe(johndoe, project) list_issues user: user, board: board, list: list2 diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index ac3469cb8a9..028ea067a97 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -67,4 +67,62 @@ describe Projects::ForksController do end end end + + describe 'GET new' do + def get_new + get :new, + namespace_id: project.namespace.to_param, + project_id: project.to_param + end + + context 'when user is signed in' do + it 'responds with status 200' do + sign_in(user) + + get_new + + expect(response).to have_http_status(200) + end + end + + context 'when user is not signed in' do + it 'redirects to the sign-in page' do + sign_out(user) + + get_new + + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'POST create' do + def post_create + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + namespace_key: user.namespace.id + end + + context 'when user is signed in' do + it 'responds with status 302' do + sign_in(user) + + post_create + + expect(response).to have_http_status(302) + expect(response).to redirect_to(namespace_project_import_path(user.namespace, project)) + end + end + + context 'when user is not signed in' do + it 'redirects to the sign-in page' do + sign_out(user) + + post_create + + expect(response).to redirect_to(new_user_session_path) + end + end + end end diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb index 8faecec0063..ec6cea5c0f4 100644 --- a/spec/controllers/projects/labels_controller_spec.rb +++ b/spec/controllers/projects/labels_controller_spec.rb @@ -72,14 +72,8 @@ describe Projects::LabelsController do end describe 'POST #generate' do - let(:admin) { create(:admin) } - - before do - sign_in(admin) - end - context 'personal project' do - let(:personal_project) { create(:empty_project) } + let(:personal_project) { create(:empty_project, namespace: user.namespace) } it 'creates labels' do post :generate, namespace_id: personal_project.namespace.to_param, project_id: personal_project.to_param @@ -96,4 +90,26 @@ describe Projects::LabelsController do end end end + + describe 'POST #toggle_subscription' do + it 'allows user to toggle subscription on project labels' do + label = create(:label, project: project) + + toggle_subscription(label) + + expect(response).to have_http_status(200) + end + + it 'allows user to toggle subscription on group labels' do + group_label = create(:group_label, group: group) + + toggle_subscription(group_label) + + expect(response).to have_http_status(200) + end + + def toggle_subscription(label) + post :toggle_subscription, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label.to_param + end + end end diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb index 191e290a118..954fc2eaf21 100644 --- a/spec/controllers/sent_notifications_controller_spec.rb +++ b/spec/controllers/sent_notifications_controller_spec.rb @@ -3,11 +3,11 @@ require 'rails_helper' describe SentNotificationsController, type: :controller do let(:user) { create(:user) } let(:project) { create(:empty_project) } - let(:sent_notification) { create(:sent_notification, noteable: issue, recipient: user) } + let(:sent_notification) { create(:sent_notification, project: project, noteable: issue, recipient: user) } let(:issue) do create(:issue, project: project, author: user) do |issue| - issue.subscriptions.create(user: user, subscribed: true) + issue.subscriptions.create(user: user, project: project, subscribed: true) end end @@ -17,7 +17,7 @@ describe SentNotificationsController, type: :controller do before { get(:unsubscribe, id: sent_notification.reply_key, force: true) } it 'unsubscribes the user' do - expect(issue.subscribed?(user)).to be_falsey + expect(issue.subscribed?(user, project)).to be_falsey end it 'sets the flash message' do @@ -33,7 +33,7 @@ describe SentNotificationsController, type: :controller do before { get(:unsubscribe, id: sent_notification.reply_key) } it 'does not unsubscribe the user' do - expect(issue.subscribed?(user)).to be_truthy + expect(issue.subscribed?(user, project)).to be_truthy end it 'does not set the flash message' do @@ -53,7 +53,7 @@ describe SentNotificationsController, type: :controller do before { get(:unsubscribe, id: sent_notification.reply_key.reverse) } it 'does not unsubscribe the user' do - expect(issue.subscribed?(user)).to be_truthy + expect(issue.subscribed?(user, project)).to be_truthy end it 'does not set the flash message' do @@ -69,7 +69,7 @@ describe SentNotificationsController, type: :controller do before { get(:unsubscribe, id: sent_notification.reply_key, force: true) } it 'unsubscribes the user' do - expect(issue.subscribed?(user)).to be_falsey + expect(issue.subscribed?(user, project)).to be_falsey end it 'sets the flash message' do @@ -85,14 +85,14 @@ describe SentNotificationsController, type: :controller do context 'when the force param is not passed' do let(:merge_request) do create(:merge_request, source_project: project, author: user) do |merge_request| - merge_request.subscriptions.create(user: user, subscribed: true) + merge_request.subscriptions.create(user: user, project: project, subscribed: true) end end - let(:sent_notification) { create(:sent_notification, noteable: merge_request, recipient: user) } + let(:sent_notification) { create(:sent_notification, project: project, noteable: merge_request, recipient: user) } before { get(:unsubscribe, id: sent_notification.reply_key) } it 'unsubscribes the user' do - expect(merge_request.subscribed?(user)).to be_falsey + expect(merge_request.subscribed?(user, project)).to be_falsey end it 'sets the flash message' do diff --git a/spec/factories/chat_names.rb b/spec/factories/chat_names.rb new file mode 100644 index 00000000000..24225468d55 --- /dev/null +++ b/spec/factories/chat_names.rb @@ -0,0 +1,16 @@ +FactoryGirl.define do + factory :chat_name, class: ChatName do + user factory: :user + service factory: :service + + team_id 'T0001' + team_domain 'Awesome Team' + + sequence :chat_id do |n| + "U#{n}" + end + sequence :chat_name do |n| + "user#{n}" + end + end +end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index 6f24bf58d14..29ad1af9fd9 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -3,8 +3,9 @@ FactoryGirl.define do sha '97de212e80737a608d939f648d959671fb0a0142' ref 'master' tag false + user project nil - + deployable factory: :ci_build environment factory: :environment after(:build) do |deployment, evaluator| diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 846cccfc7fa..0852dda6b29 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -4,5 +4,33 @@ FactoryGirl.define do project factory: :empty_project sequence(:external_url) { |n| "https://env#{n}.example.gitlab.com" } + + trait :with_review_app do |environment| + project + + transient do + ref 'master' + end + + # At this point `review app` is an ephemeral concept related to + # deployments being deployed for given environment. There is no + # first-class `review app` available so we need to create set of + # interconnected objects to simulate a review app. + # + after(:create) do |environment, evaluator| + deployment = create(:deployment, + environment: environment, + project: environment.project, + ref: evaluator.ref, + sha: environment.project.commit(evaluator.ref).id) + + teardown_build = create(:ci_build, :manual, + name: "#{deployment.environment.name}:teardown", + pipeline: deployment.deployable.pipeline) + + deployment.update_column(:on_stop, teardown_build.name) + environment.update_attribute(:deployments, [deployment]) + end + end end end diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb new file mode 100644 index 00000000000..b11b0a0a17b --- /dev/null +++ b/spec/factories/subscriptions.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :subscription do + user + project factory: :empty_project + subscribable factory: :issue + end +end diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb new file mode 100644 index 00000000000..f6d625fa7f6 --- /dev/null +++ b/spec/features/admin/admin_groups_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +feature 'Admin Groups', feature: true do + let(:internal) { Gitlab::VisibilityLevel::INTERNAL } + + before do + login_as(:admin) + + stub_application_setting(default_group_visibility: internal) + end + + describe 'create a group' do + scenario 'shows the visibility level radio populated with the default value' do + visit new_admin_group_path + + expect_selected_visibility(internal) + end + end + + describe 'group edit' do + scenario 'shows the visibility level radio populated with the group visibility_level value' do + group = create(:group, :private) + + visit edit_admin_group_path(group) + + expect_selected_visibility(group.visibility_level) + end + end + + def expect_selected_visibility(level) + selector = "#group_visibility_level_#{level}[checked=checked]" + + expect(page).to have_selector(selector, count: 1) + end +end diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index b565586ee14..1fe509c2cac 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -6,8 +6,8 @@ feature 'Environments', feature: true do given(:role) { :developer } background do - login_as(user) project.team << [user, role] + login_as(user) end describe 'when showing environments' do @@ -16,7 +16,7 @@ feature 'Environments', feature: true do given!(:manual) { } before do - visit namespace_project_environments_path(project.namespace, project) + visit_environments(project) end context 'shows two tabs' do @@ -142,7 +142,7 @@ feature 'Environments', feature: true do given!(:manual) { } before do - visit namespace_project_environment_path(project.namespace, project, environment) + visit_environment(environment) end context 'without deployments' do @@ -152,7 +152,9 @@ feature 'Environments', feature: true do end context 'with deployments' do - given(:deployment) { create(:deployment, environment: environment) } + given(:deployment) do + create(:deployment, environment: environment, deployable: nil) + end scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) @@ -232,7 +234,7 @@ feature 'Environments', feature: true do describe 'when creating a new environment' do before do - visit namespace_project_environments_path(project.namespace, project) + visit_environments(project) end context 'when logged as developer' do @@ -271,4 +273,56 @@ feature 'Environments', feature: true do end end end + + feature 'auto-close environment when branch deleted' do + given(:project) { create(:project) } + + given!(:environment) do + create(:environment, :with_review_app, project: project, + ref: 'feature') + end + + scenario 'user visits environment page' do + visit_environment(environment) + + expect(page).to have_link('Stop') + end + + scenario 'user deletes the branch with running environment' do + visit namespace_project_branches_path(project.namespace, project) + + remove_branch_with_hooks(project, user, 'feature') do + page.within('.js-branch-feature') { find('a.btn-remove').click } + end + + visit_environment(environment) + + expect(page).to have_no_link('Stop') + end + + ## + # This is a workaround for problem described in #24543 + # + def remove_branch_with_hooks(project, user, branch) + params = { + oldrev: project.commit(branch).id, + newrev: Gitlab::Git::BLANK_SHA, + ref: "refs/heads/#{branch}" + } + + yield + + GitPushService.new(project, user, params).execute + end + end + + def visit_environments(project) + visit namespace_project_environments_path(project.namespace, project) + end + + def visit_environment(environment) + visit namespace_project_environment_path(environment.project.namespace, + environment.project, + environment) + end end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 4b1aec8bf71..bc068b5e7e0 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -1,7 +1,9 @@ require 'rails_helper' feature 'Issue Sidebar', feature: true do - let(:project) { create(:project) } + include WaitForAjax + + let(:project) { create(:project, :public) } let(:issue) { create(:issue, project: project) } let!(:user) { create(:user)} @@ -10,6 +12,37 @@ feature 'Issue Sidebar', feature: true do login_as(user) end + context 'assignee', js: true do + let(:user2) { create(:user) } + let(:issue2) { create(:issue, project: project, author: user2) } + + before do + project.team << [user, :developer] + visit_issue(project, issue2) + + find('.block.assignee .edit-link').click + + wait_for_ajax + end + + it 'shows author in assignee dropdown' do + page.within '.dropdown-menu-user' do + expect(page).to have_content(user2.name) + end + end + + it 'shows author when filtering assignee dropdown' do + page.within '.dropdown-menu-user' do + find('.dropdown-input-field').native.send_keys user2.name + sleep 1 # Required to wait for end of input delay + + wait_for_ajax + + expect(page).to have_content(user2.name) + end + end + end + context 'as a allowed user' do before do project.team << [user, :developer] diff --git a/spec/features/profiles/chat_names_spec.rb b/spec/features/profiles/chat_names_spec.rb new file mode 100644 index 00000000000..6f6f7029c0b --- /dev/null +++ b/spec/features/profiles/chat_names_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +feature 'Profile > Chat', feature: true do + given(:user) { create(:user) } + given(:service) { create(:service) } + + before do + login_as(user) + end + + describe 'uses authorization link' do + given(:params) do + { team_id: 'T00', team_domain: 'my_chat_team', user_id: 'U01', user_name: 'my_chat_user' } + end + given!(:authorize_url) { ChatNames::AuthorizeUserService.new(service, params).execute } + given(:authorize_path) { URI.parse(authorize_url).request_uri } + + before do + visit authorize_path + end + + context 'clicks authorize' do + before do + click_button 'Authorize' + end + + scenario 'goes to list of chat names and see chat account' do + expect(page.current_path).to eq(profile_chat_names_path) + expect(page).to have_content('my_chat_team') + expect(page).to have_content('my_chat_user') + end + + scenario 'second use of link is denied' do + visit authorize_path + + expect(page).to have_http_status(:not_found) + end + end + + context 'clicks deny' do + before do + click_button 'Deny' + end + + scenario 'goes to list of chat names and do not see chat account' do + expect(page.current_path).to eq(profile_chat_names_path) + expect(page).not_to have_content('my_chat_team') + expect(page).not_to have_content('my_chat_user') + end + + scenario 'second use of link is denied' do + visit authorize_path + + expect(page).to have_http_status(:not_found) + end + end + end + + describe 'visits chat accounts' do + given!(:chat_name) { create(:chat_name, user: user, service: service) } + + before do + visit profile_chat_names_path + end + + scenario 'sees chat user' do + expect(page).to have_content(chat_name.team_domain) + expect(page).to have_content(chat_name.chat_name) + end + + scenario 'removes chat account' do + click_link 'Remove' + + expect(page).to have_content("You don't have any active chat names.") + end + end +end diff --git a/spec/features/projects/commits/cherry_pick_spec.rb b/spec/features/projects/commits/cherry_pick_spec.rb index e45e3a36d01..d46d9e9399e 100644 --- a/spec/features/projects/commits/cherry_pick_spec.rb +++ b/spec/features/projects/commits/cherry_pick_spec.rb @@ -64,7 +64,7 @@ describe 'Cherry-pick Commits' do context "I cherry-pick a commit from a different branch", js: true do it do - find('.commit-action-buttons a.dropdown-toggle').click + find('.header-action-buttons a.dropdown-toggle').click find(:css, "a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do diff --git a/spec/features/projects/labels/subscription_spec.rb b/spec/features/projects/labels/subscription_spec.rb new file mode 100644 index 00000000000..3130d87fba5 --- /dev/null +++ b/spec/features/projects/labels/subscription_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +feature 'Labels subscription', feature: true do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:empty_project, :public, namespace: group) } + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:feature) { create(:group_label, group: group, title: 'feature') } + + context 'when signed in' do + before do + project.team << [user, :developer] + login_as user + end + + scenario 'users can subscribe/unsubscribe to labels', js: true do + visit namespace_project_labels_path(project.namespace, project) + + expect(page).to have_content('bug') + expect(page).to have_content('feature') + + within "#project_label_#{bug.id}" do + expect(page).not_to have_button 'Unsubscribe' + + click_button 'Subscribe' + + expect(page).not_to have_button 'Subscribe' + expect(page).to have_button 'Unsubscribe' + + click_button 'Unsubscribe' + + expect(page).to have_button 'Subscribe' + expect(page).not_to have_button 'Unsubscribe' + end + + within "#group_label_#{feature.id}" do + expect(page).not_to have_button 'Unsubscribe' + + click_link_on_dropdown('Group level') + + expect(page).not_to have_selector('.dropdown-group-label') + expect(page).to have_button 'Unsubscribe' + + click_button 'Unsubscribe' + + expect(page).to have_selector('.dropdown-group-label') + + click_link_on_dropdown('Project level') + + expect(page).not_to have_selector('.dropdown-group-label') + expect(page).to have_button 'Unsubscribe' + end + end + end + + context 'when not signed in' do + it 'users can not subscribe/unsubscribe to labels' do + visit namespace_project_labels_path(project.namespace, project) + + expect(page).to have_content 'bug' + expect(page).to have_content 'feature' + expect(page).not_to have_button('Subscribe') + expect(page).not_to have_selector('.dropdown-group-label') + end + end + + def click_link_on_dropdown(text) + find('.dropdown-group-label').click + + page.within('.dropdown-group-label') do + find('a.js-subscribe-button', text: text).click + end + end +end diff --git a/spec/features/projects/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb index db56a50e058..002c6f6b359 100644 --- a/spec/features/projects/pipelines_spec.rb +++ b/spec/features/projects/pipelines_spec.rb @@ -146,7 +146,8 @@ describe "Pipelines" do end describe 'GET /:project/pipelines/:id' do - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do @success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build') diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index 33b52d1547e..e2d9cfdd0b0 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -26,11 +26,11 @@ describe 'Unsubscribe links', feature: true do expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last) expect(page).to have_text(%(Unsubscribe from issue #{issue.title} (#{issue.to_reference}))) expect(page).to have_text(%(Are you sure you want to unsubscribe from issue #{issue.title} (#{issue.to_reference})?)) - expect(issue.subscribed?(recipient)).to be_truthy + expect(issue.subscribed?(recipient, project)).to be_truthy click_link 'Unsubscribe' - expect(issue.subscribed?(recipient)).to be_falsey + expect(issue.subscribed?(recipient, project)).to be_falsey expect(current_path).to eq new_user_session_path end @@ -38,11 +38,11 @@ describe 'Unsubscribe links', feature: true do visit body_link expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last) - expect(issue.subscribed?(recipient)).to be_truthy + expect(issue.subscribed?(recipient, project)).to be_truthy click_link 'Cancel' - expect(issue.subscribed?(recipient)).to be_truthy + expect(issue.subscribed?(recipient, project)).to be_truthy expect(current_path).to eq new_user_session_path end end @@ -51,7 +51,7 @@ describe 'Unsubscribe links', feature: true do visit header_link expect(page).to have_text('unsubscribed') - expect(issue.subscribed?(recipient)).to be_falsey + expect(issue.subscribed?(recipient, project)).to be_falsey end end @@ -62,14 +62,14 @@ describe 'Unsubscribe links', feature: true do visit body_link expect(page).to have_text('unsubscribed') - expect(issue.subscribed?(recipient)).to be_falsey + expect(issue.subscribed?(recipient, project)).to be_falsey end it 'unsubscribes from the issue when visiting the link from the header' do visit header_link expect(page).to have_text('unsubscribed') - expect(issue.subscribed?(recipient)).to be_falsey + expect(issue.subscribed?(recipient, project)).to be_falsey end end end diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index 111ca7f7a70..afa98f3f715 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -74,16 +74,29 @@ feature 'Users', feature: true, js: true do visit new_user_session_path click_link 'Register' end + + scenario 'doesn\'t show an error border if the username is available' do + fill_in username_input, with: 'new-user' + wait_for_ajax + expect(find('.username')).not_to have_css '.gl-field-error-outline' + end + + scenario 'does not show an error border if the username contains dots (.)' do + fill_in username_input, with: 'new.user.username' + wait_for_ajax + expect(find('.username')).not_to have_css '.gl-field-error-outline' + end + scenario 'shows an error border if the username already exists' do fill_in username_input, with: user.username wait_for_ajax expect(find('.username')).to have_css '.gl-field-error-outline' end - scenario 'doesn\'t show an error border if the username is available' do - fill_in username_input, with: 'new-user' + scenario 'shows an error border if the username contains special characters' do + fill_in username_input, with: 'new$user!username' wait_for_ajax - expect(find('#new_user_username')).not_to have_css '.gl-field-error-outline' + expect(find('.username')).to have_css '.gl-field-error-outline' end end diff --git a/spec/fixtures/emails/outlook_html.eml b/spec/fixtures/emails/outlook_html.eml new file mode 100644 index 00000000000..506d69efe83 --- /dev/null +++ b/spec/fixtures/emails/outlook_html.eml @@ -0,0 +1,140 @@ + +MIME-Version: 1.0 +Received: by 10.25.161.144 with HTTP; Tue, 7 Oct 2014 22:17:17 -0700 (PDT) +X-Originating-IP: [117.207.85.84] +In-Reply-To: <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail> +References: <topic/35@discourse.techapj.com> + <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail> +Date: Wed, 8 Oct 2014 10:47:17 +0530 +Delivered-To: arpit@techapj.com +Message-ID: <CAOJeqne=SJ_LwN4sb-0Y95ejc2OpreVhdmcPn0TnmwSvTCYzzQ@mail.gmail.com> +Subject: Re: [Discourse] [Meta] Welcome to techAPJ's Discourse! +From: Arpit Jalan <arpit@techapj.com> +To: Discourse <mail+e1c7f2a380e33840aeb654f075490bad@arpitjalan.com>Accept-Language: en-US +Content-Language: en-US +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-originating-ip: [134.68.31.227] +Content-Type: multipart/alternative; + boundary="_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_" +MIME-Version: 1.0 + +--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_ +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: base64 + +PGh0bWwgeG1sbnM6dj0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTp2bWwiIHhtbG5zOm89InVy +bjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmljZSIgeG1sbnM6dz0idXJuOnNjaGVt +YXMtbWljcm9zb2Z0LWNvbTpvZmZpY2U6d29yZCIgeG1sbnM6bT0iaHR0cDovL3NjaGVtYXMubWlj +cm9zb2Z0LmNvbS9vZmZpY2UvMjAwNC8xMi9vbW1sIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv +VFIvUkVDLWh0bWw0MCI+DQo8aGVhZD4NCjxtZXRhIGh0dHAtZXF1aXY9IkNvbnRlbnQtVHlwZSIg +Y29udGVudD0idGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04Ij4NCjxtZXRhIG5hbWU9IkdlbmVyYXRv +ciIgY29udGVudD0iTWljcm9zb2Z0IFdvcmQgMTQgKGZpbHRlcmVkIG1lZGl1bSkiPg0KPCEtLVtp +ZiAhbXNvXT48c3R5bGU+dlw6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kb1w6KiB7 +YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kd1w6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0 +I1ZNTCk7fQ0KLnNoYXBlIHtiZWhhdmlvcjp1cmwoI2RlZmF1bHQjVk1MKTt9DQo8L3N0eWxlPjwh +W2VuZGlmXS0tPjxzdHlsZT48IS0tDQovKiBGb250IERlZmluaXRpb25zICovDQpAZm9udC1mYWNl +DQoJe2ZvbnQtZmFtaWx5OkNhbGlicmk7DQoJcGFub3NlLTE6MiAxNSA1IDIgMiAyIDQgMyAyIDQ7 +fQ0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWlseTpUYWhvbWE7DQoJcGFub3NlLTE6MiAxMSA2IDQg +MyA1IDQgNCAyIDQ7fQ0KLyogU3R5bGUgRGVmaW5pdGlvbnMgKi8NCnAuTXNvTm9ybWFsLCBsaS5N +c29Ob3JtYWwsIGRpdi5Nc29Ob3JtYWwNCgl7bWFyZ2luOjBpbjsNCgltYXJnaW4tYm90dG9tOi4w +MDAxcHQ7DQoJZm9udC1zaXplOjEyLjBwdDsNCglmb250LWZhbWlseToiVGltZXMgTmV3IFJvbWFu +Iiwic2VyaWYiO30NCmE6bGluaywgc3Bhbi5Nc29IeXBlcmxpbmsNCgl7bXNvLXN0eWxlLXByaW9y +aXR5Ojk5Ow0KCWNvbG9yOmJsdWU7DQoJdGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZTt9DQphOnZp +c2l0ZWQsIHNwYW4uTXNvSHlwZXJsaW5rRm9sbG93ZWQNCgl7bXNvLXN0eWxlLXByaW9yaXR5Ojk5 +Ow0KCWNvbG9yOnB1cnBsZTsNCgl0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lO30NCnANCgl7bXNv +LXN0eWxlLXByaW9yaXR5Ojk5Ow0KCW1zby1tYXJnaW4tdG9wLWFsdDphdXRvOw0KCW1hcmdpbi1y +aWdodDowaW47DQoJbXNvLW1hcmdpbi1ib3R0b20tYWx0OmF1dG87DQoJbWFyZ2luLWxlZnQ6MGlu +Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9udC1mYW1pbHk6IlRpbWVzIE5ldyBSb21hbiIsInNl +cmlmIjt9DQpzcGFuLkVtYWlsU3R5bGUxOA0KCXttc28tc3R5bGUtdHlwZTpwZXJzb25hbC1yZXBs +eTsNCglmb250LWZhbWlseToiQ2FsaWJyaSIsInNhbnMtc2VyaWYiOw0KCWNvbG9yOiMxRjQ5N0Q7 +fQ0KLk1zb0NocERlZmF1bHQNCgl7bXNvLXN0eWxlLXR5cGU6ZXhwb3J0LW9ubHk7DQoJZm9udC1m +YW1pbHk6IkNhbGlicmkiLCJzYW5zLXNlcmlmIjt9DQpAcGFnZSBXb3JkU2VjdGlvbjENCgl7c2l6 +ZTo4LjVpbiAxMS4waW47DQoJbWFyZ2luOjEuMGluIDEuMGluIDEuMGluIDEuMGluO30NCmRpdi5X +b3JkU2VjdGlvbjENCgl7cGFnZTpXb3JkU2VjdGlvbjE7fQ0KLS0+PC9zdHlsZT48IS0tW2lmIGd0 +ZSBtc28gOV0+PHhtbD4NCjxvOnNoYXBlZGVmYXVsdHMgdjpleHQ9ImVkaXQiIHNwaWRtYXg9IjEw +MjYiIC8+DQo8L3htbD48IVtlbmRpZl0tLT48IS0tW2lmIGd0ZSBtc28gOV0+PHhtbD4NCjxvOnNo +YXBlbGF5b3V0IHY6ZXh0PSJlZGl0Ij4NCjxvOmlkbWFwIHY6ZXh0PSJlZGl0IiBkYXRhPSIxIiAv +Pg0KPC9vOnNoYXBlbGF5b3V0PjwveG1sPjwhW2VuZGlmXS0tPg0KPC9oZWFkPg0KPGJvZHkgbGFu +Zz0iRU4tVVMiIGxpbms9ImJsdWUiIHZsaW5rPSJwdXJwbGUiPg0KPGRpdiBjbGFzcz0iV29yZFNl +Y3Rpb24xIj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTEu +MHB0O2ZvbnQtZmFtaWx5OiZxdW90O0NhbGlicmkmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90 +Oztjb2xvcjojMUY0OTdEIj5NaWNyb3NvZnQgT3V0bG9vayAyMDEwPG86cD48L286cD48L3NwYW4+ +PC9wPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCI+PHNwYW4gc3R5bGU9ImZvbnQtc2l6ZToxMS4wcHQ7 +Zm9udC1mYW1pbHk6JnF1b3Q7Q2FsaWJyaSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7O2Nv +bG9yOiMxRjQ5N0QiPjxvOnA+Jm5ic3A7PC9vOnA+PC9zcGFuPjwvcD4NCjxwIGNsYXNzPSJNc29O +b3JtYWwiPjxiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTAuMHB0O2ZvbnQtZmFtaWx5OiZxdW90 +O1RhaG9tYSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7Ij5Gcm9tOjwvc3Bhbj48L2I+PHNw +YW4gc3R5bGU9ImZvbnQtc2l6ZToxMC4wcHQ7Zm9udC1mYW1pbHk6JnF1b3Q7VGFob21hJnF1b3Q7 +LCZxdW90O3NhbnMtc2VyaWYmcXVvdDsiPiBtaWNoYWVsIFttYWlsdG86dGFsa0BvcGVubXJzLm9y +Z10NCjxicj4NCjxiPlNlbnQ6PC9iPiBNb25kYXksIE9jdG9iZXIgMTMsIDIwMTQgOTozOCBBTTxi +cj4NCjxiPlRvOjwvYj4gUG93ZXIsIENocmlzPGJyPg0KPGI+U3ViamVjdDo8L2I+IFtQTV0gWW91 +ciBwb3N0IGluICZxdW90O0J1cmdlcmhhdXM6IE5ldyByZXN0YXVyYW50IC8gbHVuY2ggdmVudWUm +cXVvdDs8bzpwPjwvbzpwPjwvc3Bhbj48L3A+DQo8cCBjbGFzcz0iTXNvTm9ybWFsIj48bzpwPiZu +YnNwOzwvbzpwPjwvcD4NCjxkaXY+DQo8dGFibGUgY2xhc3M9Ik1zb05vcm1hbFRhYmxlIiBib3Jk +ZXI9IjAiIGNlbGxzcGFjaW5nPSIwIiBjZWxscGFkZGluZz0iMCI+DQo8dGJvZHk+DQo8dHI+DQo8 +dGQgdmFsaWduPSJ0b3AiIHN0eWxlPSJwYWRkaW5nOjBpbiAwaW4gMGluIDBpbiI+PC90ZD4NCjx0 +ZCBzdHlsZT0icGFkZGluZzowaW4gMGluIDBpbiAwaW4iPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCIg +c3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMu +b3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWJHbFph +MVYwZVhoQ1kwMU1SVEZzVURKbVl6VlFNMFpsZWpFNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lq +b3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNS +aGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRYTmxjbk5jWEZ3dmJXbGphR0ZsYkZ3aUxGd2lhV1Jj +SWpwY0ltUTFZbU13TjJOa05EUmpaRFE0TUdNNFlUZzJNemxqWldJMU56Z3pZbVkyWENJc1hDSjFj +bXhmYVdSelhDSTZXMXdpWWpoa09EZzFNams1TnpkbVpqWTFaV1l5TlRFM09XUmlOR1l5TVdJM056 +RmpOemhqWmpoa09Gd2lYWDBpZlEiIHRhcmdldD0iX2JsYW5rIj48Yj48c3BhbiBzdHlsZT0iZm9u +dC1zaXplOjEwLjBwdDtmb250LWZhbWlseTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1z +ZXJpZiZxdW90Oztjb2xvcjojMDA2Njk5O3RleHQtZGVjb3JhdGlvbjpub25lIj5taWNoYWVsPC9z +cGFuPjwvYj48L2E+PGJyPg0KPHNwYW4gc3R5bGU9ImZvbnQtc2l6ZTo4LjVwdDtmb250LWZhbWls +eTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90Oztjb2xvcjojOTk5OTk5 +Ij5PY3RvYmVyIDEzPC9zcGFuPg0KPG86cD48L286cD48L3A+DQo8L3RkPg0KPC90cj4NCjx0cj4N +Cjx0ZCBjb2xzcGFuPSIyIiBzdHlsZT0icGFkZGluZzozLjc1cHQgMGluIDBpbiAwaW4iPg0KPHAg +Y2xhc3M9Ik1zb05vcm1hbCIgc3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0i +aHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5v +cmc/cD1leUp6SWpvaVVFUklTVU55UjNsVk1EZEJWVmhwV25SM1dXeDRNV05zVFc1Wklpd2lkaUk2 +TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9k +SFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRGeGNYQzlpZFhKblpY +Sm9ZWFZ6TFc1bGR5MXlaWE4wWVhWeVlXNTBMV3gxYm1Ob0xYWmxiblZsWEZ4Y0x6WTNNbHhjWEM4 +elhDSXNYQ0pwWkZ3aU9sd2laRFZpWXpBM1kyUTBOR05rTkRnd1l6aGhPRFl6T1dObFlqVTNPRE5p +WmpaY0lpeGNJblZ5YkY5cFpITmNJanBiWENKaU56WmlZamswWlRGaU56STVaVGsyWlRSbFpXTTRO +R1JtTWpRNE1ETXdZall5WVdZeU1HTTBYQ0pkZlNKOSI+PGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMw +MDY2OTk7dGV4dC1kZWNvcmF0aW9uOm5vbmUiPmh0dHBzOi8vdGFsay5vcGVubXJzLm9yZy90L2J1 +cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjcyLzM8L3NwYW4+PC9iPjwvYT4N +CjxvOnA+PC9vOnA+PC9wPg0KPHAgc3R5bGU9Im1hcmdpbi10b3A6MGluIj5Mb29rcyBsaWtlIHlv +dXIgcmVwbHktYnktZW1haWwgd2Fzbid0IHByb2Nlc3NlZCBjb3JyZWN0bHkgYnkgb3VyIHNvZnR3 +YXJlLiBDYW4geW91IGxldCBtZSBrbm93IHdoYXQgdmVyc2lvbi9PUyBvZiB3aGF0IGVtYWlsIHBy +b2dyYW0geW91J3JlIHVzaW5nPyBXZSB3aWxsIHdhbnQgdG8gdHJ5IHRvIGZpeCB0aGUgYnVnLiA6 +c21pbGU6PG86cD48L286cD48L3A+DQo8cCBzdHlsZT0ibWFyZ2luLXRvcDowaW4iPlRoYW5rcyE8 +bzpwPjwvbzpwPjwvcD4NCjwvdGQ+DQo8L3RyPg0KPC90Ym9keT4NCjwvdGFibGU+DQo8ZGl2IGNs +YXNzPSJNc29Ob3JtYWwiIGFsaWduPSJjZW50ZXIiIHN0eWxlPSJ0ZXh0LWFsaWduOmNlbnRlciI+ +DQo8aHIgc2l6ZT0iMSIgd2lkdGg9IjEwMCUiIGFsaWduPSJjZW50ZXIiPg0KPC9kaXY+DQo8ZGl2 +Pg0KPHA+PHNwYW4gc3R5bGU9ImNvbG9yOiM2NjY2NjYiPlRvIHJlc3BvbmQsIHJlcGx5IHRvIHRo +aXMgZW1haWwgb3IgdmlzaXQgPGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2Ns +aWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWVYaDJWbnBGTUhSMU1uRm5a +RWR1TlhFd01GcFFPVlp0VFZvNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05T +eGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0x +eWN5NXZjbWRjWEZ3dmRGeGNYQzk1YjNWeUxYQnZjM1F0YVc0dFluVnlaMlZ5YUdGMWN5MXVaWGN0 +Y21WemRHRjFjbUZ1ZEMxc2RXNWphQzEyWlc1MVpWeGNYQzgyTnpSY1hGd3ZNVndpTEZ3aWFXUmNJ +anBjSW1RMVltTXdOMk5rTkRSalpEUTRNR000WVRnMk16bGpaV0kxTnpnelltWTJYQ0lzWENKMWNt +eGZhV1J6WENJNlcxd2lZamMyWW1JNU5HVXhZamN5T1dVNU5tVTBaV1ZqT0RSa1pqSTBPREF6TUdJ +Mk1tRm1NakJqTkZ3aVhYMGlmUSI+DQo8Yj48c3BhbiBzdHlsZT0iY29sb3I6IzAwNjY5OTt0ZXh0 +LWRlY29yYXRpb246bm9uZSI+aHR0cHM6Ly90YWxrLm9wZW5tcnMub3JnL3QveW91ci1wb3N0LWlu +LWJ1cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjc0LzE8L3NwYW4+PC9iPjwv +YT4gaW4geW91ciBicm93c2VyLjxvOnA+PC9vOnA+PC9zcGFuPjwvcD4NCjwvZGl2Pg0KPGRpdj4N +CjxwPjxzcGFuIHN0eWxlPSJjb2xvcjojNjY2NjY2Ij5UbyB1bnN1YnNjcmliZSBmcm9tIHRoZXNl +IGVtYWlscywgdmlzaXQgeW91ciA8YSBocmVmPSJodHRwOi8vY2wub3Blbm1ycy5vcmcvdHJhY2sv +Y2xpY2svMzAwMzk5MDUvdGFsay5vcGVubXJzLm9yZz9wPWV5SnpJam9pZFV4dVdsZzVWRmMwT1da +V1MwWTRiRmRMZG1seVdHc3hUVjl6SWl3aWRpSTZNU3dpY0NJNkludGNJblZjSWpvek1EQXpPVGt3 +TlN4Y0luWmNJam94TEZ3aWRYSnNYQ0k2WENKb2RIUndjenBjWEZ3dlhGeGNMM1JoYkdzdWIzQmxi +bTF5Y3k1dmNtZGNYRnd2YlhsY1hGd3ZjSEpsWm1WeVpXNWpaWE5jSWl4Y0ltbGtYQ0k2WENKa05X +SmpNRGRqWkRRMFkyUTBPREJqT0dFNE5qTTVZMlZpTlRjNE0ySm1ObHdpTEZ3aWRYSnNYMmxrYzF3 +aU9sdGNJbUk0TVdVd1pqQTFORFk1TkRNME56Z3lNMkZtTWpBMk5qRmpaamMzWkdOaU4yTmhZemRt +TWpKY0lsMTlJbjAiPg0KPGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMwMDY2OTk7dGV4dC1kZWNvcmF0 +aW9uOm5vbmUiPnVzZXIgcHJlZmVyZW5jZXM8L3NwYW4+PC9iPjwvYT4uPG86cD48L286cD48L3Nw +YW4+PC9wPg0KPC9kaXY+DQo8L2Rpdj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxpbWcgYm9yZGVy +PSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBpZD0iX3gwMDAwX2kxMDI2IiBzcmM9Imh0dHA6Ly9j +bC5vcGVubXJzLm9yZy90cmFjay9vcGVuLnBocD91PTMwMDM5OTA1JmFtcDtpZD1kNWJjMDdjZDQ0 +Y2Q0ODBjOGE4NjM5Y2ViNTc4M2JmNiI+PG86cD48L286cD48L3A+DQo8L2Rpdj4NCjwvYm9keT4N +CjwvaHRtbD4NCg== + +--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_-- diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index c706e418d26..15863d444f8 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -57,7 +57,7 @@ describe ApplicationHelper do it 'returns an url for the avatar' do project = create(:project, avatar: File.open(avatar_file_path)) - avatar_url = "http://localhost/uploads/project/avatar/#{project.id}/banana_sample.gif" + avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif" expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -67,7 +67,7 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) - avatar_url = 'http://localhost' + namespace_project_avatar_path(project.namespace, project) + avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s).to match( image_tag(avatar_url)) end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index 5368e5fab06..1d494edcd3b 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -113,7 +113,7 @@ describe GitlabMarkdownHelper do it 'replaces commit message with emoji to link' do actual = link_to_gfm(':book:Book', '/foo') expect(actual). - to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://localhost/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) + to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://#{Gitlab.config.gitlab.host}/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) end end diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 2f9291afc3f..77841e85223 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -85,4 +85,45 @@ describe PreferencesHelper do and_return(double('user', messages)) end end + + describe '#default_project_view' do + context 'user not signed in' do + before do + helper.instance_variable_set(:@project, project) + stub_user + end + + context 'when repository is empty' do + let(:project) { create(:project_empty_repo, :public) } + + it 'returns activity if user has repository access' do + allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true) + + expect(helper.default_project_view).to eq('activity') + end + + it 'returns activity if user does not have repository access' do + allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false) + + expect(helper.default_project_view).to eq('activity') + end + end + + context 'when repository is not empty' do + let(:project) { create(:project, :public) } + + it 'returns readme if user has repository access' do + allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true) + + expect(helper.default_project_view).to eq('readme') + end + + it 'returns activity if user does not have repository access' do + allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false) + + expect(helper.default_project_view).to eq('activity') + end + end + end + end end diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb new file mode 100644 index 00000000000..8cedbb0240f --- /dev/null +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::Command, service: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + subject { described_class.new(project, user, params).execute } + + describe '#execute' do + context 'when no command is available' do + let(:params) { { text: 'issue show 1' } } + let(:project) { create(:project, has_external_issue_tracker: true) } + + it 'displays 404 messages' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('404 not found') + end + end + + context 'when an unknown command is triggered' do + let(:params) { { command: '/gitlab', text: "unknown command 123" } } + + it 'displays the help message' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Available commands') + expect(subject[:text]).to match('/gitlab issue show') + end + end + + context 'the user can not create an issue' do + let(:params) { { text: "issue create my new issue" } } + + it 'rejects the actions' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Whoops! That action is not allowed') + end + end + + context 'issue is successfully created' do + let(:params) { { text: "issue create my new issue" } } + + before do + project.team << [user, :master] + end + + it 'presents the issue' do + expect(subject[:text]).to match("my new issue") + end + + it 'shows a link to the new issue' do + expect(subject[:text]).to match(/\/issues\/\d+/) + end + end + end +end diff --git a/spec/lib/gitlab/chat_commands/issue_create_spec.rb b/spec/lib/gitlab/chat_commands/issue_create_spec.rb new file mode 100644 index 00000000000..df0c317ccea --- /dev/null +++ b/spec/lib/gitlab/chat_commands/issue_create_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::IssueCreate, service: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:regex_match) { described_class.match("issue create bird is the word") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'without description' do + it 'creates the issue' do + expect { subject }.to change { project.issues.count }.by(1) + + expect(subject.title).to eq('bird is the word') + end + end + + context 'with description' do + let(:description) { "Surfin bird" } + let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") } + + it 'creates the issue with description' do + subject + + expect(Issue.last.description).to eq(description) + end + end + end + + describe '.match' do + it 'matches the title without description' do + match = described_class.match("issue create my title") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq("") + end + + it 'matches the title with description' do + match = described_class.match("issue create my title\n\ndescription") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq('description') + end + end +end diff --git a/spec/lib/gitlab/chat_commands/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/issue_show_spec.rb new file mode 100644 index 00000000000..331a4604e9b --- /dev/null +++ b/spec/lib/gitlab/chat_commands/issue_show_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::IssueShow, service: true do + describe '#execute' do + let(:issue) { create(:issue) } + let(:project) { issue.project } + let(:user) { issue.author } + let(:regex_match) { described_class.match("issue show #{issue.iid}") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'the issue exists' do + it 'returns the issue' do + expect(subject.iid).to be issue.iid + end + end + + context 'the issue does not exist' do + let(:regex_match) { described_class.match("issue show 2343242") } + + it "returns nil" do + expect(subject).to be_nil + end + end + end + + describe 'self.match' do + it 'matches the iid' do + match = described_class.match("issue show 123") + + expect(match[:iid]).to eq("123") + end + end +end diff --git a/spec/lib/gitlab/chat_name_token_spec.rb b/spec/lib/gitlab/chat_name_token_spec.rb new file mode 100644 index 00000000000..8c1e6efa9db --- /dev/null +++ b/spec/lib/gitlab/chat_name_token_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::ChatNameToken, lib: true do + context 'when using unknown token' do + let(:token) { } + + subject { described_class.new(token).get } + + it 'returns empty data' do + is_expected.to be_nil + end + end + + context 'when storing data' do + let(:data) { { key: 'value' } } + + subject { described_class.new(@token) } + + before do + @token = described_class.new.store!(data) + end + + it 'returns stored data' do + expect(subject.get).to eq(data) + end + + context 'and after deleting them' do + before do + subject.delete + end + + it 'data are removed' do + expect(subject.get).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/code_event_spec.rb b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb new file mode 100644 index 00000000000..43f42d1bde8 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::CodeEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb new file mode 100644 index 00000000000..9aeaa6b3ee8 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -0,0 +1,326 @@ +require 'spec_helper' + +describe Gitlab::CycleAnalytics::Events do + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + subject { described_class.new(project: project, options: { from: from_date, current_user: user }) } + + before do + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context]) + + setup(context) + end + + describe '#issue_events' do + it 'has the total time' do + expect(subject.issue_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.issue_events.first[:title]).to eq(context.title) + end + + it 'has the URL' do + expect(subject.issue_events.first[:url]).not_to be_nil + end + + it 'has an iid' do + expect(subject.issue_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.issue_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.issue_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.issue_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.issue_events.first[:author][:name]).to eq(context.author.name) + end + end + + describe '#plan_events' do + it 'has a title' do + expect(subject.plan_events.first[:title]).not_to be_nil + end + + it 'has a sha short ID' do + expect(subject.plan_events.first[:short_sha]).not_to be_nil + end + + it 'has the URL' do + expect(subject.plan_events.first[:commit_url]).not_to be_nil + end + + it 'has the total time' do + expect(subject.plan_events.first[:total_time]).not_to be_empty + end + + it "has the author's URL" do + expect(subject.plan_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.plan_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.plan_events.first[:author][:name]).not_to be_nil + end + end + + describe '#code_events' do + before do + create_commit_referencing_issue(context) + end + + it 'has the total time' do + expect(subject.code_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.code_events.first[:title]).to eq('Awesome merge_request') + end + + it 'has an iid' do + expect(subject.code_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.code_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.code_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.code_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.code_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#test_events' do + let(:merge_request) { MergeRequest.first } + let!(:pipeline) do + create(:ci_pipeline, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: context.project) + end + + before do + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + pipeline.run! + pipeline.succeed! + end + + it 'has the name' do + expect(subject.test_events.first[:name]).not_to be_nil + end + + it 'has the ID' do + expect(subject.test_events.first[:id]).not_to be_nil + end + + it 'has the URL' do + expect(subject.test_events.first[:url]).not_to be_nil + end + + it 'has the branch name' do + expect(subject.test_events.first[:branch]).not_to be_nil + end + + it 'has the branch URL' do + expect(subject.test_events.first[:branch][:url]).not_to be_nil + end + + it 'has the short SHA' do + expect(subject.test_events.first[:short_sha]).not_to be_nil + end + + it 'has the commit URL' do + expect(subject.test_events.first[:commit_url]).not_to be_nil + end + + it 'has the date' do + expect(subject.test_events.first[:date]).not_to be_nil + end + + it 'has the total time' do + expect(subject.test_events.first[:total_time]).not_to be_empty + end + end + + describe '#review_events' do + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + it 'has the total time' do + expect(subject.review_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.review_events.first[:title]).to eq('Awesome merge_request') + end + + it 'has an iid' do + expect(subject.review_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has the URL' do + expect(subject.review_events.first[:url]).not_to be_nil + end + + it 'has a state' do + expect(subject.review_events.first[:state]).not_to be_nil + end + + it 'has a created_at timestamp' do + expect(subject.review_events.first[:created_at]).not_to be_nil + end + + it "has the author's URL" do + expect(subject.review_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.review_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.review_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#staging_events' do + let(:merge_request) { MergeRequest.first } + let!(:pipeline) do + create(:ci_pipeline, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: context.project) + end + + before do + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + pipeline.run! + pipeline.succeed! + + merge_merge_requests_closing_issue(context) + deploy_master + end + + it 'has the name' do + expect(subject.staging_events.first[:name]).not_to be_nil + end + + it 'has the ID' do + expect(subject.staging_events.first[:id]).not_to be_nil + end + + it 'has the URL' do + expect(subject.staging_events.first[:url]).not_to be_nil + end + + it 'has the branch name' do + expect(subject.staging_events.first[:branch]).not_to be_nil + end + + it 'has the branch URL' do + expect(subject.staging_events.first[:branch][:url]).not_to be_nil + end + + it 'has the short SHA' do + expect(subject.staging_events.first[:short_sha]).not_to be_nil + end + + it 'has the commit URL' do + expect(subject.staging_events.first[:commit_url]).not_to be_nil + end + + it 'has the date' do + expect(subject.staging_events.first[:date]).not_to be_nil + end + + it 'has the total time' do + expect(subject.staging_events.first[:total_time]).not_to be_empty + end + + it "has the author's URL" do + expect(subject.staging_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.staging_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.staging_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#production_events' do + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + before do + merge_merge_requests_closing_issue(context) + deploy_master + end + + it 'has the total time' do + expect(subject.production_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.production_events.first[:title]).to eq(context.title) + end + + it 'has the URL' do + expect(subject.production_events.first[:url]).not_to be_nil + end + + it 'has an iid' do + expect(subject.production_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.production_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.production_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.production_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.production_events.first[:author][:name]).to eq(context.author.name) + end + end + + def setup(context) + milestone = create(:milestone, project: project) + context.update(milestone: milestone) + mr = create_merge_request_closing_issue(context) + + ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha) + end +end diff --git a/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb new file mode 100644 index 00000000000..1c5c308da7d --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::IssueEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb new file mode 100644 index 00000000000..d76a255acf5 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::PlanEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/production_event_spec.rb b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb new file mode 100644 index 00000000000..ac17e3b4287 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::ProductionEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/review_event_spec.rb b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb new file mode 100644 index 00000000000..1ff53aa0227 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::ReviewEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb new file mode 100644 index 00000000000..7019e4c3351 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +shared_examples 'default query config' do + let(:event) { described_class.new(project: double, options: {}) } + + it 'has the start attributes' do + expect(event.start_time_attrs).not_to be_nil + end + + it 'has the stage attribute' do + expect(event.stage).not_to be_nil + end + + it 'has the end attributes' do + expect(event.end_time_attrs).not_to be_nil + end + + it 'has the projection attributes' do + expect(event.projections).not_to be_nil + end +end diff --git a/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb new file mode 100644 index 00000000000..4862d4765f2 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::StagingEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/test_event_spec.rb b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb new file mode 100644 index 00000000000..e249db69fc6 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::TestEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/updater_spec.rb b/spec/lib/gitlab/cycle_analytics/updater_spec.rb new file mode 100644 index 00000000000..eff54cd3692 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/updater_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Gitlab::CycleAnalytics::Updater do + describe 'updates authors' do + let(:user) { create(:user) } + let(:events) { [{ 'author_id' => user.id }] } + + it 'maps the correct user' do + described_class.update!(events, from: 'author_id', to: 'author', klass: User) + + expect(events.first['author']).to eq(user) + end + end + + describe 'updates builds' do + let(:build) { create(:ci_build) } + let(:events) { [{ 'id' => build.id }] } + + it 'maps the correct build' do + described_class.update!(events, from: 'id', to: 'build', klass: ::Ci::Build) + + expect(events.first['build']).to eq(build) + end + end +end diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb index 6f8e9a4be64..c7a0139d32a 100644 --- a/spec/lib/gitlab/email/reply_parser_spec.rb +++ b/spec/lib/gitlab/email/reply_parser_spec.rb @@ -206,5 +206,9 @@ describe Gitlab::Email::ReplyParser, lib: true do it "properly renders email reply from MS Outlook client" do expect(test_parse_body(fixture_file("emails/outlook.eml"))).to eq("Microsoft Outlook 2010") end + + it "properly renders html-only email from MS Outlook" do + expect(test_parse_body(fixture_file("emails/outlook_html.eml"))).to eq("Microsoft Outlook 2010") + end end end diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb index 7478f86bd28..000b9aa6f83 100644 --- a/spec/lib/gitlab/github_import/importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer_spec.rb @@ -101,7 +101,6 @@ describe Gitlab::GithubImport::Importer, lib: true do closed_at: nil, merged_at: nil, url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347', - labels: [double(name: 'Label #3')], ) end @@ -157,8 +156,6 @@ describe Gitlab::GithubImport::Importer, lib: true do errors: [ { type: :label, url: "https://api.github.com/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title can't be blank, Title is invalid" }, { type: :issue, url: "https://api.github.com/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank, Title is too short (minimum is 0 characters)" }, - { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Invalid Repository. Use user/repo format." }, - { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Invalid Repository. Use user/repo format." }, { type: :wiki, errors: "Gitlab::Shell::Error" }, { type: :release, url: 'https://api.github.com/repos/octocat/Hello-World/releases/2', errors: "Validation failed: Description can't be blank" } ] diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index c2f1f6b91a1..95339e2f128 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -144,20 +144,20 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do end end - describe '#valid?' do + describe '#pull_request?' do context 'when mention a pull request' do let(:raw_data) { double(base_data.merge(pull_request: double)) } - it 'returns false' do - expect(issue.valid?).to eq false + it 'returns true' do + expect(issue.pull_request?).to eq true end end context 'when does not mention a pull request' do let(:raw_data) { double(base_data.merge(pull_request: nil)) } - it 'returns true' do - expect(issue.valid?).to eq true + it 'returns false' do + expect(issue.pull_request?).to eq false end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 02b11bd999a..fe3c39e38db 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -116,6 +116,7 @@ project: - base_tags - tag_taggings - tags +- chat_services - creator - group - namespace @@ -127,6 +128,7 @@ project: - emails_on_push_service - builds_email_service - pipelines_email_service +- mattermost_slash_commands_service - irker_service - pivotaltracker_service - hipchat_service @@ -188,4 +190,4 @@ award_emoji: - awardable - user priorities: -- label
\ No newline at end of file +- label diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index 117a15264da..fd3769d75b5 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Middleware::Go, lib: true do resp = middleware.call(env) expect(resp[0]).to eq(200) expect(resp[1]['Content-Type']).to eq('text/html') - expected_body = "<!DOCTYPE html><html><head><meta content='localhost/group/project git http://localhost/group/project.git' name='go-import'></head></html>\n" + expected_body = "<!DOCTYPE html><html><head><meta content='#{Gitlab.config.gitlab.host}/group/project git http://#{Gitlab.config.gitlab.host}/group/project.git' name='go-import'></head></html>\n" expect(resp[2].body).to eq([expected_body]) end end diff --git a/spec/lib/light_url_builder_spec.rb b/spec/lib/light_url_builder_spec.rb new file mode 100644 index 00000000000..a826b24419a --- /dev/null +++ b/spec/lib/light_url_builder_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe Gitlab::UrlBuilder, lib: true do + describe '.build' do + context 'when passing a Commit' do + it 'returns a proper URL' do + commit = build_stubbed(:commit) + + url = described_class.build(commit) + + expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.path_with_namespace}/commit/#{commit.id}" + end + end + + context 'when passing an Issue' do + it 'returns a proper URL' do + issue = build_stubbed(:issue, iid: 42) + + url = described_class.build(issue) + + expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}" + end + end + + context 'when passing a MergeRequest' do + it 'returns a proper URL' do + merge_request = build_stubbed(:merge_request, iid: 42) + + url = described_class.build(merge_request) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}" + end + end + + context 'when passing a Note' do + context 'on a Commit' do + it 'returns a proper URL' do + note = build_stubbed(:note_on_commit) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}" + end + end + + context 'on a Commit Diff' do + it 'returns a proper URL' do + note = build_stubbed(:diff_note_on_commit) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}" + end + end + + context 'on an Issue' do + it 'returns a proper URL' do + issue = create(:issue, iid: 42) + note = build_stubbed(:note_on_issue, noteable: issue) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}#note_#{note.id}" + end + end + + context 'on a MergeRequest' do + it 'returns a proper URL' do + merge_request = create(:merge_request, iid: 42) + note = build_stubbed(:note_on_merge_request, noteable: merge_request) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}" + end + end + + context 'on a MergeRequest Diff' do + it 'returns a proper URL' do + merge_request = create(:merge_request, iid: 42) + note = build_stubbed(:diff_note_on_merge_request, noteable: merge_request) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}" + end + end + + context 'on a ProjectSnippet' do + it 'returns a proper URL' do + project_snippet = create(:project_snippet) + note = build_stubbed(:note_on_project_snippet, noteable: project_snippet) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.path_with_namespace}/snippets/#{note.noteable_id}#note_#{note.id}" + end + end + + context 'on another object' do + it 'returns a proper URL' do + project = build_stubbed(:project) + + expect { described_class.build(project) }. + to raise_error(NotImplementedError, 'No URL builder defined for Project') + end + end + end + + context 'when passing a WikiPage' do + it 'returns a proper URL' do + wiki_page = build(:wiki_page) + url = described_class.build(wiki_page) + + expect(url).to eq "#{Gitlab.config.gitlab.url}#{wiki_page.wiki.wiki_base_path}/#{wiki_page.slug}" + end + end + end +end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 14bc062ef12..e1877d5fde0 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -25,7 +25,7 @@ describe Notify do it 'includes a link for user to set password' do params = "reset_password_token=#{token}" is_expected.to have_body_text( - %r{http://localhost(:\d+)?/users/password/edit\?#{params}} + %r{http://#{Gitlab.config.gitlab.host}(:\d+)?/users/password/edit\?#{params}} ) end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index ae185de9ca3..ef07f2275b1 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -1052,4 +1052,132 @@ describe Ci::Build, models: true do end end end + + describe '#has_environment?' do + subject { build.has_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + it { is_expected.to be_truthy } + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#starts_environment?' do + subject { build.starts_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_truthy } + end + + context 'and start action is defined' do + before do + build.update(options: { environment: { action: 'start' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#stops_environment?' do + subject { build.stops_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_falsey } + end + + context 'and stop action is defined' do + before do + build.update(options: { environment: { action: 'stop' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#last_deployment' do + subject { build.last_deployment } + + context 'when multiple deployments are created' do + let!(:deployment1) { create(:deployment, deployable: build) } + let!(:deployment2) { create(:deployment, deployable: build) } + + it 'returns the latest one' do + is_expected.to eq(deployment2) + end + end + end + + describe '#outdated_deployment?' do + subject { build.outdated_deployment? } + + context 'when build succeeded' do + let(:build) { create(:ci_build, :success) } + let!(:deployment) { create(:deployment, deployable: build) } + + context 'current deployment is latest' do + it { is_expected.to be_falsey } + end + + context 'current deployment is not latest on environment' do + let!(:deployment2) { create(:deployment, environment: deployment.environment) } + + it { is_expected.to be_truthy } + end + end + + context 'when build failed' do + let(:build) { create(:ci_build, :failed) } + + it { is_expected.to be_falsey } + end + end + + describe '#expanded_environment_name' do + subject { build.expanded_environment_name } + + context 'when environment uses variables' do + let(:build) { create(:ci_build, ref: 'master', environment: 'review/$CI_BUILD_REF_NAME') } + + it { is_expected.to eq('review/master') } + end + end end diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb new file mode 100644 index 00000000000..b02971cab82 --- /dev/null +++ b/spec/models/chat_name_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe ChatName, models: true do + subject { create(:chat_name) } + + it { is_expected.to belong_to(:service) } + it { is_expected.to belong_to(:user) } + + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:service) } + it { is_expected.to validate_presence_of(:team_id) } + it { is_expected.to validate_presence_of(:chat_id) } + + it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:service_id) } + it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:service_id, :team_id) } +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index a37a00f461a..a7e90c8a381 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -4,6 +4,12 @@ describe Ci::Build, models: true do let(:build) { create(:ci_build) } let(:test_trace) { 'This is a test' } + it { is_expected.to belong_to(:runner) } + it { is_expected.to belong_to(:trigger_request) } + it { is_expected.to belong_to(:erased_by) } + + it { is_expected.to have_many(:deployments) } + describe '#trace' do it 'obfuscates project runners token' do allow(build).to receive(:raw_trace).and_return("Test: #{build.project.runners_token}") diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 6e987967ca5..6f84bffe046 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -176,23 +176,25 @@ describe Issue, "Issuable" do end describe '#subscribed?' do + let(:project) { issue.project } + context 'user is not a participant in the issue' do before { allow(issue).to receive(:participants).with(user).and_return([]) } it 'returns false when no subcription exists' do - expect(issue.subscribed?(user)).to be_falsey + expect(issue.subscribed?(user, project)).to be_falsey end it 'returns true when a subcription exists and subscribed is true' do - issue.subscriptions.create(user: user, subscribed: true) + issue.subscriptions.create(user: user, project: project, subscribed: true) - expect(issue.subscribed?(user)).to be_truthy + expect(issue.subscribed?(user, project)).to be_truthy end it 'returns false when a subcription exists and subscribed is false' do - issue.subscriptions.create(user: user, subscribed: false) + issue.subscriptions.create(user: user, project: project, subscribed: false) - expect(issue.subscribed?(user)).to be_falsey + expect(issue.subscribed?(user, project)).to be_falsey end end @@ -200,19 +202,19 @@ describe Issue, "Issuable" do before { allow(issue).to receive(:participants).with(user).and_return([user]) } it 'returns false when no subcription exists' do - expect(issue.subscribed?(user)).to be_truthy + expect(issue.subscribed?(user, project)).to be_truthy end it 'returns true when a subcription exists and subscribed is true' do - issue.subscriptions.create(user: user, subscribed: true) + issue.subscriptions.create(user: user, project: project, subscribed: true) - expect(issue.subscribed?(user)).to be_truthy + expect(issue.subscribed?(user, project)).to be_truthy end it 'returns false when a subcription exists and subscribed is false' do - issue.subscriptions.create(user: user, subscribed: false) + issue.subscriptions.create(user: user, project: project, subscribed: false) - expect(issue.subscribed?(user)).to be_falsey + expect(issue.subscribed?(user, project)).to be_falsey end end end diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index b7fc5a92497..58f5c164116 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -1,67 +1,128 @@ require 'spec_helper' describe Subscribable, 'Subscribable' do - let(:resource) { create(:issue) } - let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:resource) { create(:issue, project: project) } + let(:user_1) { create(:user) } describe '#subscribed?' do - it 'returns false when no subcription exists' do - expect(resource.subscribed?(user)).to be_falsey - end + context 'without project' do + it 'returns false when no subscription exists' do + expect(resource.subscribed?(user_1)).to be_falsey + end + + it 'returns true when a subcription exists and subscribed is true' do + resource.subscriptions.create(user: user_1, subscribed: true) + + expect(resource.subscribed?(user_1)).to be_truthy + end - it 'returns true when a subcription exists and subscribed is true' do - resource.subscriptions.create(user: user, subscribed: true) + it 'returns false when a subcription exists and subscribed is false' do + resource.subscriptions.create(user: user_1, subscribed: false) - expect(resource.subscribed?(user)).to be_truthy + expect(resource.subscribed?(user_1)).to be_falsey + end end - it 'returns false when a subcription exists and subscribed is false' do - resource.subscriptions.create(user: user, subscribed: false) + context 'with project' do + it 'returns false when no subscription exists' do + expect(resource.subscribed?(user_1, project)).to be_falsey + end + + it 'returns true when a subcription exists and subscribed is true' do + resource.subscriptions.create(user: user_1, project: project, subscribed: true) + + expect(resource.subscribed?(user_1, project)).to be_truthy + end - expect(resource.subscribed?(user)).to be_falsey + it 'returns false when a subcription exists and subscribed is false' do + resource.subscriptions.create(user: user_1, project: project, subscribed: false) + + expect(resource.subscribed?(user_1, project)).to be_falsey + end end end + describe '#subscribers' do it 'returns [] when no subcribers exists' do - expect(resource.subscribers).to be_empty + expect(resource.subscribers(project)).to be_empty end it 'returns the subscribed users' do - resource.subscriptions.create(user: user, subscribed: true) - resource.subscriptions.create(user: create(:user), subscribed: false) + user_2 = create(:user) + resource.subscriptions.create(user: user_1, subscribed: true) + resource.subscriptions.create(user: user_2, project: project, subscribed: true) + resource.subscriptions.create(user: create(:user), project: project, subscribed: false) - expect(resource.subscribers).to eq [user] + expect(resource.subscribers(project)).to contain_exactly(user_1, user_2) end end describe '#toggle_subscription' do - it 'toggles the current subscription state for the given user' do - expect(resource.subscribed?(user)).to be_falsey + context 'without project' do + it 'toggles the current subscription state for the given user' do + expect(resource.subscribed?(user_1)).to be_falsey - resource.toggle_subscription(user) + resource.toggle_subscription(user_1) - expect(resource.subscribed?(user)).to be_truthy + expect(resource.subscribed?(user_1)).to be_truthy + end + end + + context 'with project' do + it 'toggles the current subscription state for the given user' do + expect(resource.subscribed?(user_1, project)).to be_falsey + + resource.toggle_subscription(user_1, project) + + expect(resource.subscribed?(user_1, project)).to be_truthy + end end end describe '#subscribe' do - it 'subscribes the given user' do - expect(resource.subscribed?(user)).to be_falsey + context 'without project' do + it 'subscribes the given user' do + expect(resource.subscribed?(user_1)).to be_falsey + + resource.subscribe(user_1) + + expect(resource.subscribed?(user_1)).to be_truthy + end + end + + context 'with project' do + it 'subscribes the given user' do + expect(resource.subscribed?(user_1, project)).to be_falsey - resource.subscribe(user) + resource.subscribe(user_1, project) - expect(resource.subscribed?(user)).to be_truthy + expect(resource.subscribed?(user_1, project)).to be_truthy + end end end describe '#unsubscribe' do - it 'unsubscribes the given current user' do - resource.subscriptions.create(user: user, subscribed: true) - expect(resource.subscribed?(user)).to be_truthy + context 'without project' do + it 'unsubscribes the given current user' do + resource.subscriptions.create(user: user_1, subscribed: true) + expect(resource.subscribed?(user_1)).to be_truthy + + resource.unsubscribe(user_1) + + expect(resource.subscribed?(user_1)).to be_falsey + end + end + + context 'with project' do + it 'unsubscribes the given current user' do + resource.subscriptions.create(user: user_1, project: project, subscribed: true) + expect(resource.subscribed?(user_1, project)).to be_truthy - resource.unsubscribe(user) + resource.unsubscribe(user_1, project) - expect(resource.subscribed?(user)).to be_falsey + expect(resource.subscribed?(user_1, project)).to be_falsey + end end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index a94e6d0165f..60bbe3fcd72 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -166,4 +166,25 @@ describe Environment, models: true do end end end + + describe 'recently_updated_on_branch?' do + subject { environment.recently_updated_on_branch?('feature') } + + context 'when last deployment to environment is the most recent one' do + before do + create(:deployment, environment: environment, ref: 'feature') + end + + it { is_expected.to be true } + end + + context 'when last deployment to environment is not the most recent' do + before do + create(:deployment, environment: environment, ref: 'feature') + create(:deployment, environment: environment, ref: 'master') + end + + it { is_expected.to be false } + end + end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 1a26cee9f3d..90731f55470 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -19,7 +19,7 @@ describe Key, models: true do describe "#publishable_keys" do it 'replaces SSH key comment with simple identifier of username + hostname' do - expect(build(:key, user: user).publishable_key).to include("#{user.name} (localhost)") + expect(build(:key, user: user).publishable_key).to include("#{user.name} (#{Gitlab.config.gitlab.host})") end end end diff --git a/spec/models/project_services/chat_service_spec.rb b/spec/models/project_services/chat_service_spec.rb new file mode 100644 index 00000000000..c6a45a3e1be --- /dev/null +++ b/spec/models/project_services/chat_service_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe ChatService, models: true do + describe "Associations" do + it { is_expected.to have_many :chat_names } + end + + describe '#valid_token?' do + subject { described_class.new } + + it 'is false as it has no token' do + expect(subject.valid_token?('wer')).to be_falsey + end + end +end diff --git a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb index 652804fb444..9b80f0e7296 100644 --- a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb +++ b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb @@ -35,9 +35,9 @@ describe GitlabIssueTrackerService, models: true do end it 'gives the correct path' do - expect(@service.project_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues") - expect(@service.new_issue_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/new") - expect(@service.issue_url(432)).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/432") + expect(@service.project_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues") + expect(@service.new_issue_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/new") + expect(@service.issue_url(432)).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/432") end end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 05ee4a08391..d8c47322220 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -69,6 +69,7 @@ describe JiraService, models: true do end describe "Execute" do + let(:custom_base_url) { 'http://custom_url' } let(:user) { create(:user) } let(:project) { create(:project) } let(:merge_request) { create(:merge_request) } @@ -85,17 +86,30 @@ describe JiraService, models: true do project_key: 'GitLabProject' ) + # These stubs are needed to test JiraService#close_issue. + # We close the issue then do another request to API to check if it got closed. + # Here is stubbed the API return with a closed and an opened issues. + open_issue = JIRA::Resource::Issue.new(@jira_service.client, attrs: { "id" => "JIRA-123" }) + closed_issue = open_issue.dup + allow(open_issue).to receive(:resolution).and_return(false) + allow(closed_issue).to receive(:resolution).and_return(true) + allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) + + allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123") + @jira_service.save project_issues_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123' @project_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject' @transitions_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' + @remote_link_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/remotelink' WebMock.stub_request(:get, @project_url) WebMock.stub_request(:get, project_issues_url) WebMock.stub_request(:post, @transitions_url) WebMock.stub_request(:post, @comment_url) + WebMock.stub_request(:post, @remote_link_url) end it "calls JIRA API" do @@ -106,11 +120,44 @@ describe JiraService, models: true do ).once end + # Check https://developer.atlassian.com/jiradev/jira-platform/guides/other/guide-jira-remote-issue-links/fields-in-remote-issue-links + # for more information + it "creates Remote Link reference in JIRA for comment" do + @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) + + # Creates comment + expect(WebMock).to have_requested(:post, @comment_url) + + # Creates Remote Link in JIRA issue fields + expect(WebMock).to have_requested(:post, @remote_link_url).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/#{merge_request.diff_head_sha}", + title: "GitLab: Solved by commit #{merge_request.diff_head_sha}.", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: true, icon: { url16x16: "http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png", title: "Closed" } } + } + ) + ).once + end + + it "does not send comment or remote links to issues already closed" do + allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(true) + + @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) + + expect(WebMock).not_to have_requested(:post, @comment_url) + expect(WebMock).not_to have_requested(:post, @remote_link_url) + end + it "references the GitLab commit/merge request" do + stub_config_setting(base_url: custom_base_url) + @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @comment_url).with( - body: /#{Gitlab.config.gitlab.url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/ + body: /#{custom_base_url}\/#{project.path_with_namespace}\/commit\/#{merge_request.diff_head_sha}/ ).once end diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb new file mode 100644 index 00000000000..4a1037e950b --- /dev/null +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe MattermostSlashCommandsService, models: true do + describe "Associations" do + it { is_expected.to respond_to :token } + end + + describe '#valid_token?' do + subject { described_class.new } + + context 'when the token is empty' do + it 'is false' do + expect(subject.valid_token?('wer')).to be_falsey + end + end + + context 'when there is a token' do + before do + subject.token = '123' + end + + it 'accepts equal tokens' do + expect(subject.valid_token?('123')).to be_truthy + end + end + end + + describe '#trigger' do + subject { described_class.new } + + context 'no token is passed' do + let(:params) { Hash.new } + + it 'returns nil' do + expect(subject.trigger(params)).to be_nil + end + end + + context 'with a token passed' do + let(:project) { create(:empty_project) } + let(:params) { { token: 'token' } } + + before do + allow(subject).to receive(:token).and_return('token') + end + + context 'no user can be found' do + context 'when no url can be generated' do + it 'responds with the authorize url' do + response = subject.trigger(params) + + expect(response[:response_type]).to eq :ephemeral + expect(response[:text]).to start_with ":sweat_smile: Couldn't identify you" + end + end + + context 'when an auth url can be generated' do + let(:params) do + { + team_domain: 'http://domain.tld', + team_id: 'T3423423', + user_id: 'U234234', + user_name: 'mepmep', + token: 'token' + } + end + + let(:service) do + project.create_mattermost_slash_commands_service( + properties: { token: 'token' } + ) + end + + it 'generates the url' do + response = service.trigger(params) + + expect(response[:text]).to start_with(':wave: Hi there!') + end + end + end + + context 'when the user is authenticated' do + let!(:chat_name) { create(:chat_name, service: service) } + let(:service) do + project.create_mattermost_slash_commands_service( + properties: { token: 'token' } + ) + end + let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } } + + it 'triggers the command' do + expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute) + + service.trigger(params) + end + end + end + end +end diff --git a/spec/models/project_services/slack_service/note_message_spec.rb b/spec/models/project_services/slack_service/note_message_spec.rb index 38cfe4ad3e3..97f818125d3 100644 --- a/spec/models/project_services/slack_service/note_message_spec.rb +++ b/spec/models/project_services/slack_service/note_message_spec.rb @@ -37,8 +37,8 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on commits' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("test.user commented on " \ - "<url|commit 5f163b2b> in <somewhere.com|project_name>: " \ + expect(message.pretext).to eq("test.user <url|commented on " \ + "commit 5f163b2b> in <somewhere.com|project_name>: " \ "*Added a commit message*") expected_attachments = [ { @@ -63,8 +63,8 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on a merge request' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("test.user commented on " \ - "<url|merge request !30> in <somewhere.com|project_name>: " \ + expect(message.pretext).to eq("test.user <url|commented on " \ + "merge request !30> in <somewhere.com|project_name>: " \ "*merge request title*") expected_attachments = [ { @@ -90,8 +90,8 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on an issue' do message = SlackService::NoteMessage.new(@args) expect(message.pretext).to eq( - "test.user commented on " \ - "<url|issue #20> in <somewhere.com|project_name>: " \ + "test.user <url|commented on " \ + "issue #20> in <somewhere.com|project_name>: " \ "*issue title*") expected_attachments = [ { @@ -115,8 +115,8 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on a project snippet' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("test.user commented on " \ - "<url|snippet #5> in <somewhere.com|project_name>: " \ + expect(message.pretext).to eq("test.user <url|commented on " \ + "snippet #5> in <somewhere.com|project_name>: " \ "*snippet title*") expected_attachments = [ { diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index c74d9c282cf..33b5d8388c8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -20,6 +20,7 @@ describe Project, models: true do it { is_expected.to have_many(:deploy_keys) } it { is_expected.to have_many(:hooks).dependent(:destroy) } it { is_expected.to have_many(:protected_branches).dependent(:destroy) } + it { is_expected.to have_many(:chat_services) } it { is_expected.to have_one(:forked_project_link).dependent(:destroy) } it { is_expected.to have_one(:slack_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } @@ -35,6 +36,7 @@ describe Project, models: true do it { is_expected.to have_one(:hipchat_service).dependent(:destroy) } it { is_expected.to have_one(:flowdock_service).dependent(:destroy) } it { is_expected.to have_one(:assembla_service).dependent(:destroy) } + it { is_expected.to have_one(:mattermost_slash_commands_service).dependent(:destroy) } it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) } it { is_expected.to have_one(:buildkite_service).dependent(:destroy) } it { is_expected.to have_one(:bamboo_service).dependent(:destroy) } @@ -700,7 +702,7 @@ describe Project, models: true do "/uploads/project/avatar/#{project.id}/uploads/avatar.png" end - it { should eq "http://localhost#{avatar_path}" } + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'When avatar file in git' do @@ -712,7 +714,7 @@ describe Project, models: true do "/#{project.namespace.name}/#{project.path}/avatar" end - it { should eq "http://localhost#{avatar_path}" } + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'when git repo is empty' do @@ -1640,15 +1642,18 @@ describe Project, models: true do end it 'returns environment when with_tags is set' do - expect(project.environments_for('master', project.commit, with_tags: true)).to contain_exactly(environment) + expect(project.environments_for('master', commit: project.commit, with_tags: true)) + .to contain_exactly(environment) end it 'does not return environment when no with_tags is set' do - expect(project.environments_for('master', project.commit)).to be_empty + expect(project.environments_for('master', commit: project.commit)) + .to be_empty end it 'does not return environment when commit is not part of deployment' do - expect(project.environments_for('master', project.commit('feature'))).to be_empty + expect(project.environments_for('master', commit: project.commit('feature'))) + .to be_empty end end @@ -1658,15 +1663,65 @@ describe Project, models: true do end it 'returns environment when ref is set' do - expect(project.environments_for('master', project.commit)).to contain_exactly(environment) + expect(project.environments_for('master', commit: project.commit)) + .to contain_exactly(environment) end it 'does not environment when ref is different' do - expect(project.environments_for('feature', project.commit)).to be_empty + expect(project.environments_for('feature', commit: project.commit)) + .to be_empty end it 'does not return environment when commit is not part of deployment' do - expect(project.environments_for('master', project.commit('feature'))).to be_empty + expect(project.environments_for('master', commit: project.commit('feature'))) + .to be_empty + end + + it 'returns environment when commit constraint is not set' do + expect(project.environments_for('master')) + .to contain_exactly(environment) + end + end + end + + describe '#environments_recently_updated_on_branch' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + + context 'when last deployment to environment is the most recent one' do + before do + create(:deployment, environment: environment, ref: 'feature') + end + + it 'finds recently updated environment' do + expect(project.environments_recently_updated_on_branch('feature')) + .to contain_exactly(environment) + end + end + + context 'when last deployment to environment is not the most recent' do + before do + create(:deployment, environment: environment, ref: 'feature') + create(:deployment, environment: environment, ref: 'master') + end + + it 'does not find environment' do + expect(project.environments_recently_updated_on_branch('feature')) + .to be_empty + end + end + + context 'when there are two environments that deploy to the same branch' do + let(:second_environment) { create(:environment, project: project) } + + before do + create(:deployment, environment: environment, ref: 'feature') + create(:deployment, environment: second_environment, ref: 'feature') + end + + it 'finds both environments' do + expect(project.environments_recently_updated_on_branch('feature')) + .to contain_exactly(environment, second_environment) end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 2470d504c68..72ac41f3472 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1354,6 +1354,28 @@ describe Repository, models: true do repository.add_tag(user, '8.5', 'master', 'foo') end + it 'does not create a tag when a pre-hook fails' do + allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) + + expect do + repository.add_tag(user, '8.5', 'master', 'foo') + end.to raise_error(GitHooksService::PreReceiveError) + + repository.expire_tags_cache + expect(repository.find_tag('8.5')).to be_nil + end + + it 'passes tag SHA to hooks' do + spy = GitHooksService.new + allow(GitHooksService).to receive(:new).and_return(spy) + allow(spy).to receive(:execute).and_call_original + + tag = repository.add_tag(user, '8.5', 'master', 'foo') + + expect(spy).to have_received(:execute). + with(anything, anything, anything, tag.target, anything) + end + it 'returns a Gitlab::Git::Tag object' do tag = repository.add_tag(user, '8.5', 'master', 'foo') diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb new file mode 100644 index 00000000000..9ab112bb2ee --- /dev/null +++ b/spec/models/subscription_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Subscription, models: true do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:subscribable) } + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:subscribable) } + it { is_expected.to validate_presence_of(:user) } + + it 'validates uniqueness of project_id scoped to subscribable_id, subscribable_type, and user_id' do + create(:subscription) + + expect(subject).to validate_uniqueness_of(:project_id).scoped_to([:subscribable_id, :subscribable_type, :user_id]) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 580ce4a9e0a..0994159e210 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -33,6 +33,7 @@ describe User, models: true do it { is_expected.to have_many(:award_emoji).dependent(:destroy) } it { is_expected.to have_many(:builds).dependent(:nullify) } it { is_expected.to have_many(:pipelines).dependent(:nullify) } + it { is_expected.to have_many(:chat_names).dependent(:destroy) } describe '#group_members' do it 'does not include group memberships for which user is a requester' do diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index d79b204a24e..d9fdafde05e 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -57,13 +57,48 @@ describe API::API, api: true do end context "when using all_available in request" do + let(:response_groups) { json_response.map { |group| group['name'] } } + it "returns all groups you have access to" do public_group = create :group, :public get api("/groups", user1), all_available: true expect(response).to have_http_status(200) expect(json_response).to be_an Array - expect(json_response.first['name']).to eq(public_group.name) + expect(response_groups).to contain_exactly(public_group.name, group1.name) + end + end + + context "when using sorting" do + let(:group3) { create(:group, name: "a#{group1.name}", path: "z#{group1.path}") } + let(:response_groups) { json_response.map { |group| group['name'] } } + + before do + group3.add_owner(user1) + end + + it "sorts by name ascending by default" do + get api("/groups", user1) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group3.name, group1.name]) + end + + it "sorts in descending order when passed" do + get api("/groups", user1), sort: "desc" + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group1.name, group3.name]) + end + + it "sorts by the order_by param" do + get api("/groups", user1), order_by: "path" + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group1.name, group3.name]) end end end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 8f1a1f9e827..e88a7e27d45 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -5,7 +5,7 @@ describe API::API, api: true do let(:user) { create(:user) } let(:key) { create(:key, user: user) } let(:project) { create(:project) } - let(:secret_token) { File.read Gitlab.config.gitlab_shell.secret_file } + let(:secret_token) { Gitlab::Shell.secret_token } describe "GET /internal/check", no_db: true do it do @@ -406,7 +406,7 @@ describe API::API, api: true do it 'returns link to create new merge request' do expect(json_response).to match [{ "branch_name" => "new_branch", - "url" => "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", + "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", "new_merge_request" => true }] end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index beed53d1e5c..7bae055b241 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -637,7 +637,7 @@ describe API::API, api: true do it "sends notifications for subscribers of newly added labels" do label = project.labels.first - label.toggle_subscription(user2) + label.toggle_subscription(user2, project) perform_enqueued_jobs do post api("/projects/#{project.id}/issues", user), @@ -828,7 +828,7 @@ describe API::API, api: true do it "sends notifications for subscribers of newly added labels when issue is updated" do label = create(:label, title: 'foo', color: '#FFAABB', project: project) - label.toggle_subscription(user2) + label.toggle_subscription(user2, project) perform_enqueued_jobs do put api("/projects/#{project.id}/issues/#{issue.id}", user), diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb index 77dfebf1a98..aaf41639277 100644 --- a/spec/requests/api/labels_spec.rb +++ b/spec/requests/api/labels_spec.rb @@ -339,7 +339,7 @@ describe API::API, api: true do end context "when user is already subscribed to label" do - before { label1.subscribe(user) } + before { label1.subscribe(user, project) } it "returns 304" do post api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) @@ -358,7 +358,7 @@ describe API::API, api: true do end describe "DELETE /projects/:id/labels/:label_id/subscription" do - before { label1.subscribe(user) } + before { label1.subscribe(user, project) } context "when label_id is a label title" do it "unsubscribes from the label" do @@ -381,7 +381,7 @@ describe API::API, api: true do end context "when user is already unsubscribed from label" do - before { label1.unsubscribe(user) } + before { label1.unsubscribe(user, project) } it "returns 304" do delete api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index 2aadab3cbe1..ce9c96ace21 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -88,4 +88,61 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/services/:slug/trigger' do + let!(:project) { create(:empty_project) } + let(:service_name) { 'mattermost_slash_commands' } + + context 'no service is available' do + it 'returns a not found message' do + post api("/projects/#{project.id}/services/idonotexist/trigger") + + expect(response).to have_http_status(404) + expect(json_response["message"]).to eq("404 Service Not Found") + end + end + + context 'the service exists' do + let(:params) { { token: 'token' } } + + context 'the service is not active' do + let!(:inactive_service) do + project.create_mattermost_slash_commands_service( + active: false, + properties: { token: 'token' } + ) + end + + it 'when the service is inactive' do + post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger") + + expect(response).to have_http_status(404) + end + end + + context 'the service is active' do + let!(:active_service) do + project.create_mattermost_slash_commands_service( + active: true, + properties: { token: 'token' } + ) + end + + it 'retusn status 200' do + post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params + + expect(response).to have_http_status(200) + end + end + + context 'when the project can not be found' do + it 'returns a generic 404' do + post api("/projects/404/services/mattermost_slash_commands/trigger"), params + + expect(response).to have_http_status(404) + expect(json_response["message"]).to eq("404 Service Not Found") + end + end + end + end end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb new file mode 100644 index 00000000000..705dbb7d1c0 --- /dev/null +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe 'cycle analytics events' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } + + describe 'GET /:namespace/:project/cycle_analytics/events/issues' do + before do + project.team << [user, :developer] + + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + + 3.times { create_cycle } + deploy_master + + login_as(user) + end + + it 'lists the issue events' do + get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_issue_iid) + end + + it 'lists the plan events' do + get namespace_project_cycle_analytics_plan_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['short_sha']).to eq(MergeRequest.last.commits.first.short_id) + end + + it 'lists the code events' do + get namespace_project_cycle_analytics_code_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_mr_iid) + end + + it 'lists the test events' do + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + + it 'lists the review events' do + get namespace_project_cycle_analytics_review_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_mr_iid) + end + + it 'lists the staging events' do + get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + + it 'lists the production events' do + get namespace_project_cycle_analytics_production_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_issue_iid) + end + + context 'specific branch' do + it 'lists the test events' do + branch = MergeRequest.first.source_branch + + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json, branch: branch) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + end + + context 'with private project and builds' do + before do + ProjectMember.first.update(access_level: Gitlab::Access::GUEST) + end + + it 'does not list the test events' do + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:not_found) + end + + it 'does not list the staging events' do + get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:not_found) + end + + it 'lists the issue events' do + get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:ok) + end + end + end + + def json_response + JSON.parse(response.body) + end + + def create_cycle + milestone = create(:milestone, project: project) + issue.update(milestone: milestone) + mr = create_merge_request_closing_issue(issue) + + pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) + pipeline.run + + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + merge_merge_requests_closing_issue(issue) + + ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha) + end +end diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb new file mode 100644 index 00000000000..9ac6f20fd3c --- /dev/null +++ b/spec/serializers/analytics_build_entity_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe AnalyticsBuildEntity do + let(:entity) do + described_class.new(build, request: double) + end + + context 'build with an author' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, author: user) } + + subject { entity.as_json } + + it 'contains the URL' do + expect(subject).to include(:url) + end + + it 'contains the author' do + expect(subject).to include(:author) + end + + it 'does not contain sensitive information' do + expect(subject).not_to include(/token/) + expect(subject).not_to include(/variables/) + end + end +end diff --git a/spec/serializers/analytics_build_serializer_spec.rb b/spec/serializers/analytics_build_serializer_spec.rb new file mode 100644 index 00000000000..a0a9d9a5f12 --- /dev/null +++ b/spec/serializers/analytics_build_serializer_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe AnalyticsBuildSerializer do + let(:serializer) do + described_class + .new.represent(resource) + end + + let(:json) { serializer.as_json } + let(:resource) { create(:ci_build) } + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of analyticsBuild' do + expect(json) + .to include(:name, :branch, :short_sha, :date, :total_time, :url, :author) + end + end +end diff --git a/spec/serializers/analytics_generic_entity_spec.rb b/spec/serializers/analytics_generic_entity_spec.rb new file mode 100644 index 00000000000..68086216ba9 --- /dev/null +++ b/spec/serializers/analytics_generic_entity_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe AnalyticsIssueEntity do + let(:user) { create(:user) } + let(:entity_hash) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + created_at: "2016-11-12 15:04:02.948604", + author: user, + } + end + + let(:project) { create(:empty_project) } + let(:request) { EntityRequest.new(project: project, entity: :merge_request) } + + let(:entity) do + described_class.new(entity_hash, request: request, project: project) + end + + context 'generic entity' do + subject { entity.as_json } + + it 'contains the entity URL' do + expect(subject).to include(:url) + end + + it 'contains the author' do + expect(subject).to include(:author) + end + + it 'does not contain sensitive information' do + expect(subject).not_to include(/token/) + expect(subject).not_to include(/variables/) + end + end +end diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb new file mode 100644 index 00000000000..2842e1ba52f --- /dev/null +++ b/spec/serializers/analytics_issue_serializer_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe AnalyticsIssueSerializer do + let(:serializer) do + described_class + .new(project: project, entity: :merge_request) + .represent(resource) + end + + let(:user) { create(:user) } + let(:json) { serializer.as_json } + let(:project) { create(:project) } + let(:resource) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + created_at: "2016-11-12 15:04:02.948604", + author: user, + } + end + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of the issue' do + expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author) + end + end +end diff --git a/spec/serializers/analytics_merge_request_serializer_spec.rb b/spec/serializers/analytics_merge_request_serializer_spec.rb new file mode 100644 index 00000000000..564207984df --- /dev/null +++ b/spec/serializers/analytics_merge_request_serializer_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe AnalyticsMergeRequestSerializer do + let(:serializer) do + described_class + .new(project: project, entity: :merge_request) + .represent(resource) + end + + let(:user) { create(:user) } + let(:json) { serializer.as_json } + let(:project) { create(:project) } + let(:resource) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + state: 'open', + created_at: "2016-11-12 15:04:02.948604", + author: user + } + end + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of the merge request' do + expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author, :state) + end + end +end diff --git a/spec/serializers/entity_date_helper_spec.rb b/spec/serializers/entity_date_helper_spec.rb new file mode 100644 index 00000000000..b9cc2f64831 --- /dev/null +++ b/spec/serializers/entity_date_helper_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe EntityDateHelper do + let(:date_helper_class) { Class.new { include EntityDateHelper }.new } + + it 'converts 0 seconds' do + expect(date_helper_class.distance_of_time_as_hash(0)).to eq(seconds: 0) + end + + it 'converts 40 seconds' do + expect(date_helper_class.distance_of_time_as_hash(40)).to eq(seconds: 40) + end + + it 'converts 60 seconds' do + expect(date_helper_class.distance_of_time_as_hash(60)).to eq(mins: 1) + end + + it 'converts 70 seconds' do + expect(date_helper_class.distance_of_time_as_hash(70)).to eq(mins: 1, seconds: 10) + end + + it 'converts 3600 seconds' do + expect(date_helper_class.distance_of_time_as_hash(3600)).to eq(hours: 1) + end + + it 'converts 3750 seconds' do + expect(date_helper_class.distance_of_time_as_hash(3750)).to eq(hours: 1, mins: 2, seconds: 30) + end + + it 'converts 86400 seconds' do + expect(date_helper_class.distance_of_time_as_hash(86400)).to eq(days: 1) + end + + it 'converts 86560 seconds' do + expect(date_helper_class.distance_of_time_as_hash(86560)).to eq(days: 1, mins: 2, seconds: 40) + end + + it 'converts 86760 seconds' do + expect(date_helper_class.distance_of_time_as_hash(99760)).to eq(days: 1, hours: 3, mins: 42, seconds: 40) + end + + it 'converts 986760 seconds' do + expect(date_helper_class.distance_of_time_as_hash(986760)).to eq(days: 11, hours: 10, mins: 6) + end +end diff --git a/spec/services/after_branch_delete_service_spec.rb b/spec/services/after_branch_delete_service_spec.rb new file mode 100644 index 00000000000..d29e0addb53 --- /dev/null +++ b/spec/services/after_branch_delete_service_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe AfterBranchDeleteService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + it 'stops environments attached to branch' do + expect(service).to receive(:stop_environments) + + service.execute('feature') + end + end +end diff --git a/spec/services/chat_names/authorize_user_service_spec.rb b/spec/services/chat_names/authorize_user_service_spec.rb new file mode 100644 index 00000000000..d50bfb0492c --- /dev/null +++ b/spec/services/chat_names/authorize_user_service_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe ChatNames::AuthorizeUserService, services: true do + describe '#execute' do + let(:service) { create(:service) } + + subject { described_class.new(service, params).execute } + + context 'when all parameters are valid' do + let(:params) { { team_id: 'T0001', team_domain: 'myteam', user_id: 'U0001', user_name: 'user' } } + + it 'requests a new token' do + is_expected.to be_url + end + end + + context 'when there are missing parameters' do + let(:params) { {} } + + it 'does not request a new token' do + is_expected.to be_nil + end + end + end +end diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb new file mode 100644 index 00000000000..51441e8f3be --- /dev/null +++ b/spec/services/chat_names/find_user_service_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe ChatNames::FindUserService, services: true do + describe '#execute' do + let(:service) { create(:service) } + + subject { described_class.new(service, params).execute } + + context 'find user mapping' do + let(:user) { create(:user) } + let!(:chat_name) { create(:chat_name, user: user, service: service) } + + context 'when existing user is requested' do + let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } } + + it 'returns the existing user' do + is_expected.to eq(user) + end + + it 'updates when last time chat name was used' do + subject + + expect(chat_name.reload.last_used_at).to be_like_time(Time.now) + end + end + + context 'when different user is requested' do + let(:params) { { team_id: chat_name.team_id, user_id: 'non-existing-user' } } + + it 'returns existing user' do + is_expected.to be_nil + end + end + end + end +end diff --git a/spec/services/ci/stop_environments_service_spec.rb b/spec/services/ci/stop_environments_service_spec.rb new file mode 100644 index 00000000000..6f7d1a5d28d --- /dev/null +++ b/spec/services/ci/stop_environments_service_spec.rb @@ -0,0 +1,105 @@ +require 'spec_helper' + +describe Ci::StopEnvironmentsService, services: true do + let(:project) { create(:project, :private) } + let(:user) { create(:user) } + + let(:service) { described_class.new(project, user) } + + describe '#execute' do + context 'when environment with review app exists' do + before do + create(:environment, :with_review_app, project: project, + ref: 'feature') + end + + context 'when user has permission to stop environment' do + before do + project.team << [user, :developer] + end + + context 'when environment is associated with removed branch' do + it 'stops environment' do + expect_environment_stopped_on('feature') + end + end + + context 'when environment is associated with different branch' do + it 'does not stop environment' do + expect_environment_not_stopped_on('master') + end + end + + context 'when specified branch does not exist' do + it 'does not stop environment' do + expect_environment_not_stopped_on('non/existent/branch') + end + end + + context 'when no branch not specified' do + it 'does not stop environment' do + expect_environment_not_stopped_on(nil) + end + end + + context 'when environment is not stoppable' do + before do + allow_any_instance_of(Environment) + .to receive(:stoppable?).and_return(false) + end + + it 'does not stop environment' do + expect_environment_not_stopped_on('feature') + end + end + end + + context 'when user does not have permission to stop environment' do + before do + project.team << [user, :guest] + end + + it 'does not stop environment' do + expect_environment_not_stopped_on('master') + end + end + end + + context 'when there is no environment associated with review app' do + before do + create(:environment, project: project) + end + + context 'when user has permission to stop environments' do + before do + project.team << [user, :master] + end + + it 'does not stop environment' do + expect_environment_not_stopped_on('master') + end + end + end + + context 'when environment does not exist' do + it 'does not raise error' do + expect { service.execute('master') } + .not_to raise_error + end + end + end + + def expect_environment_stopped_on(branch) + expect_any_instance_of(Environment) + .to receive(:stop!) + + service.execute(branch) + end + + def expect_environment_not_stopped_on(branch) + expect_any_instance_of(Environment) + .not_to receive(:stop!) + + service.execute(branch) + end +end diff --git a/spec/services/delete_branch_service_spec.rb b/spec/services/delete_branch_service_spec.rb new file mode 100644 index 00000000000..336f5dafb5b --- /dev/null +++ b/spec/services/delete_branch_service_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe DeleteBranchService, services: true do + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + context 'when user has access to push to repository' do + before do + project.team << [user, :developer] + end + + it 'removes the branch' do + expect(branch_exists?('feature')).to be true + + result = service.execute('feature') + + expect(result[:status]).to eq :success + expect(branch_exists?('feature')).to be false + end + end + + context 'when user does not have access to push to repository' do + it 'does not remove branch' do + expect(branch_exists?('feature')).to be true + + result = service.execute('feature') + + expect(result[:status]).to eq :error + expect(result[:message]).to eq 'You dont have push access to repo' + expect(branch_exists?('feature')).to be true + end + end + end + + def branch_exists?(branch_name) + repository.ref_exists?("refs/heads/#{branch_name}") + end +end diff --git a/spec/services/destroy_group_service_spec.rb b/spec/services/destroy_group_service_spec.rb index da724643604..538e85cdc89 100644 --- a/spec/services/destroy_group_service_spec.rb +++ b/spec/services/destroy_group_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe DestroyGroupService, services: true do + include DatabaseConnectionHelpers + let!(:user) { create(:user) } let!(:group) { create(:group) } let!(:project) { create(:project, namespace: group) } @@ -50,6 +52,44 @@ describe DestroyGroupService, services: true do describe 'asynchronous delete' do it_behaves_like 'group destruction', true + + context 'potential race conditions' do + context "when the `GroupDestroyWorker` task runs immediately" do + it "deletes the group" do + # Commit the contents of this spec's transaction so far + # so subsequent db connections can see it. + # + # DO NOT REMOVE THIS LINE, even if you see a WARNING with "No + # transaction is currently in progress". Without this, this + # spec will always be green, since the group created in setup + # cannot be seen by any other connections / threads in this spec. + Group.connection.commit_db_transaction + + group_record = run_with_new_database_connection do |conn| + conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first + end + + expect(group_record).not_to be_nil + + # Execute the contents of `GroupDestroyWorker` in a separate thread, to + # simulate data manipulation by the Sidekiq worker (different database + # connection / transaction). + expect(GroupDestroyWorker).to receive(:perform_async).and_wrap_original do |m, group_id, user_id| + Thread.new { m[group_id, user_id] }.join(5) + end + + # Kick off the initial group destroy in a new thread, so that + # it doesn't share this spec's database transaction. + Thread.new { DestroyGroupService.new(group, user).async_execute }.join(5) + + group_record = run_with_new_database_connection do |conn| + conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first + end + + expect(group_record).to be_nil + end + end + end end describe 'synchronous delete' do diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index cea7e6429f9..62f9982e840 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -490,7 +490,17 @@ describe GitPushService, services: true do context "closing an issue" do let(:message) { "this is some work.\n\ncloses JIRA-1" } - let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json } + let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://#{Gitlab.config.gitlab.host}/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json } + + before do + open_issue = JIRA::Resource::Issue.new(jira_tracker.client, attrs: { "id" => "JIRA-1" }) + closed_issue = open_issue.dup + allow(open_issue).to receive(:resolution).and_return(false) + allow(closed_issue).to receive(:resolution).and_return(true) + allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) + + allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-1") + end context "using right markdown" do it "initiates one api call to jira server to close the issue" do diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 6f7ce8ca992..5f3020b6525 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -260,14 +260,14 @@ describe Issuable::BulkUpdateService, services: true do it 'subscribes the given user' do bulk_update(issues, subscription_event: 'subscribe') - expect(issues).to all(be_subscribed(user)) + expect(issues).to all(be_subscribed(user, project)) end end describe 'unsubscribe from issues' do let(:issues) do create_list(:closed_issue, 2, project: project) do |issue| - issue.subscriptions.create(user: user, subscribed: true) + issue.subscriptions.create(user: user, project: project, subscribed: true) end end @@ -275,7 +275,7 @@ describe Issuable::BulkUpdateService, services: true do bulk_update(issues, subscription_event: 'unsubscribe') issues.each do |issue| - expect(issue).not_to be_subscribed(user) + expect(issue).not_to be_subscribed(user, project) end end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 1638a46ed51..4777a90639e 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -215,7 +215,7 @@ describe Issues::UpdateService, services: true do let!(:subscriber) do create(:user).tap do |u| - label.toggle_subscription(u) + label.toggle_subscription(u, project) project.team << [u, :developer] end end diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index 3a71776e81f..08829e4be70 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -4,8 +4,8 @@ describe MergeRequests::GetUrlsService do let(:project) { create(:project, :public) } let(:service) { MergeRequests::GetUrlsService.new(project) } let(:source_branch) { "my_branch" } - let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } - let(:show_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } + let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } + let(:show_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } let(:deleted_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 #{Gitlab::Git::BLANK_SHA} refs/heads/#{source_branch}" } let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } @@ -115,7 +115,7 @@ describe MergeRequests::GetUrlsService do let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" } let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/existing_branch" } let(:changes) { "#{new_branch_changes}\n#{existing_branch_changes}" } - let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" } + let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" } it 'returns 2 urls for both creating new and showing merge request' do result = service.execute(changes) diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index f93d7732a9a..1fd9f5a4910 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -67,17 +67,19 @@ describe MergeRequests::MergeService, services: true do it 'closes issues on JIRA issue tracker' do jira_issue = ExternalIssue.new('JIRA-123', project) + stub_jira_urls(jira_issue) commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}") allow(merge_request).to receive(:commits).and_return([commit]) - expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, jira_issue).once + expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, an_instance_of(JIRA::Resource::Issue)).once service.execute(merge_request) end context "wrong issue markdown" do it 'does not close issues on JIRA issue tracker' do - jira_issue = ExternalIssue.new('#123', project) + jira_issue = ExternalIssue.new('#JIRA-123', project) + stub_jira_urls(jira_issue) commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}") allow(merge_request).to receive(:commits).and_return([commit]) diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 2433a7dad06..cb5d7cdb467 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -199,7 +199,7 @@ describe MergeRequests::UpdateService, services: true do context 'when the issue is relabeled' do let!(:non_subscriber) { create(:user) } - let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + let!(:subscriber) { create(:user) { |u| label.toggle_subscription(u, project) } } before do project.team << [non_subscriber, :developer] diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 8ce35354c22..8726c9eaa55 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -342,7 +342,9 @@ describe NotificationService, services: true do end describe 'Issues' do - let(:project) { create(:empty_project, :public) } + let(:group) { create(:group) } + let(:project) { create(:empty_project, :public, namespace: group) } + let(:another_project) { create(:empty_project, :public, namespace: group) } let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' } before do @@ -377,13 +379,24 @@ describe NotificationService, services: true do end it "emails subscribers of the issue's labels" do - subscriber = create(:user) - label = create(:label, issues: [issue]) + user_1 = create(:user) + user_2 = create(:user) + user_3 = create(:user) + user_4 = create(:user) + label = create(:label, project: project, issues: [issue]) + group_label = create(:group_label, group: group, issues: [issue]) issue.reload - label.toggle_subscription(subscriber) + label.toggle_subscription(user_1, project) + group_label.toggle_subscription(user_2, project) + group_label.toggle_subscription(user_3, another_project) + group_label.toggle_subscription(user_4) + notification.new_issue(issue, @u_disabled) - should_email(subscriber) + should_email(user_1) + should_email(user_2) + should_not_email(user_3) + should_email(user_4) end context 'confidential issues' do @@ -399,14 +412,14 @@ describe NotificationService, services: true do project.team << [member, :developer] project.team << [guest, :guest] - label = create(:label, issues: [confidential_issue]) + label = create(:label, project: project, issues: [confidential_issue]) confidential_issue.reload - label.toggle_subscription(non_member) - label.toggle_subscription(author) - label.toggle_subscription(assignee) - label.toggle_subscription(member) - label.toggle_subscription(guest) - label.toggle_subscription(admin) + label.toggle_subscription(non_member, project) + label.toggle_subscription(author, project) + label.toggle_subscription(assignee, project) + label.toggle_subscription(member, project) + label.toggle_subscription(guest, project) + label.toggle_subscription(admin, project) reset_delivered_emails! @@ -554,20 +567,30 @@ describe NotificationService, services: true do end describe '#relabeled_issue' do - let(:label) { create(:label, issues: [issue]) } - let(:label2) { create(:label) } - let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } - let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', issues: [issue]) } + let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') } + let(:label_1) { create(:label, project: project, title: 'Label 1', issues: [issue]) } + let(:label_2) { create(:label, project: project, title: 'Label 2') } + let!(:subscriber_to_group_label_1) { create(:user) { |u| group_label_1.toggle_subscription(u, project) } } + let!(:subscriber_1_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u, project) } } + let!(:subscriber_2_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u) } } + let!(:subscriber_to_group_label_2_on_another_project) { create(:user) { |u| group_label_2.toggle_subscription(u, another_project) } } + let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } } + let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } } it "emails subscribers of the issue's added labels only" do - notification.relabeled_issue(issue, [label2], @u_disabled) - - should_not_email(subscriber_to_label) - should_email(subscriber_to_label2) + notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) + + should_not_email(subscriber_to_label_1) + should_not_email(subscriber_to_group_label_1) + should_not_email(subscriber_to_group_label_2_on_another_project) + should_email(subscriber_1_to_group_label_2) + should_email(subscriber_2_to_group_label_2) + should_email(subscriber_to_label_2) end it "doesn't send email to anyone but subscribers of the given labels" do - notification.relabeled_issue(issue, [label2], @u_disabled) + notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) should_not_email(issue.assignee) should_not_email(issue.author) @@ -578,8 +601,12 @@ describe NotificationService, services: true do should_not_email(@watcher_and_subscriber) should_not_email(@unsubscriber) should_not_email(@u_participating) - should_not_email(subscriber_to_label) - should_email(subscriber_to_label2) + should_not_email(subscriber_to_label_1) + should_not_email(subscriber_to_group_label_1) + should_not_email(subscriber_to_group_label_2_on_another_project) + should_email(subscriber_1_to_group_label_2) + should_email(subscriber_2_to_group_label_2) + should_email(subscriber_to_label_2) end context 'confidential issues' do @@ -590,19 +617,19 @@ describe NotificationService, services: true do let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } - let!(:label_1) { create(:label, issues: [confidential_issue]) } - let!(:label_2) { create(:label) } + let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) } + let!(:label_2) { create(:label, project: project) } it "emails subscribers of the issue's labels that can read the issue" do project.team << [member, :developer] project.team << [guest, :guest] - label_2.toggle_subscription(non_member) - label_2.toggle_subscription(author) - label_2.toggle_subscription(assignee) - label_2.toggle_subscription(member) - label_2.toggle_subscription(guest) - label_2.toggle_subscription(admin) + label_2.toggle_subscription(non_member, project) + label_2.toggle_subscription(author, project) + label_2.toggle_subscription(assignee, project) + label_2.toggle_subscription(member, project) + label_2.toggle_subscription(guest, project) + label_2.toggle_subscription(admin, project) reset_delivered_emails! @@ -725,7 +752,9 @@ describe NotificationService, services: true do end describe 'Merge Requests' do - let(:project) { create(:project, :public) } + let(:group) { create(:group) } + let(:project) { create(:project, :public, namespace: group) } + let(:another_project) { create(:empty_project, :public, namespace: group) } let(:merge_request) { create :merge_request, source_project: project, assignee: create(:user), description: 'cc @participant' } before do @@ -758,12 +787,23 @@ describe NotificationService, services: true do end it "emails subscribers of the merge request's labels" do - subscriber = create(:user) - label = create(:label, merge_requests: [merge_request]) - label.toggle_subscription(subscriber) + user_1 = create(:user) + user_2 = create(:user) + user_3 = create(:user) + user_4 = create(:user) + label = create(:label, project: project, merge_requests: [merge_request]) + group_label = create(:group_label, group: group, merge_requests: [merge_request]) + label.toggle_subscription(user_1, project) + group_label.toggle_subscription(user_2, project) + group_label.toggle_subscription(user_3, another_project) + group_label.toggle_subscription(user_4) + notification.new_merge_request(merge_request, @u_disabled) - should_email(subscriber) + should_email(user_1) + should_email(user_2) + should_not_email(user_3) + should_email(user_4) end context 'participating' do @@ -857,20 +897,30 @@ describe NotificationService, services: true do end describe '#relabel_merge_request' do - let(:label) { create(:label, merge_requests: [merge_request]) } - let(:label2) { create(:label) } - let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } - let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', merge_requests: [merge_request]) } + let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') } + let(:label_1) { create(:label, project: project, title: 'Label 1', merge_requests: [merge_request]) } + let(:label_2) { create(:label, project: project, title: 'Label 2') } + let!(:subscriber_to_group_label_1) { create(:user) { |u| group_label_1.toggle_subscription(u, project) } } + let!(:subscriber_1_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u, project) } } + let!(:subscriber_2_to_group_label_2) { create(:user) { |u| group_label_2.toggle_subscription(u) } } + let!(:subscriber_to_group_label_2_on_another_project) { create(:user) { |u| group_label_2.toggle_subscription(u, another_project) } } + let!(:subscriber_to_label_1) { create(:user) { |u| label_1.toggle_subscription(u, project) } } + let!(:subscriber_to_label_2) { create(:user) { |u| label_2.toggle_subscription(u, project) } } it "emails subscribers of the merge request's added labels only" do - notification.relabeled_merge_request(merge_request, [label2], @u_disabled) - - should_not_email(subscriber_to_label) - should_email(subscriber_to_label2) + notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled) + + should_not_email(subscriber_to_label_1) + should_not_email(subscriber_to_group_label_1) + should_not_email(subscriber_to_group_label_2_on_another_project) + should_email(subscriber_1_to_group_label_2) + should_email(subscriber_2_to_group_label_2) + should_email(subscriber_to_label_2) end it "doesn't send email to anyone but subscribers of the given labels" do - notification.relabeled_merge_request(merge_request, [label2], @u_disabled) + notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled) should_not_email(merge_request.assignee) should_not_email(merge_request.author) @@ -881,8 +931,12 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_lazy_participant) - should_not_email(subscriber_to_label) - should_email(subscriber_to_label2) + should_not_email(subscriber_to_label_1) + should_not_email(subscriber_to_group_label_1) + should_not_email(subscriber_to_group_label_2_on_another_project) + should_email(subscriber_1_to_group_label_2) + should_email(subscriber_2_to_group_label_2) + should_email(subscriber_to_label_2) end end @@ -1290,10 +1344,10 @@ describe NotificationService, services: true do project.team << [@unsubscriber, :master] project.team << [@watcher_and_subscriber, :master] - issuable.subscriptions.create(user: @subscriber, subscribed: true) - issuable.subscriptions.create(user: @subscribed_participant, subscribed: true) - issuable.subscriptions.create(user: @unsubscriber, subscribed: false) + issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true) + issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true) + issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false) # Make the watcher a subscriber to detect dupes - issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true) + issuable.subscriptions.create(user: @watcher_and_subscriber, project: project, subscribed: true) end end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index b57e338b782..becf627a4f5 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -169,7 +169,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unsubscribe command' do it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do - issuable.subscribe(developer) + issuable.subscribe(developer, project) _, updates = service.execute(content, issuable) expect(updates).to eq(subscription_event: 'unsubscribe') @@ -321,7 +321,7 @@ describe SlashCommands::InterpretService, services: true do it_behaves_like 'multiple label with same argument' do let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } let(:issuable) { issue } - end + end it_behaves_like 'unlabel command' do let(:content) { %(/unlabel ~"#{inprogress.title}") } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5bb107fdd85..56d39e9a005 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe SystemNoteService, services: true do + include Gitlab::Routing.url_helpers + let(:project) { create(:project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } @@ -543,23 +545,55 @@ describe SystemNoteService, services: true do before { stub_jira_urls(jira_issue.id) } - context 'in JIRA issue tracker' do + context 'in issue' do before { jira_service_settings } describe "new reference" do subject { described_class.cross_reference(jira_issue, commit, author) } it { is_expected.to eq(success_message) } + + it "creates remote link" do + subject + + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: namespace_project_commit_url(project.namespace, project, commit), + title: "GitLab: Mentioned on commit - #{commit.title}", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: false } + } + ) + ).once + end end end - context 'issue from an issue' do + context 'in commit' do context 'in JIRA issue tracker' do before { jira_service_settings } subject { described_class.cross_reference(jira_issue, issue, author) } it { is_expected.to eq(success_message) } + + it "creates remote link" do + subject + + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: namespace_project_issue_url(project.namespace, project, issue), + title: "GitLab: Mentioned on issue - #{issue.title}", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: false } + } + ) + ).once + end end end @@ -572,6 +606,13 @@ describe SystemNoteService, services: true do subject { described_class.cross_reference(jira_issue, commit, author) } it { is_expected.not_to eq(success_message) } + + it 'does not try to create comment and remote link' do + subject + + expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) + expect(WebMock).not_to have_requested(:post, jira_api_remote_link_url(jira_issue)) + end end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 73cf4c9a24c..bead1a006d1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -26,10 +26,11 @@ RSpec.configure do |config| config.verbose_retry = true config.display_try_failure_messages = true - config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :view config.include Warden::Test::Helpers, type: :request - config.include LoginHelpers, type: :feature - config.include SearchHelpers, type: :feature + config.include LoginHelpers, type: :feature + config.include SearchHelpers, type: :feature config.include StubConfiguration config.include EmailHelpers config.include TestEnv diff --git a/spec/support/database_connection_helpers.rb b/spec/support/database_connection_helpers.rb new file mode 100644 index 00000000000..763329499f0 --- /dev/null +++ b/spec/support/database_connection_helpers.rb @@ -0,0 +1,9 @@ +module DatabaseConnectionHelpers + def run_with_new_database_connection + pool = ActiveRecord::Base.connection_pool + conn = pool.checkout + yield conn + ensure + pool.checkin(conn) + end +end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5e3b8f2b23e..194620d0a68 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -230,31 +230,31 @@ shared_examples 'issuable record that supports slash commands in its description context "with a note subscribing to the #{issuable_type}" do it "creates a new todo for the #{issuable_type}" do - expect(issuable.subscribed?(master)).to be_falsy + expect(issuable.subscribed?(master, project)).to be_falsy write_note("/subscribe") expect(page).not_to have_content '/subscribe' expect(page).to have_content 'Your commands have been executed!' - expect(issuable.subscribed?(master)).to be_truthy + expect(issuable.subscribed?(master, project)).to be_truthy end end context "with a note unsubscribing to the #{issuable_type} as done" do before do - issuable.subscribe(master) + issuable.subscribe(master, project) end it "creates a new todo for the #{issuable_type}" do - expect(issuable.subscribed?(master)).to be_truthy + expect(issuable.subscribed?(master, project)).to be_truthy write_note("/unsubscribe") expect(page).not_to have_content '/unsubscribe' expect(page).to have_content 'Your commands have been executed!' - expect(issuable.subscribed?(master)).to be_falsy + expect(issuable.subscribed?(master, project)).to be_falsy end end end diff --git a/spec/support/jira_service_helper.rb b/spec/support/jira_service_helper.rb index 96e0dad6b55..7437ba2688d 100644 --- a/spec/support/jira_service_helper.rb +++ b/spec/support/jira_service_helper.rb @@ -57,6 +57,10 @@ module JiraServiceHelper JIRA_API + "/issue/#{issue_id}/comment" end + def jira_api_remote_link_url(issue_id) + JIRA_API + "/issue/#{issue_id}/remotelink" + end + def jira_api_transition_url(issue_id) JIRA_API + "/issue/#{issue_id}/transitions" end @@ -75,6 +79,7 @@ module JiraServiceHelper WebMock.stub_request(:get, jira_issue_url(issue_id)) WebMock.stub_request(:get, jira_api_test_url) WebMock.stub_request(:post, jira_api_comment_url(issue_id)) + WebMock.stub_request(:post, jira_api_remote_link_url(issue_id)) WebMock.stub_request(:post, jira_api_transition_url(issue_id)) end end diff --git a/spec/support/matchers/be_url.rb b/spec/support/matchers/be_url.rb new file mode 100644 index 00000000000..f8096af1b22 --- /dev/null +++ b/spec/support/matchers/be_url.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :be_url do |_| + match do |actual| + URI.parse(actual) rescue false + end +end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index da43622d3f9..e0c77201116 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -1,14 +1,12 @@ require 'spec_helper' -describe 'projects/builds/show' do - include Devise::Test::ControllerHelpers - +describe 'projects/builds/show', :view do let(:project) { create(:project) } + let(:build) { create(:ci_build, pipeline: pipeline) } + let(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id) + create(:ci_pipeline, project: project, sha: project.commit.id) end - let(:build) { create(:ci_build, pipeline: pipeline) } before do assign(:build, build) @@ -17,6 +15,129 @@ describe 'projects/builds/show' do allow(view).to receive(:can?).and_return(true) end + describe 'environment info in build view' do + context 'build with latest deployment' do + let(:build) do + create(:ci_build, :success, environment: 'staging') + end + + before do + create(:environment, name: 'staging') + create(:deployment, deployable: build) + end + + it 'shows deployment message' do + expected_text = 'This build is the most recent deployment' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build with outdated deployment' do + let(:build) do + create(:ci_build, :success, environment: 'staging', pipeline: pipeline) + end + + let(:second_build) do + create(:ci_build, :success, environment: 'staging', pipeline: pipeline) + end + + let(:environment) do + create(:environment, name: 'staging', project: project) + end + + let!(:first_deployment) do + create(:deployment, environment: environment, deployable: build) + end + + let!(:second_deployment) do + create(:deployment, environment: environment, deployable: second_build) + end + + it 'shows deployment message' do + expected_text = 'This build is an out-of-date deployment ' \ + "to staging.\nView the most recent deployment ##{second_deployment.iid}." + render + + expect(rendered).to have_css('.environment-information', text: expected_text) + end + end + + context 'build failed to deploy' do + let(:build) do + create(:ci_build, :failed, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'The deployment of this build to staging did not succeed.' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build will deploy' do + let(:build) do + create(:ci_build, :running, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'This build is creating a deployment to staging' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build that failed to deploy and environment has not been created' do + let(:build) do + create(:ci_build, :failed, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'The deployment of this build to staging did not succeed' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build that will deploy and environment has not been created' do + let(:build) do + create(:ci_build, :running, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'This build is creating a deployment to staging' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + end + context 'when build is running' do before do build.run! diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb index 2c9e7c2cd02..2d47d93acec 100644 --- a/spec/workers/pipeline_metrics_worker_spec.rb +++ b/spec/workers/pipeline_metrics_worker_spec.rb @@ -15,32 +15,36 @@ describe PipelineMetricsWorker do end describe '#perform' do - subject { described_class.new.perform(pipeline.id) } + before do + described_class.new.perform(pipeline.id) + end context 'when pipeline is running' do let(:status) { 'running' } it 'records the build start time' do - subject - expect(merge_request.reload.metrics.latest_build_started_at).to be_like_time(pipeline.started_at) end it 'clears the build end time' do - subject - expect(merge_request.reload.metrics.latest_build_finished_at).to be_nil end + + it 'records the pipeline' do + expect(merge_request.reload.metrics.pipeline).to eq(pipeline) + end end context 'when pipeline succeeded' do let(:status) { 'success' } it 'records the build end time' do - subject - expect(merge_request.reload.metrics.latest_build_finished_at).to be_like_time(pipeline.finished_at) end + + it 'records the pipeline' do + expect(merge_request.reload.metrics.pipeline).to eq(pipeline) + end end end end |