summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLin Jen-Shin <godfat@godfat.org>2016-11-22 18:46:35 +0800
committerLin Jen-Shin <godfat@godfat.org>2016-11-22 18:46:35 +0800
commit17388eb034de3808c5c7f80c19726575dd073ce1 (patch)
tree2f39deac4753e2a796210a42ba477ed27c6bbe13
parentc7c4850d0b9d6751a5f6fdaa1f9c34aee6728676 (diff)
parentacaa6d733b37b18eb44a5d6e3b79f69ee821f62a (diff)
downloadgitlab-ce-17388eb034de3808c5c7f80c19726575dd073ce1.tar.gz
Merge remote-tracking branch 'upstream/master' into fix-cancelling-pipelines
* upstream/master: (133 commits) Restructure steps for MM slash commands service Add Changelog entry for CI linter validation fix Fix entry lookup in CI config inheritance rules Extend specs for global ci configuration entry Remove unnecessary require_relative calls from service classes Use single quote for strings Ue svg from SVGs object Dont trigger CI builds [ci skip] Revert "Test only migrations" Add custom copy for each empty stage Refactor Mattermost slash commands docs Fetch only one revision Highlight nav item on hover Test only migrations Fix migration paths tests Scroll CA stage panel on mobile Fix CSS declaration administer to administrator Move SVGs to JS objects for easy reuse Improve deploy command message ...
-rw-r--r--.eslintrc6
-rw-r--r--.gitlab-ci.yml19
-rw-r--r--CHANGELOG.md1
-rw-r--r--app/assets/javascripts/activities.js37
-rw-r--r--app/assets/javascripts/activities.js.es636
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es643
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es645
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es645
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es655
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es642
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js.es618
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6213
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es641
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es690
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es67
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es67
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es67
-rw-r--r--app/assets/javascripts/dispatcher.js.es69
-rw-r--r--app/assets/javascripts/environments/components/environment.js.es622
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.js.es68
-rw-r--r--app/assets/javascripts/environments/components/environment_item.js.es628
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.js.es63
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js.es667
-rw-r--r--app/assets/javascripts/merge_request_widget.js.es62
-rw-r--r--app/assets/javascripts/pager.js64
-rw-r--r--app/assets/javascripts/pager.js.es673
-rw-r--r--app/assets/javascripts/smart_interval.js.es6130
-rw-r--r--app/assets/javascripts/subbable_resource.js.es654
-rw-r--r--app/assets/javascripts/user_tabs.js.es62
-rw-r--r--app/assets/javascripts/vue_common_component/commit.js.es616
-rw-r--r--app/assets/stylesheets/framework/blocks.scss29
-rw-r--r--app/assets/stylesheets/framework/variables.scss4
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss354
-rw-r--r--app/assets/stylesheets/pages/milestone.scss104
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb36
-rw-r--r--app/controllers/projects/merge_requests_controller.rb8
-rw-r--r--app/helpers/application_settings_helper.rb10
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb4
-rw-r--r--app/models/ci/build.rb4
-rw-r--r--app/models/concerns/mentionable.rb2
-rw-r--r--app/models/cycle_analytics.rb6
-rw-r--r--app/models/environment.rb10
-rw-r--r--app/models/merge_request_diff.rb6
-rw-r--r--app/models/project.rb2
-rw-r--r--app/models/project_services/jira_service.rb30
-rw-r--r--app/models/repository.rb406
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/tree.rb4
-rw-r--r--app/services/after_branch_delete_service.rb2
-rw-r--r--app/services/create_branch_service.rb2
-rw-r--r--app/services/create_deployment_service.rb2
-rw-r--r--app/services/create_release_service.rb2
-rw-r--r--app/services/create_tag_service.rb2
-rw-r--r--app/services/delete_branch_service.rb2
-rw-r--r--app/services/delete_merged_branches_service.rb2
-rw-r--r--app/services/delete_tag_service.rb2
-rw-r--r--app/services/files/create_dir_service.rb2
-rw-r--r--app/services/files/create_service.rb2
-rw-r--r--app/services/files/delete_service.rb2
-rw-r--r--app/services/files/multi_service.rb2
-rw-r--r--app/services/files/update_service.rb2
-rw-r--r--app/services/git_push_service.rb23
-rw-r--r--app/services/merge_requests/refresh_service.rb21
-rw-r--r--app/services/merge_requests/update_service.rb4
-rw-r--r--app/services/update_release_service.rb2
-rw-r--r--app/views/admin/application_settings/_form.html.haml5
-rw-r--r--app/views/groups/issues.html.haml39
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/projects/_activity.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/_empty_stage.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_no_access.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_overview.html.haml15
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml99
-rw-r--r--app/views/projects/issues/_issues.html.haml3
-rw-r--r--app/views/projects/issues/index.html.haml24
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml4
-rw-r--r--app/views/projects/milestones/show.html.haml17
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/empty_states/_issues.html.haml22
-rw-r--r--app/views/shared/empty_states/icons/_issues.svg1
-rw-r--r--app/views/shared/icons/_delta.svg3
-rw-r--r--app/views/shared/icons/_icon_cycle_analytics_overview.svg81
-rw-r--r--app/views/shared/icons/_icon_lock.svg25
-rw-r--r--app/views/shared/icons/_icon_no_data.svg27
-rw-r--r--app/views/shared/milestones/_summary.html.haml60
-rw-r--r--app/workers/new_note_worker.rb4
-rw-r--r--app/workers/project_cache_worker.rb54
-rw-r--r--changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml4
-rw-r--r--changelogs/unreleased/23449-cycle-analytics-2-frontend.yml4
-rw-r--r--changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml4
-rw-r--r--changelogs/unreleased/chatops-deploy-command.yml4
-rw-r--r--changelogs/unreleased/dz-fix-500-group-git.yml4
-rw-r--r--changelogs/unreleased/feature-send-registry-address-with-build-payload.yml4
-rw-r--r--changelogs/unreleased/fix-ci-linter-undefined-error.yml4
-rw-r--r--changelogs/unreleased/fix-cycle-analytics-permissions.yml4
-rw-r--r--changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml4
-rw-r--r--changelogs/unreleased/hide-empty-merge-request-diffs.yml4
-rw-r--r--changelogs/unreleased/issue_24303.yml4
-rw-r--r--changelogs/unreleased/remove-require-from-services.yml4
-rw-r--r--changelogs/unreleased/smarter-cache-invalidation.yml4
-rw-r--r--doc/development/README.md1
-rw-r--r--doc/development/img/state-model-issue.pngbin0 -> 13256 bytes
-rw-r--r--doc/development/img/state-model-legend.pngbin0 -> 12412 bytes
-rw-r--r--doc/development/img/state-model-merge-request.pngbin0 -> 22484 bytes
-rw-r--r--doc/development/object_state_models.md25
-rw-r--r--doc/project_services/img/mattermost_add_slash_command.pngbin0 -> 23150 bytes
-rw-r--r--doc/project_services/img/mattermost_bot_auth.pngbin0 -> 21031 bytes
-rw-r--r--doc/project_services/img/mattermost_bot_available_commands.pngbin0 -> 12746 bytes
-rw-r--r--doc/project_services/img/mattermost_config_help.pngbin0 -> 176610 bytes
-rw-r--r--doc/project_services/img/mattermost_console_integrations.pngbin0 -> 114850 bytes
-rw-r--r--doc/project_services/img/mattermost_gitlab_token.pngbin0 -> 7879 bytes
-rw-r--r--doc/project_services/img/mattermost_goto_console.pngbin0 -> 22802 bytes
-rw-r--r--doc/project_services/img/mattermost_slash_command_configuration.pngbin0 -> 60927 bytes
-rw-r--r--doc/project_services/img/mattermost_slash_command_token.pngbin0 -> 20415 bytes
-rw-r--r--doc/project_services/img/mattermost_team_integrations.pngbin0 -> 12171 bytes
-rw-r--r--doc/project_services/mattermost_slash_commands.md157
-rw-r--r--doc/project_services/project_services.md1
-rw-r--r--features/steps/project/issues/issues.rb2
-rw-r--r--lib/api/project_snippets.rb156
-rw-r--r--lib/api/users.rb9
-rw-r--r--lib/ci/api/entities.rb6
-rw-r--r--lib/gitlab/chat_commands/command.rb1
-rw-r--r--lib/gitlab/chat_commands/deploy.rb57
-rw-r--r--lib/gitlab/chat_commands/result.rb5
-rw-r--r--lib/gitlab/ci/build/credentials/base.rb13
-rw-r--r--lib/gitlab/ci/build/credentials/factory.rb27
-rw-r--r--lib/gitlab/ci/build/credentials/registry.rb24
-rw-r--r--lib/gitlab/ci/config/entry/job.rb2
-rw-r--r--lib/gitlab/cycle_analytics/permissions.rb44
-rw-r--r--lib/gitlab/file_detector.rb63
-rw-r--r--lib/gitlab/regex.rb2
-rw-r--r--lib/mattermost/presenter.rb40
-rw-r--r--package.json3
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb43
-rw-r--r--spec/factories/ci/builds.rb6
-rw-r--r--spec/features/issues/filter_issues_spec.rb10
-rw-r--r--spec/features/issues_spec.rb5
-rw-r--r--spec/features/merge_requests/deleted_source_branch_spec.rb30
-rw-r--r--spec/features/merge_requests/merge_request_versions_spec.rb7
-rw-r--r--spec/javascripts/activities_spec.js.es62
-rw-r--r--spec/javascripts/environments/environment_item_spec.js.es62
-rw-r--r--spec/javascripts/merge_request_widget_spec.js54
-rw-r--r--spec/javascripts/pretty_time_spec.js.es6134
-rw-r--r--spec/javascripts/smart_interval_spec.js.es6159
-rw-r--r--spec/javascripts/subbable_resource_spec.js.es665
-rw-r--r--spec/lib/gitlab/chat_commands/command_spec.rb43
-rw-r--r--spec/lib/gitlab/chat_commands/deploy_spec.rb79
-rw-r--r--spec/lib/gitlab/ci/build/credentials/factory_spec.rb38
-rw-r--r--spec/lib/gitlab/ci/build/credentials/registry_spec.rb41
-rw-r--r--spec/lib/gitlab/ci/config/entry/global_spec.rb51
-rw-r--r--spec/lib/gitlab/cycle_analytics/permissions_spec.rb127
-rw-r--r--spec/lib/gitlab/file_detector_spec.rb59
-rw-r--r--spec/models/environment_spec.rb12
-rw-r--r--spec/models/project_spec.rb2
-rw-r--r--spec/models/repository_spec.rb477
-rw-r--r--spec/requests/api/branches_spec.rb2
-rw-r--r--spec/requests/api/project_snippets_spec.rb64
-rw-r--r--spec/requests/ci/api/builds_spec.rb39
-rw-r--r--spec/routing/routing_spec.rb2
-rw-r--r--spec/services/git_push_service_spec.rb104
-rw-r--r--spec/services/git_tag_push_service_spec.rb8
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb10
-rw-r--r--spec/services/system_note_service_spec.rb65
-rw-r--r--spec/workers/project_cache_worker_spec.rb86
167 files changed, 4241 insertions, 1152 deletions
diff --git a/.eslintrc b/.eslintrc
index fd26215b843..5850c107a02 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -23,7 +23,9 @@
"spyOn": false,
"spyOnEvent": false,
"Turbolinks": false,
- "window": false
+ "window": false,
+ "Vue": false,
+ "Flash": false,
+ "Cookies": false
}
}
-
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 436e9ec6c60..ab45ea57aed 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -271,12 +271,17 @@ rake db:seed_fu:
- log/development.log
teaspoon:
+ cache:
+ paths:
+ - vendor/ruby
+ - node_modules/
stage: test
<<: *use-db
script:
- curl --silent --location https://deb.nodesource.com/setup_6.x | bash -
- apt-get install --assume-yes nodejs
- - npm install --global istanbul
+ - npm install
+ - npm link istanbul
- rake teaspoon
artifacts:
name: coverage-javascript
@@ -319,12 +324,11 @@ migration paths:
- master@gitlab/gitlabhq
- master@gitlab/gitlab-ee
script:
- - git checkout HEAD .
- - git fetch --tags
- - git checkout v8.5.9
+ - git fetch origin v8.5.9
+ - git checkout -f FETCH_HEAD
- cp config/resque.yml.example config/resque.yml
- sed -i 's/localhost/redis/g' config/resque.yml
- - bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}" --retry=3
+ - bundle install --without postgres production --jobs $(nproc) ${FLAGS[@]} --retry=3
- rake db:drop db:create db:schema:load db:seed_fu
- git checkout $CI_BUILD_REF
- source scripts/prepare_build.sh
@@ -346,8 +350,11 @@ coverage:
- coverage/assets/
lint-javascript:
+ cache:
+ paths:
+ - node_modules/
stage: test
- image: "node:latest"
+ image: "node:7.1"
before_script:
- npm install
script:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f41cbc9228..5b139ac8314 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ entry.
- Fix sidekiq stats in admin area (blackst0ne)
- Added label description as tooltip to issue board list title
- Created cycle analytics bundle JavaScript file
+- Make the milestone page more responsive (yury-n)
- Hides container registry when repository is disabled
- API: Fix booleans not recognized as such when using the `to_boolean` helper
- Removed delete branch tooltip !6954
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
deleted file mode 100644
index 906a1a69d93..00000000000
--- a/app/assets/javascripts/activities.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-undef, quotes, no-var, padded-blocks, max-len */
-(function() {
- this.Activities = (function() {
- function Activities() {
- Pager.init(20, true, false, this.updateTooltips);
- $(".event-filter-link").on("click", (function(_this) {
- return function(event) {
- event.preventDefault();
- _this.toggleFilter($(event.currentTarget));
- return _this.reloadActivities();
- };
- })(this));
- }
-
- Activities.prototype.updateTooltips = function() {
- gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
- };
-
- Activities.prototype.reloadActivities = function() {
- $(".content_list").html('');
- Pager.init(20, true, false, this.updateTooltips);
- };
-
- Activities.prototype.toggleFilter = function(sender) {
- var filter = sender.attr("id").split("_")[0];
-
- $('.event-filter .active').removeClass("active");
- Cookies.set("event_filter", filter);
-
- sender.closest('li').toggleClass("active");
- };
-
- return Activities;
-
- })();
-
-}).call(this);
diff --git a/app/assets/javascripts/activities.js.es6 b/app/assets/javascripts/activities.js.es6
new file mode 100644
index 00000000000..19bcfef89fb
--- /dev/null
+++ b/app/assets/javascripts/activities.js.es6
@@ -0,0 +1,36 @@
+/* eslint-disable no-param-reassign, class-methods-use-this */
+/* global Pager, Cookies */
+
+((global) => {
+ class Activities {
+ constructor() {
+ Pager.init(20, true, false, this.updateTooltips);
+ $('.event-filter-link').on('click', (e) => {
+ e.preventDefault();
+ this.toggleFilter(e.currentTarget);
+ this.reloadActivities();
+ });
+ }
+
+ updateTooltips() {
+ gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
+ }
+
+ reloadActivities() {
+ $('.content_list').html('');
+ Pager.init(20, true, false, this.updateTooltips);
+ }
+
+ toggleFilter(sender) {
+ const $sender = $(sender);
+ const filter = $sender.attr('id').split('_')[0];
+
+ $('.event-filter .active').removeClass('active');
+ Cookies.set('event_filter', filter);
+
+ $sender.closest('li').toggleClass('active');
+ }
+ }
+
+ global.Activities = Activities;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6
new file mode 100644
index 00000000000..520cee7738b
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6
@@ -0,0 +1,43 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageCodeComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="mergeRequest in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <h5 class="item-title merge-merquest-title">
+ <a :href="mergeRequest.url">
+ {{ mergeRequest.title }}
+ </a>
+ </h5>
+ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6
new file mode 100644
index 00000000000..3bb01c67206
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6
@@ -0,0 +1,45 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageIssueComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="issue in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="issue.author.avatarUrl">
+ <h5 class="item-title issue-title">
+ <a class="issue-title" :href="issue.url">
+ {{ issue.title }}
+ </a>
+ </h5>
+ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="issue.author.webUrl" class="issue-author-link">
+ {{ issue.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="issue.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6
new file mode 100644
index 00000000000..b568ab62a69
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StagePlanComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="commit in items" class="stage-event-item">
+ <div class="item-details item-conmmit-component">
+ <img class="avatar" :src="commit.author.avatarUrl">
+ <h5 class="item-title commit-title">
+ <a :href="commit.commitUrl">
+ {{ commit.title }}
+ </a>
+ </h5>
+ <span>
+ First
+ <span class="commit-icon">${global.cycleAnalytics.svgs.iconCommit}</span>
+ <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
+ pushed by
+ <a :href="commit.author.webUrl" class="commit-author-link">
+ {{ commit.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="commit.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6
new file mode 100644
index 00000000000..a6b6d817a82
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6
@@ -0,0 +1,45 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageProductionComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="issue in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="issue.author.avatarUrl">
+ <h5 class="item-title issue-title">
+ <a class="issue-title" :href="issue.url">
+ {{ issue.title }}
+ </a>
+ </h5>
+ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="issue.author.webUrl" class="issue-author-link">
+ {{ issue.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="issue.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6
new file mode 100644
index 00000000000..9e819c1d420
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6
@@ -0,0 +1,55 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageReviewComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="mergeRequest in items" class="stage-event-item">
+ <div class="item-details">
+ <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <h5 class="item-title merge-merquest-title">
+ <a :href="mergeRequest.url">
+ {{ mergeRequest.title }}
+ </a>
+ </h5>
+ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ &middot;
+ <span>
+ Opened
+ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ </span>
+ <span>
+ by
+ <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ </span>
+ <template v-if="mergeRequest.state === 'closed'">
+ <span class="merge-request-state">
+ <i class="fa fa-ban"></i>
+ {{ mergeRequest.state.toUpperCase() }}
+ </span>
+ </template>
+ <template v-else>
+ <span class="merge-request-branch" v-if="mergeRequest.branch">
+ <i class= "fa fa-code-fork"></i>
+ <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a>
+ </span>
+ </template>
+ </div>
+ <div class="item-time">
+ <total-time :time="mergeRequest.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6
new file mode 100644
index 00000000000..b30c3a31010
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageStagingComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="build in items" class="stage-event-item item-build-component">
+ <div class="item-details">
+ <img class="avatar" :src="build.author.avatarUrl">
+ <h5 class="item-title">
+ <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
+ <i class="fa fa-code-fork"></i>
+ <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <span class="icon-branch">${global.cycleAnalytics.svgs.iconBranch}</span>
+ <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ </h5>
+ <span>
+ <a :href="build.url" class="build-date">{{ build.date }}</a>
+ by
+ <a :href="build.author.webUrl" class="issue-author-link">
+ {{ build.author.name }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6
new file mode 100644
index 00000000000..c54d6b6ee37
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6
@@ -0,0 +1,42 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.StageTestComponent = Vue.extend({
+ props: {
+ items: Array,
+ stage: Object,
+ },
+ template: `
+ <div>
+ <div class="events-description">
+ {{ stage.description }}
+ </div>
+ <ul class="stage-event-list">
+ <li v-for="build in items" class="stage-event-item item-build-component">
+ <div class="item-details">
+ <h5 class="item-title">
+ <span class="icon-build-status">${global.cycleAnalytics.svgs.iconBuildStatus}</span>
+ <a :href="build.url" class="item-build-name">{{ build.name }}</a>
+ &middot;
+ <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
+ <i class="fa fa-code-fork"></i>
+ <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <span class="icon-branch">${global.cycleAnalytics.svgs.iconBranch}</span>
+ <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ </h5>
+ <span>
+ <a :href="build.url" class="issue-date">
+ {{ build.date }}
+ </a>
+ </span>
+ </div>
+ <div class="item-time">
+ <total-time :time="build.totalTime"></total-time>
+ </div>
+ </li>
+ </ul>
+ </div>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6
new file mode 100644
index 00000000000..8403fbeaab5
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6
@@ -0,0 +1,18 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
+ props: {
+ time: Object,
+ },
+ template: `
+ <span class="total-time">
+ <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
+ <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
+ <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
+ <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
+ </span>
+ `,
+ });
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
index 331f0209888..f1ddd139c48 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6
@@ -1,98 +1,121 @@
-/* eslint-disable */
//= require vue
-
-((global) => {
-
- const COOKIE_NAME = 'cycle_analytics_help_dismissed';
- const store = gl.cycleAnalyticsStore = {
- isLoading: true,
- hasError: false,
- isHelpDismissed: Cookies.get(COOKIE_NAME),
- analytics: {}
- };
-
- gl.CycleAnalytics = class CycleAnalytics {
- constructor() {
- const that = this;
-
- this.vue = new Vue({
- el: '#cycle-analytics',
- name: 'CycleAnalytics',
- created: this.fetchData(),
- data: store,
- methods: {
- dismissLanding() {
- that.dismissLanding();
- }
+//= require_tree ./svg
+//= require_tree .
+
+$(() => {
+ const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
+ const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
+ const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore;
+ const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({
+ requestPath: cycleAnalyticsEl.dataset.requestPath,
+ });
+
+ gl.cycleAnalyticsApp = new Vue({
+ el: '#cycle-analytics',
+ name: 'CycleAnalytics',
+ data: {
+ state: cycleAnalyticsStore.state,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ hasError: false,
+ startDate: 30,
+ isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ },
+ computed: {
+ currentStage() {
+ return cycleAnalyticsStore.currentActiveStage();
+ },
+ },
+ components: {
+ 'stage-issue-component': gl.cycleAnalytics.StageIssueComponent,
+ 'stage-plan-component': gl.cycleAnalytics.StagePlanComponent,
+ 'stage-code-component': gl.cycleAnalytics.StageCodeComponent,
+ 'stage-test-component': gl.cycleAnalytics.StageTestComponent,
+ 'stage-review-component': gl.cycleAnalytics.StageReviewComponent,
+ 'stage-staging-component': gl.cycleAnalytics.StageStagingComponent,
+ 'stage-production-component': gl.cycleAnalytics.StageProductionComponent,
+ },
+ created() {
+ this.fetchCycleAnalyticsData();
+ },
+ methods: {
+ handleError() {
+ cycleAnalyticsStore.setErrorState(true);
+ return new Flash('There was an error while fetching cycle analytics data.');
+ },
+ initDropdown() {
+ const $dropdown = $('.js-ca-dropdown');
+ const $label = $dropdown.find('.dropdown-label');
+
+ $dropdown.find('li a').off('click').on('click', (e) => {
+ e.preventDefault();
+ const $target = $(e.currentTarget);
+ this.startDate = $target.data('value');
+
+ $label.text($target.text().trim());
+ this.fetchCycleAnalyticsData({ startDate: this.startDate });
+ });
+ },
+ fetchCycleAnalyticsData(options) {
+ const fetchOptions = options || { startDate: this.startDate };
+
+ this.isLoading = true;
+
+ cycleAnalyticsService
+ .fetchCycleAnalyticsData(fetchOptions)
+ .done((response) => {
+ cycleAnalyticsStore.setCycleAnalyticsData(response);
+ this.selectDefaultStage();
+ this.initDropdown();
+ })
+ .error(() => {
+ this.handleError();
+ })
+ .always(() => {
+ this.isLoading = false;
+ });
+ },
+ selectDefaultStage() {
+ const stage = this.state.stages.first();
+ this.selectStage(stage);
+ },
+ selectStage(stage) {
+ if (this.isLoadingStage) return;
+ if (this.currentStage === stage) return;
+
+ if (!stage.isUserAllowed) {
+ cycleAnalyticsStore.setActiveStage(stage);
+ return;
}
- });
- }
-
- fetchData(options) {
- store.isLoading = true;
- options = options || { startDate: 30 };
-
- $.ajax({
- url: $('#cycle-analytics').data('request-path'),
- method: 'GET',
- dataType: 'json',
- contentType: 'application/json',
- data: {
- cycle_analytics: {
- start_date: options.startDate
- }
- }
- }).done((data) => {
- this.decorateData(data);
- this.initDropdown();
- })
- .error((data) => {
- this.handleError(data);
- })
- .always(() => {
- store.isLoading = false;
- })
- }
-
- decorateData(data) {
- data.summary = data.summary || [];
- data.stats = data.stats || [];
-
- data.summary.forEach((item) => {
- item.value = item.value || '-';
- });
-
- data.stats.forEach((item) => {
- item.value = item.value || '- - -';
- });
-
- store.analytics = data;
- }
-
- handleError(data) {
- store.hasError = true;
- new Flash('There was an error while fetching cycle analytics data.', 'alert');
- }
-
- dismissLanding() {
- store.isHelpDismissed = true;
- Cookies.set(COOKIE_NAME, true);
- }
-
- initDropdown() {
- const $dropdown = $('.js-ca-dropdown');
- const $label = $dropdown.find('.dropdown-label');
-
- $dropdown.find('li a').off('click').on('click', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- const value = $target.data('value');
-
- $label.text($target.text().trim());
- this.fetchData({ startDate: value });
- })
- }
-
- }
-})(window.gl || (window.gl = {}));
+ this.isLoadingStage = true;
+ cycleAnalyticsStore.setStageEvents([]);
+ cycleAnalyticsStore.setActiveStage(stage);
+
+ cycleAnalyticsService
+ .fetchStageData({
+ stage,
+ startDate: this.startDate,
+ })
+ .done((response) => {
+ this.isEmptyStage = !response.events.length;
+ cycleAnalyticsStore.setStageEvents(response.events);
+ })
+ .error(() => {
+ this.isEmptyStage = true;
+ })
+ .always(() => {
+ this.isLoadingStage = false;
+ });
+ },
+ dismissOverviewDialog() {
+ this.isOverviewDialogDismissed = true;
+ Cookies.set(OVERVIEW_DIALOG_COOKIE, '1');
+ },
+ },
+ });
+
+ // Register global components
+ Vue.component('total-time', gl.cycleAnalytics.TotalTimeComponent);
+});
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6
new file mode 100644
index 00000000000..9f74b14c4b9
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6
@@ -0,0 +1,41 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ class CycleAnalyticsService {
+ constructor(options) {
+ this.requestPath = options.requestPath;
+ }
+
+ fetchCycleAnalyticsData(options) {
+ options = options || { startDate: 30 };
+
+ return $.ajax({
+ url: this.requestPath,
+ method: 'GET',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: {
+ cycle_analytics: {
+ start_date: options.startDate,
+ },
+ },
+ });
+ }
+
+ fetchStageData(options) {
+ const {
+ stage,
+ startDate,
+ } = options;
+
+ return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
+ cycle_analytics: {
+ start_date: startDate,
+ },
+ });
+ }
+ }
+
+ global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6
new file mode 100644
index 00000000000..9b905874167
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6
@@ -0,0 +1,90 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+
+ const EMPTY_STAGE_TEXTS = {
+ issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
+ plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
+ code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
+ test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
+ review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
+ staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
+ production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
+ };
+
+ global.cycleAnalytics.CycleAnalyticsStore = {
+ state: {
+ summary: '',
+ stats: '',
+ analytics: '',
+ events: [],
+ stages: [],
+ },
+ setCycleAnalyticsData(data) {
+ this.state = Object.assign(this.state, this.decorateData(data));
+ },
+ decorateData(data) {
+ const newData = {};
+
+ newData.stages = data.stats || [];
+ newData.summary = data.summary || [];
+
+ newData.summary.forEach((item) => {
+ item.value = item.value || '-';
+ });
+
+ newData.stages.forEach((item) => {
+ const stageName = item.title.toLowerCase();
+ item.active = false;
+ item.isUserAllowed = data.permissions[stageName];
+ item.emptyStageText = EMPTY_STAGE_TEXTS[stageName];
+ item.component = `stage-${stageName}-component`;
+ });
+ newData.analytics = data;
+ return newData;
+ },
+ setLoadingState(state) {
+ this.state.isLoading = state;
+ },
+ setErrorState(state) {
+ this.state.hasError = state;
+ },
+ deactivateAllStages() {
+ this.state.stages.forEach((stage) => {
+ stage.active = false;
+ });
+ },
+ setActiveStage(stage) {
+ this.deactivateAllStages();
+ stage.active = true;
+ },
+ setStageEvents(events) {
+ this.state.events = this.decorateEvents(events);
+ },
+ decorateEvents(events) {
+ const newEvents = events;
+
+ newEvents.forEach((item) => {
+ item.totalTime = item.total_time;
+ item.author.webUrl = item.author.web_url;
+ item.author.avatarUrl = item.author.avatar_url;
+
+ if (item.created_at) item.createdAt = item.created_at;
+ if (item.short_sha) item.shortSha = item.short_sha;
+ if (item.commit_url) item.commitUrl = item.commit_url;
+
+ delete item.author.web_url;
+ delete item.author.avatar_url;
+ delete item.total_time;
+ delete item.created_at;
+ delete item.short_sha;
+ delete item.commit_url;
+ });
+
+ return newEvents;
+ },
+ currentActiveStage() {
+ return this.state.stages.find(stage => stage.active);
+ },
+ };
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6
new file mode 100644
index 00000000000..5d486bcaf66
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_branch.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconBranch = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path fill="#8C8C8C" fill-rule="evenodd" d="M9.678 6.722C9.353 5.167 8.053 4 6.5 4S3.647 5.167 3.322 6.722h-2.6c-.397 0-.722.35-.722.778 0 .428.325.778.722.778h2.6C3.647 9.833 4.947 11 6.5 11s2.853-1.167 3.178-2.722h2.6c.397 0 .722-.35.722-.778 0-.428-.325-.778-.722-.778h-2.6zM4.694 7.5c0-1.09.795-1.944 1.806-1.944 1.01 0 1.806.855 1.806 1.944 0 1.09-.795 1.944-1.806 1.944-1.01 0-1.806-.855-1.806-1.944z"/></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6
new file mode 100644
index 00000000000..661bf9e9f1c
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconBuildStatus = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><g fill="#31AF64" fill-rule="evenodd"><path d="M12.5 7c0-3.038-2.462-5.5-5.5-5.5S1.5 3.962 1.5 7s2.462 5.5 5.5 5.5 5.5-2.462 5.5-5.5zM0 7c0-3.866 3.134-7 7-7s7 3.134 7 7-3.134 7-7 7-7-3.134-7-7z"/><path d="M6.28 7.697L5.045 6.464c-.117-.117-.305-.117-.42-.002l-.614.614c-.11.113-.11.303.007.42l1.91 1.91c.19.19.51.197.703.004l.264-.265L9.997 6.04c.108-.107.107-.293-.01-.408l-.612-.614c-.114-.113-.298-.12-.41-.01L6.28 7.7z"/></g></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6 b/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6
new file mode 100644
index 00000000000..2208c27a619
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/svg/icon_commit.js.es6
@@ -0,0 +1,7 @@
+/* eslint-disable no-param-reassign */
+((global) => {
+ global.cycleAnalytics = global.cycleAnalytics || {};
+ global.cycleAnalytics.svgs = global.cycleAnalytics.svgs || {};
+
+ global.cycleAnalytics.svgs.iconCommit = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#8F8F8F" fill-rule="evenodd" d="M28.777 18c-.91-4.008-4.494-7-8.777-7-4.283 0-7.868 2.992-8.777 7H4.01C2.9 18 2 18.895 2 20c0 1.112.9 2 2.01 2h7.213c.91 4.008 4.494 7 8.777 7 4.283 0 7.868-2.992 8.777-7h7.214C37.1 22 38 21.105 38 20c0-1.112-.9-2-2.01-2h-7.213zM20 25c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z"/></svg>';
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6
index 756a24cc0fc..c2d4670b7e9 100644
--- a/app/assets/javascripts/dispatcher.js.es6
+++ b/app/assets/javascripts/dispatcher.js.es6
@@ -110,10 +110,10 @@
Issuable.init();
break;
case 'dashboard:activity':
- new Activities();
+ new gl.Activities();
break;
case 'dashboard:projects:starred':
- new Activities();
+ new gl.Activities();
break;
case 'projects:commit:show':
new Commit();
@@ -139,7 +139,7 @@
new gl.Pipelines();
break;
case 'groups:activity':
- new Activities();
+ new gl.Activities();
break;
case 'groups:show':
shortcut_handler = new ShortcutsNavigation();
@@ -208,9 +208,6 @@
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
break;
- case 'projects:cycle_analytics:show':
- new gl.CycleAnalytics();
- break;
}
switch (path.first()) {
case 'admin':
diff --git a/app/assets/javascripts/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6
index b769161e058..1043e516483 100644
--- a/app/assets/javascripts/environments/components/environment.js.es6
+++ b/app/assets/javascripts/environments/components/environment.js.es6
@@ -157,17 +157,17 @@
<li v-bind:class="{ 'active': scope === undefined }">
<a :href="projectEnvironmentsPath">
Available
- <span
- class="badge js-available-environments-count"
- v-html="state.availableCounter"></span>
+ <span class="badge js-available-environments-count">
+ {{state.availableCounter}}
+ </span>
</a>
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="projectStoppedEnvironmentsPath">
Stopped
- <span
- class="badge js-stopped-environments-count"
- v-html="state.stoppedCounter"></span>
+ <span class="badge js-stopped-environments-count">
+ {{state.stoppedCounter}}
+ </span>
</a>
</li>
</ul>
@@ -183,8 +183,7 @@
<i class="fa fa-spinner spin"></i>
</div>
- <div
- class="blank-state blank-state-no-icon"
+ <div class="blank-state blank-state-no-icon"
v-if="!isLoading && state.environments.length === 0">
<h2 class="blank-state-title">
You don't have any environments right now.
@@ -205,8 +204,7 @@
</a>
</div>
- <div
- class="table-holder"
+ <div class="table-holder"
v-if="!isLoading && state.environments.length > 0">
<table class="table ci-table environments">
<thead>
@@ -234,7 +232,9 @@
is="environment-item"
v-for="children in model.children"
:model="children"
- :toggleRow="toggleRow.bind(children)">
+ :toggleRow="toggleRow.bind(children)"
+ :can-create-deployment="canCreateDeploymentParsed"
+ :can-read-environment="canReadEnvironmentParsed">
</tr>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_actions.js.es6 b/app/assets/javascripts/environments/components/environment_actions.js.es6
index edd39c02a46..d149a446e0b 100644
--- a/app/assets/javascripts/environments/components/environment_actions.js.es6
+++ b/app/assets/javascripts/environments/components/environment_actions.js.es6
@@ -43,8 +43,7 @@
<div class="inline">
<div class="dropdown">
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
- <span class="dropdown-play-icon-container">
- </span>
+ <span class="dropdown-play-icon-container"></span>
<i class="fa fa-caret-down"></i>
</a>
@@ -54,9 +53,10 @@
data-method="post"
rel="nofollow"
class="js-manual-action-link">
- <span class="action-play-icon-container">
+ <span class="action-play-icon-container"></span>
+ <span>
+ {{action.name}}
</span>
- <span v-html="action.name"></span>
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6
index 2f7d1d2a177..da7db5c05bd 100644
--- a/app/assets/javascripts/environments/components/environment_item.js.es6
+++ b/app/assets/javascripts/environments/components/environment_item.js.es6
@@ -389,11 +389,10 @@
template: `
<tr>
<td v-bind:class="{ 'children-row': isChildren}">
- <a
- v-if="!isFolder"
+ <a v-if="!isFolder"
class="environment-name"
- :href="model.environment_path"
- v-html="model.name">
+ :href="model.environment_path">
+ {{model.name}}
</a>
<span v-else v-on:click="toggleRow(model)" class="folder-name">
<span class="folder-icon">
@@ -401,16 +400,19 @@
<i v-show="!model.isOpen" class="fa fa-caret-right"></i>
</span>
- <span v-html="model.name"></span>
+ <span>
+ {{model.name}}
+ </span>
- <span class="badge" v-html="childrenCounter"></span>
+ <span class="badge">
+ {{childrenCounter}}
+ </span>
</span>
</td>
<td class="deployment-column">
- <span
- v-if="shouldRenderDeploymentID"
- v-html="deploymentInternalId">
+ <span v-if="shouldRenderDeploymentID">
+ {{deploymentInternalId}}
</span>
<span v-if="!isFolder && deploymentHasUser">
@@ -427,8 +429,8 @@
<td>
<a v-if="shouldRenderBuildName"
class="build-link"
- :href="model.last_deployment.deployable.build_path"
- v-html="buildName">
+ :href="model.last_deployment.deployable.build_path">
+ {{buildName}}
</a>
</td>
@@ -451,8 +453,8 @@
<td>
<span
v-if="!isFolder && model.last_deployment"
- class="environment-created-date-timeago"
- v-html="createdDate">
+ class="environment-created-date-timeago">
+ {{createdDate}}
</span>
</td>
diff --git a/app/assets/javascripts/environments/components/environment_stop.js.es6 b/app/assets/javascripts/environments/components/environment_stop.js.es6
index 2c732e50180..e6d66a0148c 100644
--- a/app/assets/javascripts/environments/components/environment_stop.js.es6
+++ b/app/assets/javascripts/environments/components/environment_stop.js.es6
@@ -14,8 +14,7 @@
},
template: `
- <a
- class="btn stop-env-link"
+ <a class="btn stop-env-link"
:href="stop_url"
data-confirm="Are you sure you want to stop this environment?"
data-method="post"
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js.es6 b/app/assets/javascripts/lib/utils/pretty_time.js.es6
new file mode 100644
index 00000000000..ccaf447eb0b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/pretty_time.js.es6
@@ -0,0 +1,67 @@
+(() => {
+ /*
+ * TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
+ * stringifyTime condensed or non-condensed, abbreviateTimelengths)
+ * */
+
+ class PrettyTime {
+
+ /*
+ * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
+ * Seconds can be negative or positive, zero or non-zero.
+ */
+ static parseSeconds(seconds) {
+ const DAYS_PER_WEEK = 5;
+ const HOURS_PER_DAY = 8;
+ const MINUTES_PER_HOUR = 60;
+ const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
+
+ const timePeriodConstraints = {
+ weeks: MINUTES_PER_WEEK,
+ days: MINUTES_PER_DAY,
+ hours: MINUTES_PER_HOUR,
+ minutes: 1,
+ };
+
+ let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
+
+ return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
+ const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
+
+ unorderedMinutes -= (periodCount * minutesPerPeriod);
+
+ return periodCount;
+ });
+ }
+
+ /*
+ * Accepts a timeObject and returns a condensed string representation of it
+ * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
+ */
+
+ static stringifyTime(timeObject) {
+ const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
+ const isNonZero = !!unitValue;
+ return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
+ }, '').trim();
+ return reducedTime.length ? reducedTime : '0m';
+ }
+
+ /*
+ * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
+ * the first non-zero unit/value pair.
+ */
+
+ static abbreviateTime(timeStr) {
+ return timeStr.split(' ')
+ .filter(unitStr => unitStr.charAt(0) !== '0')[0];
+ }
+
+ static secondsToMinutes(seconds) {
+ return Math.abs(seconds / 60);
+ }
+ }
+
+ gl.PrettyTime = PrettyTime;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_request_widget.js.es6 b/app/assets/javascripts/merge_request_widget.js.es6
index 56c87af3226..54929cd8f24 100644
--- a/app/assets/javascripts/merge_request_widget.js.es6
+++ b/app/assets/javascripts/merge_request_widget.js.es6
@@ -218,7 +218,7 @@
}
if (environment.deployed_at && environment.deployed_at_formatted) {
- environment.deployed_at = gl.utils.getTimeago(environment.deployed_at) + '.';
+ environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
} else {
$('.js-environment-timeago', $template).remove();
environment.name += '.';
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
deleted file mode 100644
index d22d2d9dbae..00000000000
--- a/app/assets/javascripts/pager.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, object-shorthand, quotes, no-undef, prefer-template, wrap-iife, comma-dangle, no-return-assign, no-else-return, consistent-return, no-unused-vars, padded-blocks, max-len */
-(function() {
- this.Pager = {
- init: function(limit, preload, disable, callback) {
- this.limit = limit != null ? limit : 0;
- this.disable = disable != null ? disable : false;
- this.callback = callback != null ? callback : $.noop;
- this.loading = $('.loading').first();
- if (preload) {
- this.offset = 0;
- this.getOld();
- } else {
- this.offset = this.limit;
- }
- return this.initLoadMore();
- },
- getOld: function() {
- this.loading.show();
- return $.ajax({
- type: "GET",
- url: $(".content_list").data('href') || location.href,
- data: "limit=" + this.limit + "&offset=" + this.offset,
- complete: (function(_this) {
- return function() {
- return _this.loading.hide();
- };
- })(this),
- success: function(data) {
- Pager.append(data.count, data.html);
- return Pager.callback();
- },
- dataType: "json"
- });
- },
- append: function(count, html) {
- $(".content_list").append(html);
- if (count > 0) {
- return this.offset += count;
- } else {
- return this.disable = true;
- }
- },
- initLoadMore: function() {
- $(document).unbind('scroll');
- return $(document).endlessScroll({
- bottomPixels: 400,
- fireDelay: 1000,
- fireOnce: true,
- ceaseFire: function() {
- return Pager.disable;
- },
- callback: (function(_this) {
- return function(i) {
- if (!_this.loading.is(':visible')) {
- _this.loading.show();
- return Pager.getOld();
- }
- };
- })(this)
- });
- }
- };
-
-}).call(this);
diff --git a/app/assets/javascripts/pager.js.es6 b/app/assets/javascripts/pager.js.es6
new file mode 100644
index 00000000000..e35cf6d295e
--- /dev/null
+++ b/app/assets/javascripts/pager.js.es6
@@ -0,0 +1,73 @@
+(() => {
+ const ENDLESS_SCROLL_BOTTOM_PX = 400;
+ const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
+
+ const Pager = {
+ init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ this.limit = limit;
+ this.offset = this.limit;
+ this.disable = disable;
+ this.callback = callback;
+ this.loading = $('.loading').first();
+ if (preload) {
+ this.offset = 0;
+ this.getOld();
+ }
+ this.initLoadMore();
+ },
+
+ getOld() {
+ this.loading.show();
+ $.ajax({
+ type: 'GET',
+ url: $('.content_list').data('href') || window.location.href,
+ data: `limit=${this.limit}&offset=${this.offset}`,
+ dataType: 'json',
+ error: () => this.loading.hide(),
+ success: (data) => {
+ this.append(data.count, data.html);
+ this.callback();
+
+ // keep loading until we've filled the viewport height
+ if (!this.disable && !this.isScrollable()) {
+ this.getOld();
+ } else {
+ this.loading.hide();
+ }
+ },
+ });
+ },
+
+ append(count, html) {
+ $('.content_list').append(html);
+ if (count > 0) {
+ this.offset += count;
+ } else {
+ this.disable = true;
+ }
+ },
+
+ isScrollable() {
+ const $w = $(window);
+ return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX;
+ },
+
+ initLoadMore() {
+ $(document).unbind('scroll');
+ $(document).endlessScroll({
+ bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
+ fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
+ fireOnce: true,
+ ceaseFire: () => this.disable === true,
+ callback: () => {
+ if (!this.loading.is(':visible')) {
+ this.loading.show();
+ this.getOld();
+ }
+ },
+ });
+ },
+ };
+
+ window.Pager = Pager;
+})();
diff --git a/app/assets/javascripts/smart_interval.js.es6 b/app/assets/javascripts/smart_interval.js.es6
new file mode 100644
index 00000000000..5eb15dba79b
--- /dev/null
+++ b/app/assets/javascripts/smart_interval.js.es6
@@ -0,0 +1,130 @@
+/*
+* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
+* and controllable by a public API.
+*
+* */
+
+(() => {
+ class SmartInterval {
+ /**
+ * @param { function } callback Function to be called on each iteration (required)
+ * @param { milliseconds } startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } maxInterval `currentInterval` will be incremented to this
+ * @param { integer } incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } lazyStart Configure if timer is initialized on instantiation or lazily
+ */
+ constructor({ callback, startingInterval, maxInterval, incrementByFactorOf, lazyStart }) {
+ this.cfg = {
+ callback,
+ startingInterval,
+ maxInterval,
+ incrementByFactorOf,
+ lazyStart,
+ };
+
+ this.state = {
+ intervalId: null,
+ currentInterval: startingInterval,
+ pageVisibility: 'visible',
+ };
+
+ this.initInterval();
+ }
+ /* public */
+
+ start() {
+ const cfg = this.cfg;
+ const state = this.state;
+
+ state.intervalId = window.setInterval(() => {
+ cfg.callback();
+
+ if (this.getCurrentInterval() === cfg.maxInterval) {
+ return;
+ }
+
+ this.incrementInterval();
+ this.resume();
+ }, this.getCurrentInterval());
+ }
+
+ // cancel the existing timer, setting the currentInterval back to startingInterval
+ cancel() {
+ this.setCurrentInterval(this.cfg.startingInterval);
+ this.stopTimer();
+ }
+
+ // start a timer, using the existing interval
+ resume() {
+ this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
+ this.start();
+ }
+
+ destroy() {
+ this.cancel();
+ $(document).off('visibilitychange').off('page:before-unload');
+ }
+
+ /* private */
+
+ initInterval() {
+ const cfg = this.cfg;
+
+ if (!cfg.lazyStart) {
+ this.start();
+ }
+
+ this.initVisibilityChangeHandling();
+ this.initPageUnloadHandling();
+ }
+
+ initVisibilityChangeHandling() {
+ // cancel interval when tab no longer shown (prevents cached pages from polling)
+ $(document)
+ .off('visibilitychange').on('visibilitychange', (e) => {
+ this.state.pageVisibility = e.target.visibilityState;
+ this.handleVisibilityChange();
+ });
+ }
+
+ initPageUnloadHandling() {
+ // prevent interval continuing after page change, when kept in cache by Turbolinks
+ $(document).on('page:before-unload', () => this.cancel());
+ }
+
+ handleVisibilityChange() {
+ const state = this.state;
+
+ const intervalAction = state.pageVisibility === 'hidden' ? this.cancel : this.resume;
+
+ intervalAction.apply(this);
+ }
+
+ getCurrentInterval() {
+ return this.state.currentInterval;
+ }
+
+ setCurrentInterval(newInterval) {
+ this.state.currentInterval = newInterval;
+ }
+
+ incrementInterval() {
+ const cfg = this.cfg;
+ const currentInterval = this.getCurrentInterval();
+ let nextInterval = currentInterval * cfg.incrementByFactorOf;
+
+ if (nextInterval > cfg.maxInterval) {
+ nextInterval = cfg.maxInterval;
+ }
+
+ this.setCurrentInterval(nextInterval);
+ }
+
+ stopTimer() {
+ const state = this.state;
+
+ state.intervalId = window.clearInterval(state.intervalId);
+ }
+ }
+ gl.SmartInterval = SmartInterval;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subbable_resource.js.es6 b/app/assets/javascripts/subbable_resource.js.es6
new file mode 100644
index 00000000000..932120157a3
--- /dev/null
+++ b/app/assets/javascripts/subbable_resource.js.es6
@@ -0,0 +1,54 @@
+//= require vue
+//= require vue-resource
+
+(() => {
+/*
+* SubbableResource can be extended to provide a pubsub-style service for one-off REST
+* calls. Subscribe by passing a callback or render method you will use to handle responses.
+ *
+* */
+
+ class SubbableResource {
+ constructor(resourcePath) {
+ this.endpoint = resourcePath;
+
+ // TODO: Switch to axios.create
+ this.resource = $.ajax;
+ this.subscribers = [];
+ }
+
+ subscribe(callback) {
+ this.subscribers.push(callback);
+ }
+
+ publish(newResponse) {
+ const responseCopy = _.extend({}, newResponse);
+ this.subscribers.forEach((fn) => {
+ fn(responseCopy);
+ });
+ return newResponse;
+ }
+
+ get(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ post(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ put(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+
+ delete(payload) {
+ return this.resource(payload)
+ .then(data => this.publish(data));
+ }
+ }
+
+ gl.SubbableResource = SubbableResource;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/user_tabs.js.es6 b/app/assets/javascripts/user_tabs.js.es6
index 2b310da319c..5a625611987 100644
--- a/app/assets/javascripts/user_tabs.js.es6
+++ b/app/assets/javascripts/user_tabs.js.es6
@@ -134,7 +134,7 @@ content on the Users#show page.
}
const $calendarWrap = this.$parentEl.find('.user-calendar');
$calendarWrap.load($calendarWrap.data('href'));
- new Activities();
+ new gl.Activities();
return this.loaded['activity'] = true;
}
diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_common_component/commit.js.es6
index fd628fad4d7..1bc68c1ba2f 100644
--- a/app/assets/javascripts/vue_common_component/commit.js.es6
+++ b/app/assets/javascripts/vue_common_component/commit.js.es6
@@ -138,16 +138,15 @@
<a v-if="hasRef"
class="monospace branch-name"
- :href="ref.ref_url"
- v-html="ref.name">
+ :href="ref.ref_url">
+ {{ref.name}}
</a>
- <div class="icon-container commit-icon commit-icon-container">
- </div>
+ <div class="icon-container commit-icon commit-icon-container"></div>
<a class="commit-id monospace"
- :href="commit_url"
- v-html="short_sha">
+ :href="commit_url">
+ {{short_sha}}
</a>
<p class="commit-title">
@@ -156,14 +155,15 @@
class="avatar-image-container"
:href="author.web_url">
<img
- class="avatar has-tooltip s20"
+ class="avatar has-tooltip s20"
:src="author.avatar_url"
:alt="userImageAltDescription"
:title="author.username" />
</a>
<a class="commit-row-message"
- :href="commit_url" v-html="title">
+ :href="commit_url">
+ {{title}}
</a>
</span>
<span v-else>
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 7e168092522..77ae9e9a6e7 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -254,3 +254,32 @@
.content-block-small {
padding: 10px 0;
}
+
+.empty-state {
+ margin: 100px 0 0;
+
+ .text-content {
+ max-width: 460px;
+ margin: 0 auto;
+ padding: $gl-padding;
+ }
+
+ .svg-content {
+ text-align: center;
+
+ svg {
+ max-width: 425px;
+ width: 100%;
+ padding: $gl-padding;
+ }
+ }
+
+ @media(max-width: $screen-xs-max) {
+ margin-top: 50px;
+ text-align: center;
+
+ .btn {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 92226f7432e..750d99ebabe 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -160,6 +160,7 @@ $settings-icon-size: 18px;
$provider-btn-group-border: #e5e5e5;
$provider-btn-not-active-color: #4688f1;
$link-underline-blue: #4a8bee;
+$active-item-blue: #4a8bee;
$layout-link-gray: #7e7c7c;
$todo-alert-blue: #428bca;
$btn-side-margin: 10px;
@@ -283,6 +284,9 @@ $calendar-unselectable-bg: $gray-light;
*/
$cycle-analytics-box-padding: 30px;
$cycle-analytics-box-text-color: #8c8c8c;
+$cycle-analytics-big-font: 19px;
+$cycle-analytics-dark-text: $gl-title-color;
+$cycle-analytics-light-gray: #bfbfbf;
/*
* Personal Access Tokens
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 572e1e7d558..498a8f68e49 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -1,10 +1,53 @@
#cycle-analytics {
+ max-width: 1000px;
margin: 24px auto 0;
- max-width: 800px;
position: relative;
- .panel {
+ .col-headers {
+ ul {
+ margin: 0;
+ padding: 0;
+ @include clearfix;
+ }
+
+ li {
+ display: inline-block;
+ float: left;
+ line-height: 50px;
+ width: 20%;
+ }
+
+
+ .fa {
+ color: $cycle-analytics-light-gray;
+ }
+
+ .stage-header {
+ width: 28%;
+ padding-left: $gl-padding;
+ }
+ .median-header {
+ width: 12%;
+ }
+
+ .event-header {
+ width: 45%;
+ padding-left: $gl-padding;
+ }
+
+ .total-time-header {
+ width: 15%;
+ text-align: right;
+ padding-right: $gl-padding;
+ }
+
+ .stage-name {
+ font-weight: 600;
+ }
+ }
+
+ .panel {
.content-block {
padding: 24px 0;
border-bottom: none;
@@ -35,23 +78,20 @@
}
&:last-child {
- text-align: right;
-
@media (max-width: $screen-sm-min) {
text-align: center;
}
}
}
+ }
- .dropdown {
- top: 13px;
- }
+ .js-ca-dropdown {
+ top: $gl-padding-top;
}
.bordered-box {
border: 1px solid $border-color;
border-radius: $border-radius-default;
-
}
.content-list {
@@ -141,4 +181,302 @@
margin-top: 36px;
}
+ .stage-panel-body {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ .stage-nav,
+ .stage-entries {
+ display: flex;
+ vertical-align: top;
+ font-size: $gl-font-size;
+ }
+
+ .stage-nav {
+ width: 40%;
+ margin-bottom: 0;
+
+ ul {
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ }
+
+ li {
+ list-style-type: none;
+ @include clearfix;
+ }
+
+ .stage-nav-item {
+ display: block;
+ line-height: 65px;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ border-right: 1px solid $border-color;
+ background-color: $gray-light;
+ cursor: default;
+
+ &.active {
+ background-color: transparent;
+ border-right-color: transparent;
+ border-top-color: $border-color;
+ border-bottom-color: $border-color;
+ box-shadow: inset 2px 0 0 0 $active-item-blue;
+
+ .stage-name {
+ font-weight: 600;
+ }
+ }
+
+ &:hover:not(.active) {
+ background-color: $gray-lightest;
+ box-shadow: inset 2px 0 0 0 $border-color;
+ }
+
+ &:first-child {
+ border-top: none;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .stage-nav-item-cell {
+ float: left;
+
+ &.stage-name {
+ width: 70%;
+ }
+
+ &.stage-median {
+ width: 30%;
+ }
+ }
+
+ .stage-name {
+ padding-left: 16px;
+ }
+
+ .stage-empty,
+ .not-available {
+ color: $gl-text-color-light;
+ }
+ }
+ }
+
+ .stage-panel-container {
+ width: 100%;
+ overflow: auto;
+ }
+
+ .stage-panel {
+ min-width: 968px;
+
+ .panel-heading {
+ padding: 0;
+ background-color: transparent;
+ }
+
+ .events-description {
+ line-height: 65px;
+ padding-left: $gl-padding;
+ }
+ }
+
+ .stage-events {
+ width: 60%;
+ overflow: scroll;
+ height: 467px;
+ }
+
+ .stage-event-list {
+ margin: 0;
+ padding: 0;
+ }
+
+ .stage-event-item {
+ list-style-type: none;
+ padding: 0 0 $gl-padding;
+ margin: 0 $gl-padding $gl-padding;
+ border-bottom: 1px solid $gray-darker;
+ @include clearfix;
+
+ &:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ }
+
+ .item-details,
+ .item-time {
+ float: left;
+ }
+
+ .item-details {
+ width: 75%;
+ }
+
+ .item-title {
+ margin: 0 0 2px;
+
+ &.issue-title,
+ &.commit-title,
+ &.merge-merquest-title {
+ max-width: 100%;
+ display: block;
+ @include text-overflow();
+
+ a {
+ color: $gl-dark-link-color;
+ }
+ }
+ }
+
+ .item-time {
+ width: 25%;
+ text-align: right;
+ }
+
+ .total-time {
+ font-size: $cycle-analytics-big-font;
+ color: $cycle-analytics-dark-text;
+
+ span {
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ }
+ }
+
+ .issue-date,
+ .build-date {
+ color: $gl-text-color;
+ }
+
+ .issue-link,
+ .commit-author-link,
+ .issue-author-link {
+ color: $gl-dark-link-color;
+ }
+
+ // Custom CSS for components
+ .item-conmmit-component {
+ .commit-icon {
+ position: relative;
+ top: 3px;
+ left: 1px;
+ display: inline-block;
+
+ svg {
+ float: left;
+ }
+ }
+ }
+
+ .merge-request-branch {
+ a {
+ max-width: 180px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: bottom;
+ }
+ }
+ }
+
+ // Custom Styles for stage items
+ .item-build-component {
+
+ .item-title {
+ .icon-build-status {
+ float: left;
+ margin-right: 5px;
+ position: relative;
+ top: 2px;
+ }
+
+ .item-build-name {
+ color: $gl-title-color;
+ }
+
+ .pipeline-id {
+ color: $gl-title-color;
+ padding: 0 3px 0 0;
+ }
+
+ .branch-name {
+ color: $black;
+ display: inline-block;
+ max-width: 180px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ line-height: 1.3;
+ vertical-align: top;
+ }
+
+ .short-sha {
+ color: $gl-link-color;
+ line-height: 1.3;
+ vertical-align: top;
+ font-weight: normal;
+ }
+
+ .fa {
+ color: $gl-text-color-light;
+ font-size: $code_font_size;
+ }
+ }
+ }
+
+ .empty-stage,
+ .no-access-stage {
+ text-align: center;
+ width: 75%;
+ margin: 0 auto;
+ padding-top: 130px;
+ color: $gl-text-color-light;
+
+ h4 {
+ color: $gl-text-color;
+ }
+ }
+
+ .empty-stage {
+ .icon-no-data {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+
+ .no-access-stage {
+ .icon-lock {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+}
+
+.cycle-analytics-overview {
+ padding-top: 100px;
+
+ .overview-details {
+ display: flex;
+ align-items: center;
+ }
+
+ .overview-image {
+ text-align: right;
+ }
+
+ .overview-icon {
+ svg {
+ width: 365px;
+ height: 227px;
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 13402acd8e1..8843d1463db 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -11,6 +11,7 @@
}
.progress {
+ width: 100%;
height: 6px;
}
}
@@ -30,7 +31,6 @@
margin-right: 7px;
}
- // Issue title
span a {
color: $gl-text-color;
word-wrap: break-word;
@@ -39,15 +39,66 @@
}
.milestone-summary {
- margin-bottom: 25px;
-
.milestone-stat {
+ white-space: nowrap;
margin-right: 10px;
+
+ &.with-drilldown {
+ margin-right: 2px;
+ }
}
.remaining-days {
color: $orange-light;
}
+
+ .milestone-stats-and-buttons {
+ display: flex;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+
+ @media (min-width: $screen-xs-min) {
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ }
+ }
+
+ .milestone-progress-buttons {
+ order: 1;
+ margin-top: 10px;
+
+ @media (min-width: $screen-xs-min) {
+ order: 2;
+ margin-top: 0;
+ flex-shrink: 0;
+ }
+
+ .btn {
+ float: left;
+ margin-right: $btn-side-margin;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ .milestone-stats {
+ order: 2;
+ width: 100%;
+ padding: 7px 0;
+ flex-shrink: 1;
+
+ @media (min-width: $screen-xs-min) {
+ // when displayed on one line stats go first, buttons second
+ order: 1;
+ }
+ }
+
+ .progress {
+ width: 100%;
+ margin: 15px 0;
+ }
}
.issues-sortable-list,
@@ -82,3 +133,50 @@
}
}
}
+
+.milestone-page-header {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ flex-wrap: wrap;
+
+ .status-box {
+ margin-top: 0;
+ }
+
+ .milestone-buttons {
+ margin-left: auto;
+ }
+
+ .status-box {
+ order: 1;
+ }
+
+ .milestone-buttons {
+ order: 2;
+ }
+
+ .header-text-content {
+ order: 3;
+ width: 100%;
+ }
+
+ .milestone-buttons .verbose {
+ display: none;
+ }
+
+ @media (min-width: $screen-xs-min) {
+ .milestone-buttons .verbose {
+ display: inline;
+ }
+
+ .header-text-content {
+ order: 2;
+ width: auto;
+ }
+
+ .milestone-buttons {
+ order: 3;
+ }
+ }
+}
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 96eb75a0547..fd263960b93 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -8,6 +8,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def show
@cycle_analytics = ::CycleAnalytics.new(@project, from: start_date(cycle_analytics_params))
+ stats_values, cycle_analytics_json = generate_cycle_analytics_data
+
+ @cycle_analytics_no_data = stats_values.blank?
+
respond_to do |format|
format.html
format.json { render json: cycle_analytics_json }
@@ -22,23 +26,29 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
{ start_date: params[:cycle_analytics][:start_date] }
end
- def cycle_analytics_json
- cycle_analytics_view_data = [[:issue, "Issue", "Time before an issue gets scheduled"],
- [:plan, "Plan", "Time before an issue starts implementation"],
- [:code, "Code", "Time until first merge request"],
- [:test, "Test", "Total test time for all commits/merges"],
- [:review, "Review", "Time between merge request creation and merge/close"],
- [:staging, "Staging", "From merge request merge until deploy to production"],
- [:production, "Production", "From issue creation until deploy to production"]]
+ def generate_cycle_analytics_data
+ stats_values = []
+
+ cycle_analytics_view_data = [[:issue, "Issue", "Related Issues", "Time before an issue gets scheduled"],
+ [:plan, "Plan", "Related Commits", "Time before an issue starts implementation"],
+ [:code, "Code", "Related Merge Requests", "Time spent coding"],
+ [:test, "Test", "Relative Builds Trigger by Commits", "The time taken to build and test the application"],
+ [:review, "Review", "Relative Merged Requests", "The time taken to review the code"],
+ [:staging, "Staging", "Relative Deployed Builds", "The time taken in staging"],
+ [:production, "Production", "Related Issues", "The total time taken from idea to production"]]
- stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_description)|
+ stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_legend, stage_description)|
value = @cycle_analytics.send(stage_method).presence
+ stats_values << value.abs if value
+
stats << {
title: stage_text,
description: stage_description,
+ legend: stage_legend,
value: value && !value.zero? ? distance_of_time_in_words(value) : nil
}
+
stats
end
@@ -52,9 +62,11 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
{ title: "Deploy".pluralize(deploys), value: deploys }
]
- {
- summary: summary,
- stats: stats
+ cycle_analytics_hash = { summary: summary,
+ stats: stats,
+ permissions: @cycle_analytics.permissions(user: current_user)
}
+
+ [stats_values, cycle_analytics_hash]
end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index b343ba0b744..dbbd2ad849e 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -82,12 +82,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request_diff =
if params[:diff_id]
- @merge_request.merge_request_diffs.find(params[:diff_id])
+ @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
else
@merge_request.merge_request_diff
end
- @merge_request_diffs = @merge_request.merge_request_diffs.select_without_diff
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
@comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
if params[:start_sha].present?
@@ -417,7 +417,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
response = {
title: merge_request.title,
- sha: merge_request.diff_head_commit.short_id,
+ sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha),
status: status,
coverage: coverage
}
@@ -564,7 +564,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def define_pipelines_vars
@pipelines = @merge_request.all_pipelines
- if @pipelines.present?
+ if @pipelines.present? && @merge_request.commits.present?
@pipeline = @pipelines.first
@statuses = @pipeline.statuses.relevant
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index be5e0301a43..6d10fe3e9d7 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -50,14 +50,14 @@ module ApplicationSettingsHelper
def restricted_level_checkboxes(help_block_id)
Gitlab::VisibilityLevel.options.map do |name, level|
checked = restricted_visibility_levels(true).include?(level)
- css_class = 'btn'
- css_class += ' active' if checked
- checkbox_name = 'application_setting[restricted_visibility_levels][]'
+ css_class = checked ? 'active' : ''
+ checkbox_name = "application_setting[restricted_visibility_levels][]"
- label_tag(checkbox_name, class: css_class) do
+ label_tag(name, class: css_class) do
check_box_tag(checkbox_name, level, checked,
autocomplete: 'off',
- 'aria-describedby' => help_block_id) + name
+ 'aria-describedby' => help_block_id,
+ id: name) + visibility_level_icon(level) + name
end
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index ab880ed6de0..75cd9eece5c 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -48,4 +48,8 @@ module GroupsHelper
"#{status.humanize} #{projects_lfs_status(group)}"
end
end
+
+ def group_issues(group)
+ IssuesFinder.new(current_user, group_id: group.id).execute
+ end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 17123b1eaee..898ce6a3af7 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -455,4 +455,8 @@ module ProjectsHelper
def project_child_container_class(view_path)
view_path == "projects/issues/issues" ? "prepend-top-default" : "project-show-#{view_path}"
end
+
+ def project_issues(project)
+ IssuesFinder.new(current_user, project_id: project.id).execute
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 83c0c64e5fb..e7d33bd26db 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -487,6 +487,10 @@ module Ci
]
end
+ def credentials
+ Gitlab::Ci::Build::Credentials::Factory.new(self).create!
+ end
+
private
def update_artifacts_size
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index eb2ff0428f6..8ab0401d288 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -1,6 +1,6 @@
# == Mentionable concern
#
-# Contains functionality related to objects that can mention Users, Issues, MergeRequests, or Commits by
+# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
# GFM references.
#
# Used by Issue, Note, MergeRequest, and Commit.
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
index 314a1ce9b63..cb8e088d21d 100644
--- a/app/models/cycle_analytics.rb
+++ b/app/models/cycle_analytics.rb
@@ -1,4 +1,6 @@
class CycleAnalytics
+ STAGES = %i[issue plan code test review staging production].freeze
+
def initialize(project, from:)
@project = project
@from = from
@@ -9,6 +11,10 @@ class CycleAnalytics
@summary ||= Summary.new(@project, from: @from)
end
+ def permissions(user:)
+ Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
+ end
+
def issue
@fetcher.calculate_metric(:issue,
Issue.arel_table[:created_at],
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 5278efd71d2..a7f4156fc2e 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -19,7 +19,7 @@ class Environment < ActiveRecord::Base
allow_nil: true,
addressable_url: true
- delegate :stop_action, to: :last_deployment, allow_nil: true
+ delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
scope :available, -> { with_state(:available) }
scope :stopped, -> { with_state(:stopped) }
@@ -99,4 +99,12 @@ class Environment < ActiveRecord::Base
stop
stop_action.play(current_user)
end
+
+ def actions_for(environment)
+ return [] unless manual_actions
+
+ manual_actions.select do |action|
+ action.expanded_environment_name == environment
+ end
+ end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index dd65a9a8b86..58a24eb84cb 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -11,6 +11,9 @@ class MergeRequestDiff < ActiveRecord::Base
belongs_to :merge_request
+ serialize :st_commits
+ serialize :st_diffs
+
state_machine :state, initial: :empty do
state :collected
state :overflow
@@ -22,8 +25,7 @@ class MergeRequestDiff < ActiveRecord::Base
state :overflow_diff_lines_limit
end
- serialize :st_commits
- serialize :st_diffs
+ scope :viewable, -> { without_state(:empty) }
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
diff --git a/app/models/project.rb b/app/models/project.rb
index 995359daf1e..f8a54324341 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1086,7 +1086,7 @@ class Project < ActiveRecord::Base
"refs/heads/#{branch}",
force: true)
repository.copy_gitattributes(branch)
- repository.expire_avatar_cache(branch)
+ repository.expire_avatar_cache
reload_default_branch
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 2caf6179ef8..aeded715893 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -128,15 +128,9 @@ class JiraService < IssueTrackerService
return unless jira_issue.present?
- project = self.project
- noteable_name = noteable.model_name.singular
- noteable_id = if noteable.is_a?(Commit)
- noteable.id
- else
- noteable.iid
- end
-
- entity_url = build_entity_url(noteable_name.to_sym, noteable_id)
+ noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
+ noteable_type = noteable_name(noteable)
+ entity_url = build_entity_url(noteable_type, noteable_id)
data = {
user: {
@@ -144,11 +138,11 @@ class JiraService < IssueTrackerService
url: resource_url(user_path(author)),
},
project: {
- name: project.path_with_namespace,
- url: resource_url(namespace_project_path(project.namespace, project))
+ name: self.project.path_with_namespace,
+ url: resource_url(namespace_project_path(project.namespace, self.project))
},
entity: {
- name: noteable_name.humanize.downcase,
+ name: noteable_type.humanize.downcase,
url: entity_url,
title: noteable.title
}
@@ -285,18 +279,26 @@ class JiraService < IssueTrackerService
"#{Settings.gitlab.base_url.chomp("/")}#{resource}"
end
- def build_entity_url(entity_name, entity_id)
+ def build_entity_url(noteable_type, entity_id)
polymorphic_url(
[
self.project.namespace.becomes(Namespace),
self.project,
- entity_name
+ noteable_type.to_sym
],
id: entity_id,
host: Settings.gitlab.base_url
)
end
+ def noteable_name(noteable)
+ name = noteable.model_name.singular
+
+ # ProjectSnippet inherits from Snippet class so it causes
+ # routing error building the URL.
+ name == "project_snippet" ? "snippet" : name
+ end
+
# Handle errors when doing JIRA API calls
def jira_request
yield
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 31be06be50c..bf136ccdb6c 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1,15 +1,53 @@
require 'securerandom'
class Repository
+ include Gitlab::ShellAdapter
+
+ attr_accessor :path_with_namespace, :project
+
class CommitError < StandardError; end
- # Files to use as a project avatar in case no avatar was uploaded via the web
- # UI.
- AVATAR_FILES = %w{logo.png logo.jpg logo.gif}
+ # Methods that cache data from the Git repository.
+ #
+ # Each entry in this Array should have a corresponding method with the exact
+ # same name. The cache key used by those methods must also match method's
+ # name.
+ #
+ # For example, for entry `:readme` there's a method called `readme` which
+ # stores its data in the `readme` cache key.
+ CACHED_METHODS = %i(size commit_count readme version contribution_guide
+ changelog license_blob license_key gitignore koding_yml
+ gitlab_ci_yml branch_names tag_names branch_count
+ tag_count avatar exists? empty? root_ref)
+
+ # Certain method caches should be refreshed when certain types of files are
+ # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to
+ # the corresponding methods to call for refreshing caches.
+ METHOD_CACHES_FOR_FILE_TYPES = {
+ readme: :readme,
+ changelog: :changelog,
+ license: %i(license_blob license_key),
+ contributing: :contribution_guide,
+ version: :version,
+ gitignore: :gitignore,
+ koding: :koding_yml,
+ gitlab_ci: :gitlab_ci_yml,
+ avatar: :avatar
+ }
+
+ # Wraps around the given method and caches its output in Redis and an instance
+ # variable.
+ #
+ # This only works for methods that do not take any arguments.
+ def self.cache_method(name, fallback: nil)
+ original = :"_uncached_#{name}"
- include Gitlab::ShellAdapter
+ alias_method(original, name)
- attr_accessor :path_with_namespace, :project
+ define_method(name) do
+ cache_method_output(name, fallback: fallback) { __send__(original) }
+ end
+ end
def self.storages
Gitlab.config.repositories.storages
@@ -37,24 +75,6 @@ class Repository
)
end
- def exists?
- return @exists unless @exists.nil?
-
- @exists = cache.fetch(:exists?) do
- begin
- raw_repository && raw_repository.rugged ? true : false
- rescue Gitlab::Git::Repository::NoRepository
- false
- end
- end
- end
-
- def empty?
- return @empty unless @empty.nil?
-
- @empty = cache.fetch(:empty?) { raw_repository.empty? }
- end
-
#
# Git repository can contains some hidden refs like:
# /refs/notes/*
@@ -221,10 +241,6 @@ class Repository
branch_names + tag_names
end
- def branch_names
- @branch_names ||= cache.fetch(:branch_names) { branches.map(&:name) }
- end
-
def branch_exists?(branch_name)
branch_names.include?(branch_name)
end
@@ -274,34 +290,6 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
- def tag_names
- cache.fetch(:tag_names) { raw_repository.tag_names }
- end
-
- def commit_count
- cache.fetch(:commit_count) do
- begin
- raw_repository.commit_count(self.root_ref)
- rescue
- 0
- end
- end
- end
-
- def branch_count
- @branch_count ||= cache.fetch(:branch_count) { branches.size }
- end
-
- def tag_count
- @tag_count ||= cache.fetch(:tag_count) { raw_repository.rugged.tags.count }
- end
-
- # Return repo size in megabytes
- # Cached in redis
- def size
- cache.fetch(:size) { raw_repository.size }
- end
-
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
@@ -317,48 +305,55 @@ class Repository
end
end
- # Keys for data that can be affected for any commit push.
- def cache_keys
- %i(size commit_count
- readme version contribution_guide changelog
- license_blob license_key gitignore koding_yml)
+ def expire_tags_cache
+ expire_method_caches(%i(tag_names tag_count))
+ @tags = nil
end
- # Keys for data on branch/tag operations.
- def cache_keys_for_branches_and_tags
- %i(branch_names tag_names branch_count tag_count)
+ def expire_branches_cache
+ expire_method_caches(%i(branch_names branch_count))
+ @local_branches = nil
end
- def build_cache
- (cache_keys + cache_keys_for_branches_and_tags).each do |key|
- unless cache.exist?(key)
- send(key)
- end
- end
+ def expire_statistics_caches
+ expire_method_caches(%i(size commit_count))
end
- def expire_tags_cache
- cache.expire(:tag_names)
- @tags = nil
+ def expire_all_method_caches
+ expire_method_caches(CACHED_METHODS)
end
- def expire_branches_cache
- cache.expire(:branch_names)
- @branch_names = nil
- @local_branches = nil
+ # Expires the caches of a specific set of methods
+ def expire_method_caches(methods)
+ methods.each do |key|
+ cache.expire(key)
+
+ ivar = cache_instance_variable_name(key)
+
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
+ end
end
- def expire_cache(branch_name = nil, revision = nil)
- cache_keys.each do |key|
- cache.expire(key)
+ def expire_avatar_cache
+ expire_method_caches(%i(avatar))
+ end
+
+ # Refreshes the method caches of this repository.
+ #
+ # types - An Array of file types (e.g. `:readme`) used to refresh extra
+ # caches.
+ def refresh_method_caches(types)
+ to_refresh = []
+
+ types.each do |type|
+ methods = METHOD_CACHES_FOR_FILE_TYPES[type.to_sym]
+
+ to_refresh.concat(Array(methods)) if methods
end
- expire_branch_cache(branch_name)
- expire_avatar_cache(branch_name, revision)
+ expire_method_caches(to_refresh)
- # This ensures this particular cache is flushed after the first commit to a
- # new repository.
- expire_emptiness_caches if empty?
+ to_refresh.each { |method| send(method) }
end
def expire_branch_cache(branch_name = nil)
@@ -377,15 +372,14 @@ class Repository
end
def expire_root_ref_cache
- cache.expire(:root_ref)
- @root_ref = nil
+ expire_method_caches(%i(root_ref))
end
# Expires the cache(s) used to determine if a repository is empty or not.
def expire_emptiness_caches
- cache.expire(:empty?)
- @empty = nil
+ return unless empty?
+ expire_method_caches(%i(empty?))
expire_has_visible_content_cache
end
@@ -394,51 +388,22 @@ class Repository
@has_visible_content = nil
end
- def expire_branch_count_cache
- cache.expire(:branch_count)
- @branch_count = nil
- end
-
- def expire_tag_count_cache
- cache.expire(:tag_count)
- @tag_count = nil
- end
-
def lookup_cache
@lookup_cache ||= {}
end
- def expire_avatar_cache(branch_name = nil, revision = nil)
- # Avatars are pulled from the default branch, thus if somebody pushes to a
- # different branch there's no need to expire anything.
- return if branch_name && branch_name != root_ref
-
- # We don't want to flush the cache if the commit didn't actually make any
- # changes to any of the possible avatar files.
- if revision && commit = self.commit(revision)
- return unless commit.raw_diffs(deltas_only: true).
- any? { |diff| AVATAR_FILES.include?(diff.new_path) }
- end
-
- cache.expire(:avatar)
-
- @avatar = nil
- end
-
def expire_exists_cache
- cache.expire(:exists?)
- @exists = nil
+ expire_method_caches(%i(exists?))
end
# expire cache that doesn't depend on repository data (when expiring)
def expire_content_cache
expire_tags_cache
- expire_tag_count_cache
expire_branches_cache
- expire_branch_count_cache
expire_root_ref_cache
expire_emptiness_caches
expire_exists_cache
+ expire_statistics_caches
end
# Runs code after a repository has been created.
@@ -453,9 +418,8 @@ class Repository
# Runs code just before a repository is deleted.
def before_delete
expire_exists_cache
-
- expire_cache if exists?
-
+ expire_all_method_caches
+ expire_branch_cache if exists?
expire_content_cache
repository_event(:remove_repository)
@@ -472,9 +436,9 @@ class Repository
# Runs code before pushing (= creating or removing) a tag.
def before_push_tag
- expire_cache
+ expire_statistics_caches
+ expire_emptiness_caches
expire_tags_cache
- expire_tag_count_cache
repository_event(:push_tag)
end
@@ -482,7 +446,7 @@ class Repository
# Runs code before removing a tag.
def before_remove_tag
expire_tags_cache
- expire_tag_count_cache
+ expire_statistics_caches
repository_event(:remove_tag)
end
@@ -494,12 +458,14 @@ class Repository
# Runs code after a repository has been forked/imported.
def after_import
expire_content_cache
- build_cache
+ expire_tags_cache
+ expire_branches_cache
end
# Runs code after a new commit has been pushed.
- def after_push_commit(branch_name, revision)
- expire_cache(branch_name, revision)
+ def after_push_commit(branch_name)
+ expire_statistics_caches
+ expire_branch_cache(branch_name)
repository_event(:push_commit, branch: branch_name)
end
@@ -508,7 +474,6 @@ class Repository
def after_create_branch
expire_branches_cache
expire_has_visible_content_cache
- expire_branch_count_cache
repository_event(:push_branch)
end
@@ -523,7 +488,6 @@ class Repository
# Runs code after an existing branch has been removed.
def after_remove_branch
expire_has_visible_content_cache
- expire_branch_count_cache
expire_branches_cache
end
@@ -550,86 +514,127 @@ class Repository
Gitlab::Git::Blob.raw(self, oid)
end
+ def root_ref
+ if raw_repository
+ raw_repository.root_ref
+ else
+ # When the repo does not exist we raise this error so no data is cached.
+ raise Rugged::ReferenceError
+ end
+ end
+ cache_method :root_ref
+
+ def exists?
+ refs_directory_exists?
+ end
+ cache_method :exists?
+
+ def empty?
+ raw_repository.empty?
+ end
+ cache_method :empty?
+
+ # The size of this repository in megabytes.
+ def size
+ exists? ? raw_repository.size : 0.0
+ end
+ cache_method :size, fallback: 0.0
+
+ def commit_count
+ root_ref ? raw_repository.commit_count(root_ref) : 0
+ end
+ cache_method :commit_count, fallback: 0
+
+ def branch_names
+ branches.map(&:name)
+ end
+ cache_method :branch_names, fallback: []
+
+ def tag_names
+ raw_repository.tag_names
+ end
+ cache_method :tag_names, fallback: []
+
+ def branch_count
+ branches.size
+ end
+ cache_method :branch_count, fallback: 0
+
+ def tag_count
+ raw_repository.rugged.tags.count
+ end
+ cache_method :tag_count, fallback: 0
+
+ def avatar
+ if tree = file_on_head(:avatar)
+ tree.path
+ end
+ end
+ cache_method :avatar
+
def readme
- cache.fetch(:readme) { tree(:head).readme }
+ if head = tree(:head)
+ head.readme
+ end
end
+ cache_method :readme
def version
- cache.fetch(:version) do
- tree(:head).blobs.find do |file|
- file.name.casecmp('version').zero?
- end
- end
+ file_on_head(:version)
end
+ cache_method :version
def contribution_guide
- cache.fetch(:contribution_guide) do
- tree(:head).blobs.find do |file|
- file.contributing?
- end
- end
+ file_on_head(:contributing)
end
+ cache_method :contribution_guide
def changelog
- cache.fetch(:changelog) do
- file_on_head(/\A(changelog|history|changes|news)/i)
- end
+ file_on_head(:changelog)
end
+ cache_method :changelog
def license_blob
- return nil unless head_exists?
-
- cache.fetch(:license_blob) do
- file_on_head(/\A(licen[sc]e|copying)(\..+|\z)/i)
- end
+ file_on_head(:license)
end
+ cache_method :license_blob
def license_key
- return nil unless head_exists?
+ return unless exists?
- cache.fetch(:license_key) do
- Licensee.license(path).try(:key)
- end
+ Licensee.license(path).try(:key)
end
+ cache_method :license_key
def gitignore
- return nil if !exists? || empty?
-
- cache.fetch(:gitignore) do
- file_on_head(/\A\.gitignore\z/)
- end
+ file_on_head(:gitignore)
end
+ cache_method :gitignore
def koding_yml
- return nil unless head_exists?
-
- cache.fetch(:koding_yml) do
- file_on_head(/\A\.koding\.yml\z/)
- end
+ file_on_head(:koding)
end
+ cache_method :koding_yml
def gitlab_ci_yml
- return nil unless head_exists?
-
- @gitlab_ci_yml ||= tree(:head).blobs.find do |file|
- file.name == '.gitlab-ci.yml'
- end
- rescue Rugged::ReferenceError
- # For unknow reason spinach scenario "Scenario: I change project path"
- # lead to "Reference 'HEAD' not found" exception from Repository#empty?
- nil
+ file_on_head(:gitlab_ci)
end
+ cache_method :gitlab_ci_yml
def head_commit
@head_commit ||= commit(self.root_ref)
end
def head_tree
- @head_tree ||= Tree.new(self, head_commit.sha, nil)
+ if head_commit
+ @head_tree ||= Tree.new(self, head_commit.sha, nil)
+ end
end
def tree(sha = :head, path = nil, recursive: false)
if sha == :head
+ return unless head_commit
+
if path.nil?
return head_tree
else
@@ -779,10 +784,6 @@ class Repository
@tags ||= raw_repository.tags
end
- def root_ref
- @root_ref ||= cache.fetch(:root_ref) { raw_repository.root_ref }
- end
-
def commit_dir(user, path, message, branch, author_email: nil, author_name: nil)
update_branch_with_hooks(user, branch) do |ref|
options = {
@@ -1140,28 +1141,55 @@ class Repository
end
end
- def avatar
- return nil unless exists?
+ # Caches the supplied block both in a cache and in an instance variable.
+ #
+ # The cache key and instance variable are named the same way as the value of
+ # the `key` argument.
+ #
+ # This method will return `nil` if the corresponding instance variable is also
+ # set to `nil`. This ensures we don't keep yielding the block when it returns
+ # `nil`.
+ #
+ # key - The name of the key to cache the data in.
+ # fallback - A value to fall back to in the event of a Git error.
+ def cache_method_output(key, fallback: nil, &block)
+ ivar = cache_instance_variable_name(key)
- @avatar ||= cache.fetch(:avatar) do
- AVATAR_FILES.find do |file|
- blob_at_branch(root_ref, file)
+ if instance_variable_defined?(ivar)
+ instance_variable_get(ivar)
+ else
+ begin
+ instance_variable_set(ivar, cache.fetch(key, &block))
+ rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository
+ # if e.g. HEAD or the entire repository doesn't exist we want to
+ # gracefully handle this and not cache anything.
+ fallback
end
end
end
- private
+ def cache_instance_variable_name(key)
+ :"@#{key.to_s.tr('?!', '')}"
+ end
- def cache
- @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
+ def file_on_head(type)
+ if head = tree(:head)
+ head.blobs.find do |file|
+ Gitlab::FileDetector.type_of(file.name) == type
+ end
+ end
end
- def head_exists?
- exists? && !empty? && !rugged.head_unborn?
+ private
+
+ def refs_directory_exists?
+ return false unless path_with_namespace
+
+ File.exist?(File.join(path_to_repo, 'refs'))
end
- def file_on_head(regex)
- tree(:head).blobs.find { |file| file.name =~ regex }
+ def cache
+ @cache ||= RepositoryCache.new(path_with_namespace, @project.id)
end
def tags_sorted_by_committed_date
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 2373b445009..8ff4e7ae718 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -6,6 +6,7 @@ class Snippet < ActiveRecord::Base
include Referable
include Sortable
include Awardable
+ include Mentionable
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content
diff --git a/app/models/tree.rb b/app/models/tree.rb
index 2d1d68dbd81..fe148b0ec65 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -18,7 +18,9 @@ class Tree
def readme
return @readme if defined?(@readme)
- available_readmes = blobs.select(&:readme?)
+ available_readmes = blobs.select do |blob|
+ Gitlab::FileDetector.type_of(blob.name) == :readme
+ end
previewable_readmes = available_readmes.select do |blob|
previewable?(blob.name)
diff --git a/app/services/after_branch_delete_service.rb b/app/services/after_branch_delete_service.rb
index 2be4d3e6ab5..227e9ea9c6d 100644
--- a/app/services/after_branch_delete_service.rb
+++ b/app/services/after_branch_delete_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
##
# Branch can be deleted either by DeleteBranchService
# or by GitPushService.
diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb
index 757fc35a78f..e004a303496 100644
--- a/app/services/create_branch_service.rb
+++ b/app/services/create_branch_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class CreateBranchService < BaseService
def execute(branch_name, ref, source_project: @project)
valid_branch = Gitlab::GitRefValidator.validate(branch_name)
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 8ae15ad32f4..47f9b2c621c 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class CreateDeploymentService < BaseService
def execute(deployable = nil)
return unless executable?
diff --git a/app/services/create_release_service.rb b/app/services/create_release_service.rb
index d6d4afcf29a..54ff1f74126 100644
--- a/app/services/create_release_service.rb
+++ b/app/services/create_release_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class CreateReleaseService < BaseService
def execute(tag_name, release_description)
repository = project.repository
diff --git a/app/services/create_tag_service.rb b/app/services/create_tag_service.rb
index c0e7ecf6a96..fe9353afeb8 100644
--- a/app/services/create_tag_service.rb
+++ b/app/services/create_tag_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class CreateTagService < BaseService
def execute(tag_name, target, message, release_description = nil)
valid_tag = Gitlab::GitRefValidator.validate(tag_name)
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 3e5dd4ebb86..11a045f4c31 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class DeleteBranchService < BaseService
def execute(branch_name)
repository = project.repository
diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
index 8b8deafedb7..1b5623baebe 100644
--- a/app/services/delete_merged_branches_service.rb
+++ b/app/services/delete_merged_branches_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class DeleteMergedBranchesService < BaseService
def async_execute
DeleteMergedBranchesWorker.perform_async(project.id, current_user.id)
diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb
index d824406cb49..a44dee14a0f 100644
--- a/app/services/delete_tag_service.rb
+++ b/app/services/delete_tag_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class DeleteTagService < BaseService
def execute(tag_name)
repository = project.repository
diff --git a/app/services/files/create_dir_service.rb b/app/services/files/create_dir_service.rb
index d00d78cee7e..e5b4d60e467 100644
--- a/app/services/files/create_dir_service.rb
+++ b/app/services/files/create_dir_service.rb
@@ -1,5 +1,3 @@
-require_relative "base_service"
-
module Files
class CreateDirService < Files::BaseService
def commit
diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb
index bf127843d55..b23576b9a28 100644
--- a/app/services/files/create_service.rb
+++ b/app/services/files/create_service.rb
@@ -1,5 +1,3 @@
-require_relative "base_service"
-
module Files
class CreateService < Files::BaseService
def commit
diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb
index 8b27ad51789..4f7e7a5baaa 100644
--- a/app/services/files/delete_service.rb
+++ b/app/services/files/delete_service.rb
@@ -1,5 +1,3 @@
-require_relative "base_service"
-
module Files
class DeleteService < Files::BaseService
def commit
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index d28912e1301..54446e90007 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -1,5 +1,3 @@
-require_relative "base_service"
-
module Files
class MultiService < Files::BaseService
class FileChangedError < StandardError; end
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index c17fdb8d1f1..47a18e3e132 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -1,5 +1,3 @@
-require_relative "base_service"
-
module Files
class UpdateService < Files::BaseService
class FileChangedError < StandardError; end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 77c6c81cc1b..647930d555c 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -18,7 +18,7 @@ class GitPushService < BaseService
#
def execute
@project.repository.after_create if @project.empty_repo?
- @project.repository.after_push_commit(branch_name, params[:newrev])
+ @project.repository.after_push_commit(branch_name)
if push_remove_branch?
@project.repository.after_remove_branch
@@ -51,12 +51,32 @@ class GitPushService < BaseService
execute_related_hooks
perform_housekeeping
+
+ update_caches
end
def update_gitattributes
@project.repository.copy_gitattributes(params[:ref])
end
+ def update_caches
+ if is_default_branch?
+ paths = Set.new
+
+ @push_commits.each do |commit|
+ commit.raw_diffs(deltas_only: true).each do |diff|
+ paths << diff.new_path
+ end
+ end
+
+ types = Gitlab::FileDetector.types_in_paths(paths.to_a)
+ else
+ types = []
+ end
+
+ ProjectCacheWorker.perform_async(@project.id, types)
+ end
+
protected
def execute_related_hooks
@@ -70,7 +90,6 @@ class GitPushService < BaseService
@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
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 4a7e6930842..22596b4014a 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -60,7 +60,15 @@ module MergeRequests
merge_requests = filter_merge_requests(merge_requests)
merge_requests.each do |merge_request|
- reload_diff(merge_request) unless branch_removed?
+ if merge_request.source_branch == @branch_name || force_push?
+ merge_request.reload_diff
+ else
+ mr_commit_ids = merge_request.commits.map(&:id)
+ push_commit_ids = @commits.map(&:id)
+ matches = mr_commit_ids & push_commit_ids
+ merge_request.reload_diff if matches.any?
+ end
+
merge_request.mark_as_unchecked
end
end
@@ -165,16 +173,5 @@ module MergeRequests
def branch_removed?
Gitlab::Git.blank_ref?(@newrev)
end
-
- def reload_diff(merge_request)
- if merge_request.source_branch == @branch_name || force_push?
- merge_request.reload_diff
- else
- mr_commit_ids = merge_request.commits.map(&:id)
- push_commit_ids = @commits.map(&:id)
- matches = mr_commit_ids & push_commit_ids
- merge_request.reload_diff if matches.any?
- end
- end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index a37cc3fdf21..fda0da19d87 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -1,7 +1,3 @@
-require_relative 'base_service'
-require_relative 'reopen_service'
-require_relative 'close_service'
-
module MergeRequests
class UpdateService < MergeRequests::BaseService
def execute(merge_request)
diff --git a/app/services/update_release_service.rb b/app/services/update_release_service.rb
index 0ee1ff2d7d9..b7c36651968 100644
--- a/app/services/update_release_service.rb
+++ b/app/services/update_release_service.rb
@@ -1,5 +1,3 @@
-require_relative 'base_service'
-
class UpdateReleaseService < BaseService
def execute(tag_name, release_description)
repository = project.repository
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index a236335131a..95cae5ea24b 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -22,9 +22,8 @@
.form-group
= f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
.col-sm-10
- - data_attrs = { toggle: 'buttons' }
- .btn-group{ data: data_attrs }
- - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ - restricted_level_checkboxes('restricted-visibility-help').each do |level|
+ .checkbox
= level
%span.help-block#restricted-visibility-help
Selected levels cannot be used by non-admin users for projects or snippets.
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index dc6c1bb69de..324a116a50e 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -3,24 +3,27 @@
- if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@group.name} issues")
-.top-area
- = render 'shared/issuable/nav', type: :issues
- .nav-controls
- - if current_user
- = link_to url_for(params.merge(format: :atom, private_token: current_user.private_token)), class: 'btn' do
- = icon('rss')
- %span.icon-label
- Subscribe
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
+- if group_issues(@group).exists?
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls
+ - if current_user
+ = link_to url_for(params.merge(format: :atom, private_token: current_user.private_token)), class: 'btn' do
+ = icon('rss')
+ %span.icon-label
+ Subscribe
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
-= render 'shared/issuable/filter', type: :issues
+ = render 'shared/issuable/filter', type: :issues
-.row-content-block.second-block
- Only issues from
- %strong #{@group.name}
- group are listed here.
- - if current_user
- To see all issues you should visit #{link_to 'dashboard', issues_dashboard_path} page.
+ .row-content-block.second-block
+ Only issues from the
+ %strong #{@group.name}
+ group are listed here.
+ - if current_user
+ To see all issues you should visit #{link_to 'dashboard', issues_dashboard_path} page.
-.prepend-top-default
- = render 'shared/issues'
+ .prepend-top-default
+ = render 'shared/issues'
+- else
+ = render 'shared/empty_states/issues', project_select_button: true
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 6b32d377e1a..1ec1e7c70e4 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -19,7 +19,7 @@
= chat_name.chat_name
%td
- if chat_name.last_used_at
- time_ago_with_tooltip(chat_name.last_used_at)
+ = time_ago_with_tooltip(chat_name.last_used_at)
- else
Never
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index d011e51e696..4f15f2997fb 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -13,7 +13,7 @@
= spinner
:javascript
- var activity = new Activities();
+ var activity = new gl.Activities();
$(document).on('page:restore', function (event) {
activity.reloadActivities()
})
diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml
new file mode 100644
index 00000000000..b200ce22970
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml
@@ -0,0 +1,7 @@
+.empty-stage-container
+ .empty-stage
+ .icon-no-data
+ = custom_icon ('icon_no_data')
+ %h4 We don’t have enough data to show this stage.
+ %p
+ {{currentStage.emptyStageText}}
diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml
new file mode 100644
index 00000000000..0ffc79b3181
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_no_access.html.haml
@@ -0,0 +1,7 @@
+.no-access-stage-container
+ .no-access-stage
+ .icon-lock
+ = custom_icon ('icon_lock')
+ %h4 You need permission.
+ %p
+ Want to see the data? Please ask administrator for access.
diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml
new file mode 100644
index 00000000000..c8f0b547f80
--- /dev/null
+++ b/app/views/projects/cycle_analytics/_overview.html.haml
@@ -0,0 +1,15 @@
+.cycle-analytics-overview
+ .container
+ .row
+ .col-md-10.col-md-offset-1
+ .row.overview-details
+ .col-md-6.overview-text
+ %h4 Introducing Cycle Analytics
+ %p
+ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
+ To set up CA, you must first define a production environment by setting up your CI and then deploy to production.
+ %p
+ %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: "_blank" } Read more
+ .col-md-6.overview-image
+ %span.overview-icon
+ = custom_icon ('icon_cycle_analytics_overview')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 247d612ba6f..ef1b38d5e21 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,40 +1,35 @@
- @no_container = true
- page_title "Cycle Analytics"
-
- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('cycle_analytics/cycle_analytics_bundle.js')
+ = page_specific_javascript_tag("cycle_analytics/cycle_analytics_bundle.js")
= render "projects/pipelines/head"
-#cycle-analytics{class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) }}
-
- .bordered-box.landing.content-block{"v-if" => "!isHelpDismissed"}
- = icon('times', class: 'dismiss-icon', "@click" => "dismissLanding()")
- .row
- .col-sm-3.col-xs-12.svg-container
- = custom_icon('icon_cycle_analytics_splash')
- .col-sm-8.col-xs-12.inner-content
- %h4
- Introducing Cycle Analytics
- %p
- Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
-
- = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
+#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
+ - if @cycle_analytics_no_data
+ .bordered-box.landing.content-block{"v-if" => "!isOverviewDialogDismissed"}
+ = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()")
+ .row
+ .col-sm-3.col-xs-12.svg-container
+ = custom_icon('icon_cycle_analytics_splash')
+ .col-sm-8.col-xs-12.inner-content
+ %h4
+ Introducing Cycle Analytics
+ %p
+ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
+ = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
= icon("spinner spin", "v-show" => "isLoading")
-
.wrapper{"v-show" => "!isLoading && !hasError"}
.panel.panel-default
.panel-heading
Pipeline Health
-
.content-block
.container-fluid
.row
- .col-sm-3.col-xs-12.column{"v-for" => "item in analytics.summary"}
+ .col-sm-3.col-xs-12.column{"v-for" => "item in state.summary"}
%h3.header {{item.value}}
%p.text {{item.title}}
-
.col-sm-3.col-xs-12.column
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{"data-toggle" => "dropdown", :type => "button"}
@@ -42,22 +37,54 @@
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
- %a{'href' => "#", 'data-value' => '30'}
+ %a{ "href" => "#", "data-value" => "30" }
Last 30 days
%li
- %a{'href' => "#", 'data-value' => '90'}
+ %a{ "href" => "#", "data-value" => "90" }
Last 90 days
-
- .bordered-box
- %ul.content-list
- %li{"v-for" => "item in analytics.stats"}
- .container-fluid
- .row
- .col-xs-8.title-col
- %p.title
- {{item.title}}
- %p.text
- {{item.description}}
- .col-xs-4.value-col
- %span
- {{item.value}}
+ .stage-panel-container
+ .panel.panel-default.stage-panel
+ .panel-heading
+ %nav.col-headers
+ %ul
+ %li.stage-header
+ %span.stage-name
+ Stage
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" }
+ %li.median-header
+ %span.stage-name
+ Median
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" }
+ %li.event-header
+ %span.stage-name
+ {{ currentStage ? currentStage.legend : 'Related Issues' }}
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" }
+ %li.total-time-header
+ %span.stage-name
+ Total Time
+ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" }
+ .stage-panel-body
+ %nav.stage-nav
+ %ul
+ %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" }
+ .stage-nav-item-cell.stage-name
+ {{ stage.title }}
+ .stage-nav-item-cell.stage-median
+ %template{ "v-if" => "stage.isUserAllowed" }
+ %span{ "v-if" => "stage.value" }
+ {{ stage.value }}
+ %span.stage-empty{ "v-else" => true }
+ Not enough data
+ %template{ "v-else" => true }
+ %span.not-available
+ Not available
+ .section.stage-events
+ %template{ "v-if" => "isLoadingStage" }
+ = icon("spinner spin")
+ %template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
+ = render partial: "no_access"
+ %template{ "v-else" => true }
+ %template{ "v-if" => "isEmptyStage && !isLoadingStage" }
+ = render partial: "empty_stage"
+ %template{ "v-if" => "state.events.length && !isLoadingStage && !isEmptyStage" }
+ %component{ ":is" => "currentStage.component", ":stage" => "currentStage", ":items" => "state.events" }
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index a4b752ad86d..34d5a3e1831 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,8 +1,7 @@
%ul.content-list.issues-list.issuable-list
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
- %li
- .nothing-here-block No issues to show
+ = render 'shared/empty_states/issues'
- if @issues.present?
= paginate @issues, theme: "gitlab"
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index c493ff3585b..26f3f0ac292 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -10,8 +10,8 @@
- if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues")
-%div{ class: (container_class) }
- - if @project.issues.any?
+- if project_issues(@project).exists?
+ %div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
@@ -36,21 +36,5 @@
= render 'issues'
- if new_issue_email
= render 'issue_by_email', email: new_issue_email
- - else
- .blank-state.blank-state-welcome
- %h2.blank-state-title.blank-state-welcome-title
- Welcome to GitLab Issues
- %p.blank-state-text
- Code, test, and deploy together
- .blank-state
- .blank-state-icon
- = custom_icon("issues", size: 50)
- %h3.blank-state-title
- You don't have any issues right now.
- %p.blank-state-text
- Issues are the best way to track your project progress
- - if can? current_user, :create_issue, @project
- = link_to new_namespace_project_issue_path(@project.namespace, @project), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do
- New Issue
- - if new_issue_email
- = render 'issue_by_email', email: new_issue_email
+- else
+ = render 'shared/empty_states/issues', button_path: new_namespace_project_issue_path(@project.namespace, @project)
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
index ac26aa569ba..20c93930abc 100644
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ b/app/views/projects/merge_requests/widget/_open.html.haml
@@ -9,10 +9,10 @@
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- - elsif @merge_request.commits.blank?
- = render 'projects/merge_requests/widget/open/nothing'
- elsif @merge_request.branch_missing?
= render 'projects/merge_requests/widget/open/missing_branch'
+ - elsif @merge_request.commits.blank?
+ = render 'projects/merge_requests/widget/open/nothing'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- elsif @merge_request.cannot_be_merged? && !resolved_conflicts
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index f9ba77e87b5..e01aca3dda6 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -4,7 +4,7 @@
= render "projects/issues/head"
%div{ class: container_class }
- .detail-page-header
+ .detail-page-header.milestone-page-header
.status-box{ class: status_box_class(@milestone) }
- if @milestone.closed?
Closed
@@ -12,13 +12,14 @@
Past due
- else
Open
- %span.identifier
- Milestone ##{@milestone.iid}
- - if @milestone.expires_at
- %span.creator
- &middot;
- = @milestone.expires_at
- .pull-right
+ .header-text-content
+ %span.identifier
+ Milestone ##{@milestone.iid}
+ - if @milestone.expires_at
+ %span.creator
+ &middot;
+ = @milestone.expires_at
+ .milestone-buttons
- if can?(current_user, :admin_milestone, @project)
- if @milestone.active?
= link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index a5df502d7b5..baa6d5f8206 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -13,4 +13,4 @@
= render 'projects/issues/issue', issue: issue
= paginate @issues, theme: "gitlab"
- else
- .nothing-here-block No issues to show
+ = render 'shared/empty_states/issues'
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
new file mode 100644
index 00000000000..e939278bc07
--- /dev/null
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -0,0 +1,22 @@
+- button_path = local_assigns.fetch(:button_path, false)
+- project_select_button = local_assigns.fetch(:project_select_button, false)
+- has_button = button_path || project_select_button
+
+.row.empty-state
+ .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .svg-content
+ = render 'shared/empty_states/icons/issues.svg'
+ .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .text-content
+ - if has_button
+ %h4
+ The Issue Tracker is a good place to add things that need to be improved or solved in a project!
+ %p
+ An issue can be a bug, a todo or a feature request that needs to be discussed in a project.
+ Besides, issues are searchable and filterable.
+ - if project_select_button
+ = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue'
+ - else
+ = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
+ - else
+ %h4.text-center There are no issues to show.
diff --git a/app/views/shared/empty_states/icons/_issues.svg b/app/views/shared/empty_states/icons/_issues.svg
new file mode 100644
index 00000000000..2e92bf19579
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_issues.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="790 253 425 254" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="25" height="8.9423" x="25" y="88.4231" rx="2"/><mask id="h" width="25" height="8.9423" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M16 29.8013h43V91.404H16z"/><mask id="i" width="43" height="61.6026" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M57 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5747 24.863c.1564 1.0866-.253 1.2572-.912.384L66 86.436l-9-6.9552"/><mask id="j" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><path id="d" d="M.2496 60.6026l13.1868 9.3587c.449.3188.876 1.0142.9556 1.5673l3.5748 24.863c.1562 1.0866-.2532 1.2572-.9123.384L9.2495 86.436l-9-6.9552"/><mask id="k" width="17.7504" height="36.7306" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><path id="e" d="M16 29.8013L35.786 1.4556c.9466-1.3562 2.4792-1.3594 3.428 0L59 29.8013"/><mask id="l" width="43" height="29.364" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><rect id="f" width="26.2653" height="35.5088" x="6.3673" rx="13.1327"/><mask id="m" width="26.2653" height="35.5088" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><rect id="g" width="16.8367" height="22.386" x="4.0816" rx="8.4184"/><mask id="n" width="16.8367" height="22.386" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(792.000000, 255.000000)"><g fill="#FDE5D8"><path d="M225.4372 59.5866c-.059.5897-.1323 1.2698-.2203 2.0305-.252 2.1764-.5717 4.559-.9653 7.07-.1283.8185.4312 1.586 1.2496 1.7143.8185.1283 1.586-.4312 1.7142-1.2497.4-2.5528.7253-4.975.9815-7.1898.0898-.7762.1646-1.4715.2252-2.0762.0366-.365.0604-.62.0722-.7557.0717-.8254-.539-1.5526-1.3645-1.6244-.8254-.0717-1.5526.539-1.6244 1.3645-.0106.1228-.0332.365-.0684.7166zM219.8738 87.9413c-.2563.7878.1745 1.6342.9622 1.8906.7878.2562 1.6342-.1745 1.8906-.9623.975-2.9962 1.849-6.2827 2.6287-9.797.1794-.8086-.3308-1.6097-1.1395-1.789-.8088-.1795-1.61.3306-1.7893 1.1394-.76 3.4256-1.6096 6.6206-2.5527 9.5183zM209.9266 103.166c-.781.2766-1.1897 1.134-.913 1.9148.2765.781 1.1338 1.1897 1.9147.913 2.9792-1.0552 5.5414-3.679 7.7796-7.6272.4084-.7207.1554-1.636-.5653-2.0447-.7207-.4086-1.636-.1556-2.0446.565-1.9152 3.3786-3.9945 5.508-6.1714 6.279zM190.439 107.5834c-.7636.3214-1.122 1.201-.8005 1.9645.3215.7634 1.201 1.1217 1.9645.8003 3.1204-1.314 6.2717-2.3243 9.258-2.9816.809-.178 1.3205-.9783 1.1424-1.7874-.178-.809-.9783-1.3205-1.7874-1.1424-3.1666.697-6.4914 1.763-9.777 3.1464zM173.231 118.6257c-.6005.5706-.6248 1.52-.0542 2.1206s1.52.625 2.1206.0543c2.282-2.1682 4.8656-4.162 7.6758-5.946.6994-.444.9064-1.371.4624-2.0704-.444-.6994-1.371-.9064-2.0704-.4624-2.9698 1.8854-5.707 3.998-8.1342 6.304zM162.4543 136.2492c-.2022.8034.2852 1.6185 1.0885 1.8207.8034.202 1.6186-.2853 1.8208-1.0886.7688-3.0547 2.0416-5.9768 3.781-8.7486.4403-.7018.2284-1.6276-.4733-2.068-.7017-.4402-1.6275-.2283-2.068.4734-1.9026 3.0322-3.3016 6.2438-4.149 9.611zM162.1894 156.693c.1036.822.854 1.4042 1.676 1.3006.8218-.1037 1.404-.854 1.3004-1.676-.367-2.9097-.5796-6.1364-.6444-9.8167-.0146-.8284-.698-1.488-1.5262-1.4734-.8283.0146-1.488.698-1.4733 1.5262.0665 3.783.286 7.1162.6674 10.1393zM168.408 176.1653c.3876.7322 1.2953 1.0117 2.0275.6242.7322-.3875 1.0117-1.2952.6242-2.0274-1.6733-3.162-2.9028-5.9954-3.8477-8.943-.2528-.789-1.0973-1.2235-1.8862-.9706-.789.2528-1.2234 1.0974-.9706 1.8863 1.0025 3.1275 2.3014 6.121 4.053 9.4306zM175.9738 188.9357c1.056 1.7165 1.8892 3.0806 2.7307 4.474.4283.709 1.3503.9368 2.0595.5085.709-.4283.9368-1.3503.5085-2.0595-.8464-1.4014-1.6836-2.772-2.7434-4.4948.0808.131-1.9545-3.1733-2.486-4.0405-.4328-.7063-1.3563-.928-2.0627-.495-.7063.4327-.928 1.3563-.495 2.0626.5334.8707 2.5708 4.1785 2.4885 4.0447zM184.83 211.3822c.011.8284.6912 1.491 1.5196 1.4803.8283-.0108 1.491-.691 1.4803-1.5194-.046-3.519-.6604-6.996-1.8367-10.3262-.276-.7812-1.1328-1.1908-1.914-.915-.781.276-1.1906 1.133-.9147 1.914 1.0668 3.0206 1.624 6.1733 1.6655 9.3664zM179.3467 229.4095c-.459.6896-.2723 1.6208.4173 2.08.6896.459 1.6208.272 2.08-.4175 1.966-2.9533 3.4756-6.124 4.4877-9.4165.2434-.7918-.2012-1.631-.993-1.8745-.792-.2434-1.6312.2012-1.8746.993-.9264 3.014-2.3108 5.922-4.1173 8.6355z"/></g><g transform="translate(336.866969, 147.225953) rotate(-300.000000) translate(-336.866969, -147.225953) translate(299.366969, 69.725953)"><path stroke="#FDE5D8" stroke-width="3" d="M19 154l10-52.6603m16 0L55 154" stroke-linecap="round"/><rect width="3" height="38.75" x="35" y="99.3526" fill="#FDE5D8" rx="1.5"/><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#h)" xlink:href="#a"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#i)" xlink:href="#b"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#j)" xlink:href="#c"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#k)" transform="translate(9.124810, 78.967887) scale(-1, 1) translate(-9.124810, -78.967887)" xlink:href="#d"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#l)" xlink:href="#e"/><ellipse cx="28.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="34.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="40.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="46.5" cy="82.9583" fill="#FC8A51" rx="1.5" ry="1.4904"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="10.5" ry="10.4327"/><ellipse cx="37.5" cy="55.1378" stroke="#FDE5D8" stroke-width="3" rx="5.5" ry="5.4647"/></g><path fill="#EEE" d="M96.0426 37.2106c-.1512 1.6874.0814 3.815.997 6.146.2046.5207.7936.7774 1.3155.5733.522-.2043.7793-.792.5747-1.313-.7912-2.0142-.99-3.832-.865-5.226.0102-.1143.0195-.186.0238-.2113.092-.552-.2814-1.0738-.8344-1.1658-.553-.092-1.076.2808-1.168.8326-.0126.075-.0285.1975-.0434.364zM107.5302 52.8934c.4913.239 1.098.0626 1.355-.394.2572-.4566.0674-1.0205-.4238-1.2595-1.8668-.9083-3.4584-1.9152-4.7943-3.0075-.4162-.3404-1.0506-.3026-1.4168.0843-.3663.387-.3256.9766.0907 1.317 1.4583 1.1925 3.1828 2.2835 5.1893 3.2596zM120.661 58.9533c.5467.171 1.1257-.1425 1.2933-.7003.1675-.5577-.1397-1.1484-.6864-1.3194-3.0283-.9472-4.1984-1.3178-5.915-1.8824-.544-.179-1.1274.126-1.3028.6813-.1754.5552.1235 1.1504.6677 1.3294 1.729.5686 2.9053.941 5.943 1.8913zM132.5954 62.881c.449.246 1.022.0983 1.2798-.33.258-.4282.103-.975-.3458-1.221-1.4942-.819-3.1928-1.545-5.2675-2.2746-.486-.1708-1.025.0664-1.204.53-.179.4634.0697.9776.5555 1.1484 1.9832.6973 3.5892 1.3838 4.982 2.1472zM141.9774 73.383c.205.4938.809.742 1.3485.5543.5395-.1878.8106-.7404.6055-1.2344-.8504-2.0482-1.853-3.7962-3.0375-5.3046-.337-.429-.99-.527-1.4588-.2184-.4687.3085-.5755.9064-.2386 1.3354 1.0743 1.368 1.9926 2.9692 2.7808 4.8675zM144.609 87.025c.0183.5535.5682.99 1.2283.9746.66-.0153 1.1805-.4764 1.1622-1.03-.0725-2.2033-.2693-4.206-.622-6.1198-.1008-.5473-.7115-.9225-1.3642-.838-.6526.0846-1.1.597-.999 1.1442.336 1.8248.5248 3.745.5947 5.869z"/><path fill="#E5E5E5" d="M144.1423 95.7297c-.0863 2.5442-.1214 3.769-.1422 5.2548-.0076.5523.3963 1.007.9022 1.0154.506.0083.9223-.4326.93-.985.0205-1.4668.0554-2.6812.1412-5.2113l.026-.7667c.0185-.552-.3764-1.016-.882-1.0363-.5056-.0203-.9306.411-.949.963l-.026.766zM144.939 115.201c.1196.5447.6727.8925 1.2355.7768.5628-.1157.922-.651.8026-1.1957-.417-1.9-.7104-3.84-.8976-5.8637-.0513-.5545-.5574-.964-1.1305-.9142-.573.0497-.996.5396-.9448 1.0942.1944 2.1015.4998 4.121.9348 6.103zM149.995 127.5248c.296.454.9528.61 1.4668.3485.514-.2614.6907-.8413.3947-1.2952-1.0787-1.6535-2.0046-3.3145-2.7896-4.9916-.2266-.484-.8547-.7143-1.403-.5142-.548.2-.809.7546-.5823 1.2387.8208 1.7534 1.788 3.4886 2.9134 5.2138zM154.8088 135.226c1.0587 1.232 2.242 2.4097 3.543 3.531.404.3482 1.0276.3186 1.393-.066.3657-.3843.3346-.978-.0692-1.3262-1.2296-1.0597-2.345-2.17-3.3402-3.328-.195-.227-.3872-.4542-.5764-.6813-.3385-.4063-.9588-.4744-1.3856-.1522-.4267.3223-.4983.913-.1598 1.3192.1954.2346.3938.469.5952.7034zM170.634 146.9026c.4806.242 1.0517.0176 1.2758-.501.224-.5188.0162-1.1354-.4642-1.3773-1.7563-.8842-3.422-1.8432-4.9857-2.8726-.4527-.298-1.0434-.1435-1.3195.3452-.276.4885-.133 1.126.3198 1.424 1.6256 1.0704 3.354 2.0655 5.1738 2.9816z"/><path fill="#EEE" d="M184.7334 151.9698c.5527.1412 1.1072-.2262 1.2385-.8206.1312-.5944-.2104-1.1908-.763-1.332-2.001-.5114-3.9602-1.1002-5.8632-1.763-.5405-.1883-1.1205.1303-1.2955.7115-.175.5813.1212 1.205.6616 1.3934 1.9557.6813 3.9676 1.286 6.0214 1.8108zM197.9337 153.9977c.5532.04 1.0297-.445 1.0643-1.083.0346-.6383-.3857-1.188-.939-1.228-1.973-.1424-3.952-.3682-5.9206-.676-.5492-.086-1.0547.358-1.1292.9917-.0744.6336.3105 1.2168.8597 1.3027 2.0164.3154 4.0433.5467 6.0647.6927zM212.1213 152.6062c.5493-.055.9392-.4576.871-.8994-.0684-.442-.569-.7555-1.1184-.7006-1.9168.1917-3.893.3194-5.9104.382-.553.0173-.9842.392-.9628.8368.0213.445.487.7916 1.0402.7744 2.0737-.0645 4.1064-.1957 6.0803-.3932zM226.3665 149.949c.5293-.22.7755-.8162.5497-1.332-.2257-.5155-.838-.7553-1.3672-.5354-1.7815.74-3.7143 1.3827-5.7772 1.923-.5558.1454-.8852.7023-.7358 1.2436.1494.5414.721.8623 1.2768.7168 2.1547-.5643 4.1797-1.2376 6.0537-2.016zM237.8486 140.4168c.292-.4344.1488-1.006-.3202-1.2766-.469-.2706-1.086-.1378-1.3782.2967-.9575 1.4237-2.225 2.7337-3.7847 3.9202-.427.3248-.4888.9087-.138 1.3042.3505.3955.981.4528 1.408.128 1.723-1.3107 3.1363-2.7714 4.213-4.3726zM245.6725 130.6874c.3987-.3503.439-.9587.09-1.3588-.3492-.4-.9554-.4405-1.3542-.0902-1.5048 1.3222-2.8978 2.7094-4.1698 4.1635-.3497.3995-.3102 1.008.088 1.3587.3983.3508 1.0046.3113 1.3542-.0884 1.2153-1.389 2.5487-2.717 3.9918-3.985zM257.4814 122.8697c.476-.2568.657-.8577.4047-1.342-.2523-.4843-.8428-.6687-1.3188-.4118-1.7682.9542-3.4795 1.973-5.1228 3.0587-.4518.2985-.5803.9133-.287 1.373.2934.46.8975.5906 1.3494.292 1.5938-1.0528 3.2557-2.0423 4.9746-2.97zM270.276 116.9216c.5503-.1682.8513-.724.6723-1.241-.179-.5173-.77-.8003-1.3204-.632-1.9296.5898-3.932 1.2728-5.975 2.054-.536.205-.7936.7797-.5754 1.2835.218.504.8294.746 1.3654.541 1.9947-.7628 3.95-1.4298 5.833-2.0054z"/><circle cx="145" cy="90" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><circle cx="238" cy="138" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><path stroke="#B5A7DD" stroke-width="3" d="M20.0605 56s-17.4698 33-12 53c5.4697 20 17 32 38 44S78.5 148 107 159s29 43 29 43" stroke-linecap="round" stroke-dasharray="8 10"/><g stroke="#EEE" stroke-width="3" transform="translate(108.000000, 173.000000)"><path fill="#FFF" d="M154 77c0-42.526-34.474-77-77-77S0 34.474 0 77" stroke-linecap="round"/><circle cx="108" cy="41" r="16"/><circle cx="42.5" cy="30.5" r="8.5"/><circle cx="22" cy="58" r="5"/></g><g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(19.897959, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g fill="#FC8A51" transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(12.602041, 6.000000) scale(-1, 1) translate(-12.602041, -6.000000) translate(6.102041, -0.000000)"><path d="M.398 11.2982h2.3877c0-4.234 3.3853-7.6666 7.5612-7.6666v-2.421C4.8522 1.2105.398 5.727.398 11.298z"/><ellipse cx="10.7449" cy="2.0175" rx="1.9898" ry="2.0175"/></g><g transform="translate(235.917801, 27.746228) rotate(-345.000000) translate(-235.917801, -27.746228) translate(216.417801, 4.246228) translate(0.000000, 10.491228)"><g fill="#FC8A51" transform="translate(29.448980, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><g fill="#FC8A51" transform="translate(5.051020, 21.298246) scale(-1, 1) translate(-5.051020, -21.298246) translate(0.551020, 11.298246)"><rect width="7.9592" height="2" x=".7959" y="8.8772" rx="1"/><rect width="7.9592" height="2" x=".7959" y="16.1404" transform="translate(4.775510, 17.140351) rotate(-345.000000) translate(-4.775510, -17.140351)" rx="1"/><rect width="7.9592" height="2" x=".9151" y="1.8072" transform="translate(4.894667, 2.807217) rotate(-15.000000) translate(-4.894667, -2.807217)" rx="1"/></g><use stroke="#FC8A51" stroke-width="6" mask="url(#m)" xlink:href="#f"/><path fill="#FC8A51" d="M7.1633 12.9123H31.041v3H7.1632z"/></g></g><g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(12.755102, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g fill="#EEE" transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(7.744898, 4.000000) scale(-1, 1) translate(-7.744898, -4.000000) translate(3.244898, 0.000000)"><path d="M.255 7.1228h1.5307c0-2.6694 2.17-4.8333 4.847-4.8333V.7632C3.1104.7632.255 3.6105.255 7.1228z"/><ellipse cx="6.8878" cy="1.2719" rx="1.2755" ry="1.2719"/></g><g transform="translate(92.956359, 18.724125) scale(-1, 1) rotate(-345.000000) translate(-92.956359, -18.724125) translate(80.456359, 3.724125) translate(0.000000, 6.614035)"><g fill="#EEE" transform="translate(18.877551, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><g fill="#EEE" transform="translate(3.122449, 13.622807) scale(-1, 1) translate(-3.122449, -13.622807) translate(0.122449, 7.122807)"><rect width="5.102" height="2" x=".5102" y="5.5965" rx="1"/><rect width="5.102" height="2" x=".5102" y="10.1754" transform="translate(3.061224, 11.175439) rotate(-345.000000) translate(-3.061224, -11.175439)" rx="1"/><rect width="5.102" height="2" x=".5866" y="1.1393" transform="translate(3.137607, 2.139333) rotate(-15.000000) translate(-3.137607, -2.139333)" rx="1"/></g><use stroke="#EEE" stroke-width="4" mask="url(#n)" xlink:href="#g"/><path fill="#EEE" d="M4.5918 8.1404h15.306v2H4.592z"/></g></g><g fill="#FFF" transform="translate(0.000000, 103.000000)"><circle cx="8.5" cy="8.5" r="8.5" stroke="#B5A7DD" stroke-width="4"/><circle cx="171.5" cy="20.5" r="6.5"/></g><g><g transform="translate(39.000000, 142.000000)"><ellipse cx="12.5" cy="12.5" fill="#FFF" stroke="#6B4FBB" stroke-width="4" rx="12.5" ry="12.5"/><path fill="#FC8A51" d="M10.7322 13.475l-1.7665-1.7667c-.5873-.5873-1.5368-.587-2.1226-.0012-.5897.59-.585 1.5362.0013 2.1226l2.826 2.826.0007.0007.0006.0006c.5898.5897 1.534.587 2.118.003l6.3704-6.3703c.577-.577.5826-1.5323-.003-2.118-.59-.59-1.5343-.5873-2.1183-.0033l-5.3065 5.3065z"/></g></g><circle cx="171.5" cy="122.5" r="6.5" fill="#FFF" stroke="#FC8A51" stroke-width="3"/><circle cx="22" cy="52" r="6" fill="#FFF" stroke="#B5A7DD" stroke-width="3"/><path fill="#FFF" stroke="#B5A7DD" stroke-width="3.6" d="M188.151 141.596c8.7045-7.7456 11.0126-20.9255 4.8625-31.5777-7.0208-12.1604-22.4055-16.422-34.363-9.5183-11.9572 6.9036-15.959 22.358-8.9382 34.5183 6.2353 10.8 19.068 15.3695 30.2375 11.4206l10.8992 18.8778c1.3167 2.2807 4.2302 3.063 6.5078 1.748 2.273-1.3122 3.0567-4.2295 1.74-6.51l-10.9458-18.9587zm-8.4343-4.6086c7.8576-4.5366 10.4874-14.6923 5.8738-22.6834-4.6137-7.991-14.7237-10.7915-22.5814-6.255-7.8575 4.5368-10.4873 14.6925-5.8737 22.6836 4.6137 7.991 14.7237 10.7915 22.5814 6.2548z"/></g></svg>
diff --git a/app/views/shared/icons/_delta.svg b/app/views/shared/icons/_delta.svg
new file mode 100644
index 00000000000..7c0c0d3999c
--- /dev/null
+++ b/app/views/shared/icons/_delta.svg
@@ -0,0 +1,3 @@
+<svg width="14px" height="10px" viewBox="322 21 14 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <path d="M330.078605,22.8166945 L335.259532,29.6235062 C335.615145,30.0907182 335.412062,30.4694683 334.822641,30.4694683 L331.657805,30.4694683 L324.04678,30.4694683 C323.449879,30.4694683 323.260751,30.0822112 323.609889,29.6235062 L328.790816,22.8166945 C329.146429,22.3494825 329.729467,22.3579895 330.078605,22.8166945 Z" id="delta" stroke="#5C5C5C" stroke-width="1" fill="none"></path>
+</svg>
diff --git a/app/views/shared/icons/_icon_cycle_analytics_overview.svg b/app/views/shared/icons/_icon_cycle_analytics_overview.svg
new file mode 100644
index 00000000000..eea9c975c35
--- /dev/null
+++ b/app/views/shared/icons/_icon_cycle_analytics_overview.svg
@@ -0,0 +1,81 @@
+<svg width="366px" height="229px" viewBox="784 258 366 229" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 41 (35326) - http://www.bohemiancoding.com/sketch -->
+ <desc>Created with Sketch.</desc>
+ <defs>
+ <rect id="path-1" x="35" y="39" width="24" height="21" rx="10"></rect>
+ <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="24" height="21" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ <rect id="path-3" x="64.8662386" y="58.3882666" width="10" height="71" rx="5"></rect>
+ <mask id="mask-4" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="71" fill="white">
+ <use xlink:href="#path-3"></use>
+ </mask>
+ <rect id="path-5" x="18.1550472" y="58.3882666" width="10" height="71" rx="5"></rect>
+ <mask id="mask-6" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="71" fill="white">
+ <use xlink:href="#path-5"></use>
+ </mask>
+ <rect id="path-7" x="24" y="56" width="46" height="10" rx="5"></rect>
+ <mask id="mask-8" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="46" height="10" fill="white">
+ <use xlink:href="#path-7"></use>
+ </mask>
+ <rect id="path-9" x="42" y="60" width="10" height="68" rx="5"></rect>
+ <mask id="mask-10" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="10" height="68" fill="white">
+ <use xlink:href="#path-9"></use>
+ </mask>
+ <rect id="path-11" x="69" y="12" width="12" height="12" rx="3"></rect>
+ <mask id="mask-12" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="12" height="12" fill="white">
+ <use xlink:href="#path-11"></use>
+ </mask>
+ <rect id="path-13" x="40" y="18" width="14" height="22" rx="6"></rect>
+ <mask id="mask-14" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="14" height="22" fill="white">
+ <use xlink:href="#path-13"></use>
+ </mask>
+ <rect id="path-15" x="41" y="8" width="34" height="20" rx="3"></rect>
+ <mask id="mask-16" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="34" height="20" fill="white">
+ <use xlink:href="#path-15"></use>
+ </mask>
+ <path d="M8,8.00793008 C8,6.34669617 9.34984627,5.0321392 11.0036812,5.07151622 L46.9963188,5.92848378 C48.6552061,5.9679811 50,7.34177063 50,8.99109042 L50,27.0089096 C50,28.6608432 48.6501537,30.0321392 46.9963188,30.0715162 L11.0036812,30.9284838 C9.34479389,30.9679811 8,29.6568766 8,27.9920699 L8,8.00793008 Z" id="path-17"></path>
+ <mask id="mask-18" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="42" height="25.858699" fill="white">
+ <use xlink:href="#path-17"></use>
+ </mask>
+ <rect id="path-19" x="-7.10542736e-15" y="1.77635684e-14" width="16" height="36" rx="3"></rect>
+ <mask id="mask-20" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="16" height="36" fill="white">
+ <use xlink:href="#path-19"></use>
+ </mask>
+ </defs>
+ <g id="Group-7" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(786.000000, 259.000000)">
+ <g id="Group-5" transform="translate(132.727922, 71.000000)">
+ <use id="Rectangle-21" stroke="#EEEEEE" mask="url(#mask-2)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-1"></use>
+ <use id="Rectangle-16-Copy" stroke="#EEEEEE" mask="url(#mask-4)" stroke-width="8" fill="#FFFFFF" transform="translate(69.866239, 93.888267) rotate(-20.000000) translate(-69.866239, -93.888267) " xlink:href="#path-3"></use>
+ <use id="Rectangle-16-Copy-2" stroke="#EEEEEE" mask="url(#mask-6)" stroke-width="8" fill="#FFFFFF" transform="translate(23.155047, 93.888267) scale(-1, 1) rotate(-20.000000) translate(-23.155047, -93.888267) " xlink:href="#path-5"></use>
+ <use id="Rectangle-15" stroke="#EEEEEE" mask="url(#mask-8)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-7"></use>
+ <use id="Rectangle-16" stroke="#EEEEEE" mask="url(#mask-10)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-9"></use>
+ <g id="Group" transform="translate(45.500000, 33.000000) rotate(20.000000) translate(-45.500000, -33.000000) translate(5.000000, 13.000000)">
+ <use id="Rectangle-4" stroke="#EEEEEE" mask="url(#mask-12)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-11"></use>
+ <use id="Rectangle-20" stroke="#EEEEEE" mask="url(#mask-14)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-13"></use>
+ <use id="Rectangle-2" stroke="#EEEEEE" mask="url(#mask-16)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-15"></use>
+ <use id="Rectangle" stroke="#EEEEEE" mask="url(#mask-18)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-17"></use>
+ <rect id="Rectangle-17" fill="#EEEEEE" x="21" y="7" width="3" height="22"></rect>
+ <rect id="Rectangle-17-Copy" fill="#EEEEEE" x="64" y="8" width="3" height="17"></rect>
+ <circle id="Oval-9" fill="#B5A7DD" cx="40" cy="18" r="2"></circle>
+ <circle id="Oval-9-Copy-4" fill="#EEEEEE" cx="47" cy="33" r="2"></circle>
+ <use id="Rectangle-19" stroke="#EEEEEE" mask="url(#mask-20)" stroke-width="8" fill="#FFFFFF" xlink:href="#path-19"></use>
+ </g>
+ </g>
+ <path d="M265.128496,225.286991 C247.289192,194.617726 214.068171,174 176.031622,174 C137.847583,174 104.51649,194.77793 86.7279221,225.644211" id="Oval-10" stroke="#EEEEEE" stroke-width="4" stroke-linecap="round" fill="#FFFFFF"></path>
+ <circle id="Oval-11" stroke="#FDE5D8" stroke-width="4" fill="#FFFFFF" cx="24.5" cy="25.5" r="24.5"></circle>
+ <path d="M24,1.00292933 C24,0.449026756 24.4438648,0 25,0 C25.5522847,0 26,0.437881351 26,1.00292933 L26,5.99707067 C26,6.55097324 25.5561352,7 25,7 C24.4477153,7 24,6.56211865 24,5.99707067 L24,1.00292933 Z M48.46461,17.3244238 C48.9914026,17.1532585 49.5556142,17.4366422 49.7274694,17.9655581 C49.8981348,18.4908122 49.6200365,19.0519274 49.0826439,19.2265369 L44.3329333,20.7698114 C43.8061406,20.9409767 43.241929,20.6575931 43.0700738,20.1286771 C42.8994084,19.6034231 43.1775067,19.0423078 43.7148993,18.8676984 L48.46461,17.3244238 Z M40.5019265,45.6352697 C40.8275022,46.0833863 40.7323394,46.7075538 40.2824166,47.0344419 C39.8356088,47.3590667 39.2160194,47.2679737 38.8838925,46.8108402 L35.9484099,42.770495 C35.6228341,42.3223784 35.717997,41.6982109 36.1679198,41.3713229 C36.6147275,41.0466981 37.234317,41.1377911 37.5664439,41.5949245 L40.5019265,45.6352697 Z M11.1161075,46.8108402 C10.7905317,47.2589568 10.1675063,47.3613299 9.71758344,47.0344419 C9.27077569,46.709817 9.16594665,46.0924031 9.49807352,45.6352697 L12.4335561,41.5949245 C12.7591319,41.1468079 13.3821574,41.0444348 13.8320802,41.3713229 C14.278888,41.6959477 14.383717,42.3133616 14.0515901,42.770495 L11.1161075,46.8108402 Z M0.917356057,19.2265369 C0.390563404,19.0553716 0.100675355,18.4944741 0.272530576,17.9655581 C0.44319595,17.4403041 0.997997482,17.1498144 1.53539005,17.3244238 L6.28510071,18.8676984 C6.81189336,19.0388637 7.10178141,19.5997611 6.92992619,20.1286771 C6.75926082,20.6539311 6.20445928,20.9444208 5.66706672,20.7698114 L0.917356057,19.2265369 Z" id="Rectangle-23" fill="#FDE5D8"></path>
+ <rect id="Rectangle-18" fill="#FC6D26" x="24" y="14" width="3" height="12" rx="1.5"></rect>
+ <rect id="Rectangle-22" fill="#FC6D26" x="24" y="24" width="12" height="3" rx="1.5"></rect>
+ <circle id="Oval-11" fill="#6B4FBB" cx="25.5" cy="25.5" r="2.5"></circle>
+ <path d="M358.949747,6.87474747 L357.453009,7.20729654 C356.9128,7.32732164 356.570654,6.9935311 356.692198,6.44648557 L357.024747,4.94974747 L356.692198,3.45300937 C356.572173,2.91279997 356.905964,2.57065443 357.453009,2.69219839 L358.949747,3.02474747 L360.446486,2.69219839 C360.986695,2.5721733 361.328841,2.90596384 361.207297,3.45300937 L360.874747,4.94974747 L361.207297,6.44648557 C361.327322,6.98669496 360.993531,7.32884051 360.446486,7.20729654 L358.949747,6.87474747 Z" id="Star-Copy-5" fill="#6B4FBB" transform="translate(358.949747, 4.949747) rotate(-315.000000) translate(-358.949747, -4.949747) "></path>
+ <path d="M113.949747,32.8747475 L112.453009,33.2072965 C111.9128,33.3273216 111.570654,32.9935311 111.692198,32.4464856 L112.024747,30.9497475 L111.692198,29.4530094 C111.572173,28.9128 111.905964,28.5706544 112.453009,28.6921984 L113.949747,29.0247475 L115.446486,28.6921984 C115.986695,28.5721733 116.328841,28.9059638 116.207297,29.4530094 L115.874747,30.9497475 L116.207297,32.4464856 C116.327322,32.986695 115.993531,33.3288405 115.446486,33.2072965 L113.949747,32.8747475 Z" id="Star-Copy-7" fill="#B5A7DD" transform="translate(113.949747, 30.949747) rotate(-315.000000) translate(-113.949747, -30.949747) "></path>
+ <path d="M329.949747,211.874747 L328.453009,212.207297 C327.9128,212.327322 327.570654,211.993531 327.692198,211.446486 L328.024747,209.949747 L327.692198,208.453009 C327.572173,207.9128 327.905964,207.570654 328.453009,207.692198 L329.949747,208.024747 L331.446486,207.692198 C331.986695,207.572173 332.328841,207.905964 332.207297,208.453009 L331.874747,209.949747 L332.207297,211.446486 C332.327322,211.986695 331.993531,212.328841 331.446486,212.207297 L329.949747,211.874747 Z" id="Star-Copy-6" fill="#B5A7DD" opacity="0.5" transform="translate(329.949747, 209.949747) rotate(-315.000000) translate(-329.949747, -209.949747) "></path>
+ <path d="M265.363961,54.838961 L263.153969,55.3299826 C262.617155,55.4492534 262.280283,55.1035008 262.397939,54.5739526 L262.888961,52.363961 L262.397939,50.1539694 C262.278669,49.6171548 262.624421,49.2802831 263.153969,49.3979395 L265.363961,49.888961 L267.573953,49.3979395 C268.110767,49.2786686 268.447639,49.6244213 268.329983,50.1539694 L267.838961,52.363961 L268.329983,54.5739526 C268.449253,55.1107673 268.103501,55.4476389 267.573953,55.3299826 L265.363961,54.838961 Z" id="Star-Copy-9" fill="#FC6D26" transform="translate(265.363961, 52.363961) rotate(-315.000000) translate(-265.363961, -52.363961) "></path>
+ <path d="M56.363961,142.838961 L54.1539694,143.329983 C53.6171548,143.449253 53.2802831,143.103501 53.3979395,142.573953 L53.888961,140.363961 L53.3979395,138.153969 C53.2786686,137.617155 53.6244213,137.280283 54.1539694,137.397939 L56.363961,137.888961 L58.5739526,137.397939 C59.1107673,137.278669 59.4476389,137.624421 59.3299826,138.153969 L58.838961,140.363961 L59.3299826,142.573953 C59.4492534,143.110767 59.1035008,143.447639 58.5739526,143.329983 L56.363961,142.838961 Z" id="Star-Copy-8" fill="#6B4FBB" transform="translate(56.363961, 140.363961) rotate(-315.000000) translate(-56.363961, -140.363961) "></path>
+ <g id="Group-6" transform="translate(311.872633, 125.094458) rotate(-345.000000) translate(-311.872633, -125.094458) translate(290.872633, 115.094458)">
+ <circle id="Oval-12" stroke="#FDE5D8" stroke-width="4" fill="#FFFFFF" cx="21" cy="10" r="10"></circle>
+ <ellipse id="Oval-13" fill="#FDE5D8" cx="21" cy="10" rx="21" ry="2"></ellipse>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_lock.svg b/app/views/shared/icons/_icon_lock.svg
new file mode 100644
index 00000000000..6ec671a76ed
--- /dev/null
+++ b/app/views/shared/icons/_icon_lock.svg
@@ -0,0 +1,25 @@
+<svg width="46px" height="54px" viewBox="227 0 46 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 41 (35326) - http://www.bohemiancoding.com/sketch -->
+ <desc>Created with Sketch.</desc>
+ <defs>
+ <rect id="path-1" x="0" y="20" width="46" height="34" rx="8"></rect>
+ <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="46" height="34" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ <path d="M29,16 C29,8.2680135 22.7319865,2 15,2 C7.2680135,2 1,8.2680135 1,16" id="path-3"></path>
+ <mask id="mask-4" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="28" height="14" fill="white">
+ <use xlink:href="#path-3"></use>
+ </mask>
+ </defs>
+ <g id="locker" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(227.000000, 0.000000)">
+ <g id="Group-8">
+ <use id="Rectangle-14" stroke="#B5A7DD" mask="url(#mask-2)" stroke-width="6" xlink:href="#path-1"></use>
+ <g id="Group-7" transform="translate(8.000000, 0.000000)">
+ <use id="Oval-3" stroke="#B5A7DD" mask="url(#mask-4)" stroke-width="6" xlink:href="#path-3"></use>
+ <rect id="Rectangle-13" fill="#B5A7DD" x="1" y="16" width="3" height="6"></rect>
+ <rect id="Rectangle-13-Copy" fill="#B5A7DD" x="26" y="16" width="3" height="6"></rect>
+ </g>
+ <path d="M25,37.4648712 C26.1956027,36.7732524 27,35.4805647 27,34 C27,31.790861 25.209139,30 23,30 C20.790861,30 19,31.790861 19,34 C19,35.4805647 19.8043973,36.7732524 21,37.4648712 L21,41.0026083 C21,42.1041422 21.8954305,43 23,43 C24.1122704,43 25,42.1057373 25,41.0026083 L25,37.4648712 Z" id="Combined-Shape" fill="#6B4FBB"></path>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_no_data.svg b/app/views/shared/icons/_icon_no_data.svg
new file mode 100644
index 00000000000..ced8653b88c
--- /dev/null
+++ b/app/views/shared/icons/_icon_no_data.svg
@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="211 0 78 36" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <circle id="a" cx="5" cy="31" r="5"/>
+ <mask id="e" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <circle id="b" cx="29" cy="14" r="5"/>
+ <mask id="f" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#b"/>
+ </mask>
+ <circle id="c" cx="53" cy="24" r="5"/>
+ <mask id="g" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#c"/>
+ </mask>
+ <circle id="d" cx="73" cy="5" r="5"/>
+ <mask id="h" width="10" height="10" x="0" y="0" fill="#fff">
+ <use xlink:href="#d"/>
+ </mask>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(211)">
+ <path stroke="#B5A7DD" stroke-width="2" d="M5 31l24-17 26 10L73 5" stroke-linecap="round" stroke-dasharray="3 6"/>
+ <use fill="#FFF" stroke="#6B4FBB" stroke-width="6" mask="url(#e)" xlink:href="#a"/>
+ <use fill="#FFF" stroke="#6B4FBB" stroke-width="6" mask="url(#f)" xlink:href="#b"/>
+ <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#g)" xlink:href="#c"/>
+ <use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#h)" xlink:href="#d"/>
+ </g>
+</svg>
diff --git a/app/views/shared/milestones/_summary.html.haml b/app/views/shared/milestones/_summary.html.haml
index dee2472fa79..0a237136959 100644
--- a/app/views/shared/milestones/_summary.html.haml
+++ b/app/views/shared/milestones/_summary.html.haml
@@ -3,32 +3,38 @@
.context.prepend-top-default
.milestone-summary
%h4 Progress
- %strong= milestone.issues_visible_to_user(current_user).size
- issues:
- %span.milestone-stat
- %strong= milestone.issues_visible_to_user(current_user).opened.size
- open and
- %strong= milestone.issues_visible_to_user(current_user).closed.size
- closed
- %strong= milestone.merge_requests.size
- merge requests:
- %span.milestone-stat
- %strong= milestone.merge_requests.opened.size
- open and
- %strong= milestone.merge_requests.merged.size
- merged
- %span.milestone-stat
- %strong== #{milestone.percent_complete(current_user)}%
- complete
- %span.milestone-stat
- %span.remaining-days= milestone_remaining_days(milestone)
- %span.pull-right.tab-issues-buttons
- - if project && can?(current_user, :create_issue, project)
- = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn btn-grouped", title: "New Issue" do
- New Issue
- = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn btn-grouped"
- %span.pull-right.tab-merge-requests-buttons.hidden
- = link_to 'Browse Merge Requests', milestones_browse_issuables_path(milestone, type: :merge_requests), class: "btn btn-grouped"
+ .milestone-stats-and-buttons
+ .milestone-stats
+ %span.milestone-stat.with-drilldown
+ %strong= milestone.issues_visible_to_user(current_user).size
+ issues:
+ %span.milestone-stat
+ %strong= milestone.issues_visible_to_user(current_user).opened.size
+ open and
+ %strong= milestone.issues_visible_to_user(current_user).closed.size
+ closed
+ %span.milestone-stat.with-drilldown
+ %strong= milestone.merge_requests.size
+ merge requests:
+ %span.milestone-stat
+ %strong= milestone.merge_requests.opened.size
+ open and
+ %strong= milestone.merge_requests.merged.size
+ merged
+ %span.milestone-stat
+ %strong== #{milestone.percent_complete(current_user)}%
+ complete
+ %span.milestone-stat
+ %span.remaining-days= milestone_remaining_days(milestone)
- = milestone_progress_bar(milestone)
+ .milestone-progress-buttons
+ %span.tab-issues-buttons
+ - if project && can?(current_user, :create_issue, project)
+ = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn", title: "New Issue" do
+ New Issue
+ = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn"
+ %span.tab-merge-requests-buttons.hidden
+ = link_to 'Browse Merge Requests', milestones_browse_issuables_path(milestone, type: :merge_requests), class: "btn"
+
+ = milestone_progress_bar(milestone)
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 66574d0fd01..926162b8c53 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -2,7 +2,9 @@ class NewNoteWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
- def perform(note_id)
+ # Keep extra parameter to preserve backwards compatibility with
+ # old `NewNoteWorker` jobs (can remove later)
+ def perform(note_id, _params = {})
if note = Note.find_by(id: note_id)
NotificationService.new.new_note(note)
Notes::PostProcessService.new(note).execute
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 4dfa745fb50..27d7e652721 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -1,54 +1,38 @@
# Worker for updating any project specific caches.
-#
-# This worker runs at most once every 15 minutes per project. This is to ensure
-# that multiple instances of jobs for this worker don't hammer the underlying
-# storage engine as much.
class ProjectCacheWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
LEASE_TIMEOUT = 15.minutes.to_i
- def self.lease_for(project_id)
- Gitlab::ExclusiveLease.
- new("project_cache_worker:#{project_id}", timeout: LEASE_TIMEOUT)
- end
+ # project_id - The ID of the project for which to flush the cache.
+ # refresh - An Array containing extra types of data to refresh such as
+ # `:readme` to flush the README and `:changelog` to flush the
+ # CHANGELOG.
+ def perform(project_id, refresh = [])
+ project = Project.find_by(id: project_id)
- # Overwrite Sidekiq's implementation so we only schedule when actually needed.
- def self.perform_async(project_id)
- # If a lease for this project is still being held there's no point in
- # scheduling a new job.
- super unless lease_for(project_id).exists?
- end
+ return unless project && project.repository.exists?
- def perform(project_id)
- if try_obtain_lease_for(project_id)
- Rails.logger.
- info("Obtained ProjectCacheWorker lease for project #{project_id}")
- else
- Rails.logger.
- info("Could not obtain ProjectCacheWorker lease for project #{project_id}")
-
- return
- end
+ update_repository_size(project)
+ project.update_commit_count
- update_caches(project_id)
+ project.repository.refresh_method_caches(refresh.map(&:to_sym))
end
- def update_caches(project_id)
- project = Project.find(project_id)
+ def update_repository_size(project)
+ return unless try_obtain_lease_for(project.id, :update_repository_size)
- return unless project.repository.exists?
+ Rails.logger.info("Updating repository size for project #{project.id}")
project.update_repository_size
- project.update_commit_count
-
- if project.repository.root_ref
- project.repository.build_cache
- end
end
- def try_obtain_lease_for(project_id)
- self.class.lease_for(project_id).try_obtain
+ private
+
+ def try_obtain_lease_for(project_id, section)
+ Gitlab::ExclusiveLease.
+ new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT).
+ try_obtain
end
end
diff --git a/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml b/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml
new file mode 100644
index 00000000000..b8b8810ecfa
--- /dev/null
+++ b/changelogs/unreleased/18136-ui-for-restricting-global-visibility-levels-is-unclear.yml
@@ -0,0 +1,4 @@
+---
+title: Changed restricted visibility admin buttons to checkboxes
+merge_request: 7463
+author:
diff --git a/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml b/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml
new file mode 100644
index 00000000000..5140c09be8a
--- /dev/null
+++ b/changelogs/unreleased/23449-cycle-analytics-2-frontend.yml
@@ -0,0 +1,4 @@
+---
+title: Show events per stage on Cycle Analytics page
+merge_request: 23449
+author:
diff --git a/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml b/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml
new file mode 100644
index 00000000000..53dcc2a82f1
--- /dev/null
+++ b/changelogs/unreleased/24499-fix-activity-autoload-on-large-viewports.yml
@@ -0,0 +1,4 @@
+---
+title: Fix activity page endless scroll on large viewports
+merge_request: 7608
+author:
diff --git a/changelogs/unreleased/chatops-deploy-command.yml b/changelogs/unreleased/chatops-deploy-command.yml
new file mode 100644
index 00000000000..1e5a3e8df15
--- /dev/null
+++ b/changelogs/unreleased/chatops-deploy-command.yml
@@ -0,0 +1,4 @@
+---
+title: Add deployment command to ChatOps
+merge_request: 7619
+author:
diff --git a/changelogs/unreleased/dz-fix-500-group-git.yml b/changelogs/unreleased/dz-fix-500-group-git.yml
new file mode 100644
index 00000000000..38e80ad8bde
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-500-group-git.yml
@@ -0,0 +1,4 @@
+---
+title: Fix 500 error when group name ends with git
+merge_request: 7630
+author:
diff --git a/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml b/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml
new file mode 100644
index 00000000000..db9bb2bc31f
--- /dev/null
+++ b/changelogs/unreleased/feature-send-registry-address-with-build-payload.yml
@@ -0,0 +1,4 @@
+---
+title: Send credentials (currently for registry only) with build data to GitLab Runner
+merge_request: 7474
+author:
diff --git a/changelogs/unreleased/fix-ci-linter-undefined-error.yml b/changelogs/unreleased/fix-ci-linter-undefined-error.yml
new file mode 100644
index 00000000000..229970b79c0
--- /dev/null
+++ b/changelogs/unreleased/fix-ci-linter-undefined-error.yml
@@ -0,0 +1,4 @@
+---
+title: Fix undefined error in CI linter
+merge_request: 7650
+author:
diff --git a/changelogs/unreleased/fix-cycle-analytics-permissions.yml b/changelogs/unreleased/fix-cycle-analytics-permissions.yml
new file mode 100644
index 00000000000..ddcf78d705f
--- /dev/null
+++ b/changelogs/unreleased/fix-cycle-analytics-permissions.yml
@@ -0,0 +1,4 @@
+---
+title: Added permissions per stage to cycle analytics endpoint
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml b/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml
deleted file mode 100644
index a6bee989f6d..00000000000
--- a/changelogs/unreleased/fix-merge-request-screen-deleted-source-branch.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Do not create a MergeRequestDiff record when source branch is deleted
-merge_request: 7481
-author:
diff --git a/changelogs/unreleased/hide-empty-merge-request-diffs.yml b/changelogs/unreleased/hide-empty-merge-request-diffs.yml
new file mode 100644
index 00000000000..e32a51b555a
--- /dev/null
+++ b/changelogs/unreleased/hide-empty-merge-request-diffs.yml
@@ -0,0 +1,4 @@
+---
+title: Fix errors happening when source branch of merge request is removed and then restored
+merge_request: 7568
+author:
diff --git a/changelogs/unreleased/issue_24303.yml b/changelogs/unreleased/issue_24303.yml
new file mode 100644
index 00000000000..1f007712732
--- /dev/null
+++ b/changelogs/unreleased/issue_24303.yml
@@ -0,0 +1,4 @@
+---
+title: Fix JIRA references for project snippets
+merge_request:
+author:
diff --git a/changelogs/unreleased/remove-require-from-services.yml b/changelogs/unreleased/remove-require-from-services.yml
new file mode 100644
index 00000000000..400512e0314
--- /dev/null
+++ b/changelogs/unreleased/remove-require-from-services.yml
@@ -0,0 +1,4 @@
+---
+title: 'Remove unnecessary require_relative calls from service classes'
+merge_request: '7601'
+author: Semyon Pupkov
diff --git a/changelogs/unreleased/smarter-cache-invalidation.yml b/changelogs/unreleased/smarter-cache-invalidation.yml
new file mode 100644
index 00000000000..14a93e26ac4
--- /dev/null
+++ b/changelogs/unreleased/smarter-cache-invalidation.yml
@@ -0,0 +1,4 @@
+---
+title: Rework cache invalidation so only changed data is refreshed
+merge_request: 7360
+author:
diff --git a/doc/development/README.md b/doc/development/README.md
index 371bb55c127..6f2ca7b8590 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -38,6 +38,7 @@
- [Rake tasks](rake_tasks.md) for development
- [Shell commands](shell_commands.md) in the GitLab codebase
- [Sidekiq debugging](sidekiq_debugging.md)
+- [Object state models](object_state_models.md)
## Databases
diff --git a/doc/development/img/state-model-issue.png b/doc/development/img/state-model-issue.png
new file mode 100644
index 00000000000..c85fffc2a3a
--- /dev/null
+++ b/doc/development/img/state-model-issue.png
Binary files differ
diff --git a/doc/development/img/state-model-legend.png b/doc/development/img/state-model-legend.png
new file mode 100644
index 00000000000..088230bfc39
--- /dev/null
+++ b/doc/development/img/state-model-legend.png
Binary files differ
diff --git a/doc/development/img/state-model-merge-request.png b/doc/development/img/state-model-merge-request.png
new file mode 100644
index 00000000000..0e7556784f4
--- /dev/null
+++ b/doc/development/img/state-model-merge-request.png
Binary files differ
diff --git a/doc/development/object_state_models.md b/doc/development/object_state_models.md
new file mode 100644
index 00000000000..623bbf143ef
--- /dev/null
+++ b/doc/development/object_state_models.md
@@ -0,0 +1,25 @@
+# Object state models
+
+## Diagrams
+
+[GitLab object state models](https://drive.google.com/drive/u/3/folders/0B5tDlHAM4iZINmpvYlJXcDVqMGc)
+
+---
+
+## Legend
+
+![legend](img/state-model-legend.png)
+
+---
+
+## Issue
+
+[`app/models/issue.rb`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/models/issue.rb)
+![issue](img/state-model-issue.png)
+
+---
+
+## Merge request
+
+[`app/models/merge_request.rb`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/models/merge_request.rb)
+![merge request](img/state-model-merge-request.png) \ No newline at end of file
diff --git a/doc/project_services/img/mattermost_add_slash_command.png b/doc/project_services/img/mattermost_add_slash_command.png
new file mode 100644
index 00000000000..6d45bce8004
--- /dev/null
+++ b/doc/project_services/img/mattermost_add_slash_command.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_bot_auth.png b/doc/project_services/img/mattermost_bot_auth.png
new file mode 100644
index 00000000000..19c4735194f
--- /dev/null
+++ b/doc/project_services/img/mattermost_bot_auth.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_bot_available_commands.png b/doc/project_services/img/mattermost_bot_available_commands.png
new file mode 100644
index 00000000000..f912a639cc5
--- /dev/null
+++ b/doc/project_services/img/mattermost_bot_available_commands.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_config_help.png b/doc/project_services/img/mattermost_config_help.png
new file mode 100644
index 00000000000..3e38bf0abc6
--- /dev/null
+++ b/doc/project_services/img/mattermost_config_help.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_console_integrations.png b/doc/project_services/img/mattermost_console_integrations.png
new file mode 100644
index 00000000000..eecec0950a8
--- /dev/null
+++ b/doc/project_services/img/mattermost_console_integrations.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_gitlab_token.png b/doc/project_services/img/mattermost_gitlab_token.png
new file mode 100644
index 00000000000..3f4f26aab35
--- /dev/null
+++ b/doc/project_services/img/mattermost_gitlab_token.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_goto_console.png b/doc/project_services/img/mattermost_goto_console.png
new file mode 100644
index 00000000000..3576758b331
--- /dev/null
+++ b/doc/project_services/img/mattermost_goto_console.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_slash_command_configuration.png b/doc/project_services/img/mattermost_slash_command_configuration.png
new file mode 100644
index 00000000000..06416b0d068
--- /dev/null
+++ b/doc/project_services/img/mattermost_slash_command_configuration.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_slash_command_token.png b/doc/project_services/img/mattermost_slash_command_token.png
new file mode 100644
index 00000000000..320e263026a
--- /dev/null
+++ b/doc/project_services/img/mattermost_slash_command_token.png
Binary files differ
diff --git a/doc/project_services/img/mattermost_team_integrations.png b/doc/project_services/img/mattermost_team_integrations.png
new file mode 100644
index 00000000000..9086cf1c136
--- /dev/null
+++ b/doc/project_services/img/mattermost_team_integrations.png
Binary files differ
diff --git a/doc/project_services/mattermost_slash_commands.md b/doc/project_services/mattermost_slash_commands.md
new file mode 100644
index 00000000000..1507dfa3abd
--- /dev/null
+++ b/doc/project_services/mattermost_slash_commands.md
@@ -0,0 +1,157 @@
+# Mattermost slash commands
+
+> Introduced in GitLab 8.14
+
+Mattermost commands give users an extra interface to perform common operations
+from the chat environment. This allows one to, for example, create an issue as
+soon as the idea was discussed in Mattermost.
+
+## Prerequisites
+
+Mattermost 3.4 and up is required.
+
+If you have the Omnibus GitLab package installed, Mattermost is already bundled
+in it. All you have to do is configure it. Read more in the
+[Omnibus GitLab Mattermost documentation][omnimmdocs].
+
+## Configuration
+
+The configuration consists of two parts. First you need to enable the slash
+commands in Mattermost and then enable the service in GitLab.
+
+
+### Step 1. Enable custom slash commands in Mattermost
+
+The first thing to do in Mattermost is to enable custom slash commands from
+the administrator console.
+
+1. Log in with an account that has admin privileges and navigate to the system
+ console.
+
+ ![Mattermost go to console](img/mattermost_goto_console.png)
+
+ ---
+
+1. Click **Custom integrations** and set **Enable Custom Slash Commands** to
+ true.
+
+ ![Mattermost console](img/mattermost_console_integrations.png)
+
+ ---
+
+1. Click **Save** at the bottom to save the changes.
+
+### Step 2. Open the Mattermost slash commands service in GitLab
+
+1. Open a new tab for GitLab and go to your project's settings
+ **Services âž” Mattermost command**. A screen will appear with all the values you
+ need to copy in Mattermost as described in the next step. Leave the window open.
+
+ >**Note:**
+ GitLab will propose some values for the Mattermost settings. The only one
+ required to copy-paste as-is is the **Request URL**, all the others are just
+ suggestions.
+
+ ![Mattermost setup instructions](img/mattermost_config_help.png)
+
+ ---
+
+1. Proceed to the next step and create a slash command in Mattermost with the
+ above values.
+
+### Step 3. Create a new custom slash command in Mattermost
+
+Now that you have enabled the custom slash commands in Mattermost and opened
+the Mattermost slash commands service in GitLab, it's time to copy these values
+in a new slash command.
+
+1. Back to Mattermost, under your team page settings, you should see the
+ **Integrations** option.
+
+ ![Mattermost team integrations](img/mattermost_team_integrations.png)
+
+ ---
+
+1. Go to the **Slash Commands** integration and add a new one by clicking the
+ **Add Slash Command** button.
+
+ ![Mattermost add command](img/mattermost_add_slash_command.png)
+
+ ---
+
+1. Fill in the options for the custom command as described in
+ [step 2](#step-2-open-the-mattermost-slash-commands-service-in-gitlab).
+
+ >**Note:**
+ If you plan on connecting multiple projects, pick a slash command trigger
+ word that relates to your projects such as `/gitlab-project-name` or even
+ just `/project-name`. Only use `/gitlab` if you will only connect a single
+ project to your Mattermost team.
+
+ ![Mattermost add command configuration](img/mattermost_slash_command_configuration.png)
+
+1. After you setup all the values, copy the token (we will use it below) and
+ click **Done**.
+
+ ![Mattermost slash command token](img/mattermost_slash_command_token.png)
+
+### Step 4. Copy the Mattermost token into the Mattermost slash command service
+
+1. In GitLab, paste the Mattermost token you copied in the previous step and
+ check the **Active** checkbox.
+
+ ![Mattermost copy token to GitLab](img/mattermost_gitlab_token.png)
+
+1. Click **Save changes** for the changes to take effect.
+
+---
+
+You are now set to start using slash commands in Mattermost that talk to the
+GitLab project you configured.
+
+## Authorizing Mattermost to interact with GitLab
+
+The first time a user will interact with the newly created slash commands,
+Mattermost will trigger an authorization process.
+
+![Mattermost bot authorize](img/mattermost_bot_auth.png)
+
+This will connect your Mattermost user with your GitLab user. You can
+see all authorized chat accounts in your profile's page under **Chat**.
+
+When the authorization process is complete, you can start interacting with
+GitLab using the Mattermost commands.
+
+## Available slash commands
+
+The available slash commands so far are:
+
+| Command | Description | Example |
+| ------- | ----------- | ------- |
+| `/<trigger> issue create <title>\n<description>` | Create a new issue in the project that `<trigger>` is tied to. `<description>` is optional. | `/trigger issue create We need to change the homepage` |
+| `/<trigger> issue show <issue-number>` | Show the issue with ID `<issue-number>` from the project that `<trigger>` is tied to. | `/trigger issue show 42` |
+| `/<trigger> deploy <environment> to <environment>` | Start the CI job that deploys from one environment to another, for example `staging` to `production`. CI/CD must be [properly configured][ciyaml]. | `/trigger deploy staging to production` |
+
+To see a list of available commands that can interact with GitLab, type the
+trigger word followed by `help`:
+
+```
+/my-project help
+```
+
+![Mattermost bot available commands](img/mattermost_bot_available_commands.png)
+
+## Permissions
+
+The permissions to run the [available commands](#available-commands) derive from
+the [permissions you have on the project](../user/permissions.md#project).
+
+## Further reading
+
+- [Mattermost slash commands documentation][mmslashdocs]
+- [Omnibus GitLab Mattermost][omnimmdocs]
+
+
+[omnimmdocs]: https://docs.gitlab.com/omnibus/gitlab-mattermost/
+[mmslashdocs]: https://docs.mattermost.com/developer/slash-commands.html
+[ciyaml]: ../ci/yaml/README.md
diff --git a/doc/project_services/project_services.md b/doc/project_services/project_services.md
index 4442b7c1742..890f7525b0e 100644
--- a/doc/project_services/project_services.md
+++ b/doc/project_services/project_services.md
@@ -42,6 +42,7 @@ further configuration instructions and details. Contributions are welcome.
| [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway |
| [JIRA](jira.md) | JIRA issue tracker |
| JetBrains TeamCity CI | A continuous integration and build server |
+| [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands |
| PivotalTracker | Project Management Software (Source Commits Endpoint) |
| Pushover | Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop |
| [Redmine](redmine.md) | Redmine issue tracker |
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index b50f5238e80..aaf0ede67e6 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -62,7 +62,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New Issue"' do
- click_link "New Issue"
+ page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
end
step 'I click "author" dropdown' do
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index ce1bf0d26d2..d0ee9c9a5b2 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -3,6 +3,9 @@ module API
class ProjectSnippets < Grape::API
before { authenticate! }
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
resource :projects do
helpers do
def handle_project_member_errors(errors)
@@ -18,111 +21,108 @@ module API
end
end
- # Get a project snippets
- #
- # Parameters:
- # id (required) - The ID of a project
- # Example Request:
- # GET /projects/:id/snippets
+ desc 'Get all project snippets' do
+ success Entities::ProjectSnippet
+ end
get ":id/snippets" do
present paginate(snippets_for_current_user), with: Entities::ProjectSnippet
end
- # Get a project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # GET /projects/:id/snippets/:snippet_id
+ desc 'Get a single project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
get ":id/snippets/:snippet_id" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- present @snippet, with: Entities::ProjectSnippet
- end
-
- # Create a new project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # title (required) - The title of a snippet
- # file_name (required) - The name of a snippet file
- # code (required) - The content of a snippet
- # visibility_level (required) - The snippet's visibility
- # Example Request:
- # POST /projects/:id/snippets
+ snippet = snippets_for_current_user.find(params[:snippet_id])
+ present snippet, with: Entities::ProjectSnippet
+ end
+
+ desc 'Create a new project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ requires :title, type: String, desc: 'The title of the snippet'
+ requires :file_name, type: String, desc: 'The file name of the snippet'
+ requires :code, type: String, desc: 'The content of the snippet'
+ requires :visibility_level, type: Integer,
+ values: [Gitlab::VisibilityLevel::PRIVATE,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC],
+ desc: 'The visibility level of the snippet'
+ end
post ":id/snippets" do
authorize! :create_project_snippet, user_project
- required_attributes! [:title, :file_name, :code, :visibility_level]
+ snippet_params = declared_params
+ snippet_params[:content] = snippet_params.delete(:code)
- attrs = attributes_for_keys [:title, :file_name, :visibility_level]
- attrs[:content] = params[:code] if params[:code].present?
- @snippet = CreateSnippetService.new(user_project, current_user,
- attrs).execute
+ snippet = CreateSnippetService.new(user_project, current_user, snippet_params).execute
- if @snippet.errors.any?
- render_validation_error!(@snippet)
+ if snippet.persisted?
+ present snippet, with: Entities::ProjectSnippet
else
- present @snippet, with: Entities::ProjectSnippet
+ render_validation_error!(snippet)
end
end
- # Update an existing project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # title (optional) - The title of a snippet
- # file_name (optional) - The name of a snippet file
- # code (optional) - The content of a snippet
- # visibility_level (optional) - The snippet's visibility
- # Example Request:
- # PUT /projects/:id/snippets/:snippet_id
+ desc 'Update an existing project snippet' do
+ success Entities::ProjectSnippet
+ end
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ optional :title, type: String, desc: 'The title of the snippet'
+ optional :file_name, type: String, desc: 'The file name of the snippet'
+ optional :code, type: String, desc: 'The content of the snippet'
+ optional :visibility_level, type: Integer,
+ values: [Gitlab::VisibilityLevel::PRIVATE,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC],
+ desc: 'The visibility level of the snippet'
+ at_least_one_of :title, :file_name, :code, :visibility_level
+ end
put ":id/snippets/:snippet_id" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- authorize! :update_project_snippet, @snippet
+ snippet = snippets_for_current_user.find_by(id: params.delete(:snippet_id))
+ not_found!('Snippet') unless snippet
+
+ authorize! :update_project_snippet, snippet
+
+ snippet_params = declared_params(include_missing: false)
+ snippet_params[:content] = snippet_params.delete(:code) if snippet_params[:code].present?
- attrs = attributes_for_keys [:title, :file_name, :visibility_level]
- attrs[:content] = params[:code] if params[:code].present?
+ UpdateSnippetService.new(user_project, current_user, snippet,
+ snippet_params).execute
- UpdateSnippetService.new(user_project, current_user, @snippet,
- attrs).execute
- if @snippet.errors.any?
- render_validation_error!(@snippet)
+ if snippet.persisted?
+ present snippet, with: Entities::ProjectSnippet
else
- present @snippet, with: Entities::ProjectSnippet
+ render_validation_error!(snippet)
end
end
- # Delete a project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # DELETE /projects/:id/snippets/:snippet_id
+ desc 'Delete a project snippet'
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
delete ":id/snippets/:snippet_id" do
- begin
- @snippet = snippets_for_current_user.find(params[:snippet_id])
- authorize! :update_project_snippet, @snippet
- @snippet.destroy
- rescue
- not_found!('Snippet')
- end
+ snippet = snippets_for_current_user.find_by(id: params[:snippet_id])
+ not_found!('Snippet') unless snippet
+
+ authorize! :admin_project_snippet, snippet
+ snippet.destroy
end
- # Get a raw project snippet
- #
- # Parameters:
- # id (required) - The ID of a project
- # snippet_id (required) - The ID of a project snippet
- # Example Request:
- # GET /projects/:id/snippets/:snippet_id/raw
+ desc 'Get a raw project snippet'
+ params do
+ requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
+ end
get ":id/snippets/:snippet_id/raw" do
- @snippet = snippets_for_current_user.find(params[:snippet_id])
+ snippet = snippets_for_current_user.find_by(id: params[:snippet_id])
+ not_found!('Snippet') unless snippet
env['api.format'] = :txt
content_type 'text/plain'
- present @snippet.content
+ present snippet.content
end
end
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index c07539194ed..a73650dc361 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -140,7 +140,8 @@ module API
User.where(username: params[:username]).
where.not(id: user.id).count > 0
- identity_attrs = params.slice(:provider, :extern_uid)
+ user_params = declared_params(include_missing: false)
+ identity_attrs = user_params.slice(:provider, :extern_uid)
if identity_attrs.any?
identity = user.identities.find_by(provider: identity_attrs[:provider])
@@ -154,10 +155,10 @@ module API
end
# Delete already handled parameters
- params.delete(:extern_uid)
- params.delete(:provider)
+ user_params.delete(:extern_uid)
+ user_params.delete(:provider)
- if user.update_attributes(declared_params(include_missing: false))
+ if user.update_attributes(user_params)
present user, with: Entities::UserFull
else
render_validation_error!(user)
diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb
index 66c05773b68..792ff628b09 100644
--- a/lib/ci/api/entities.rb
+++ b/lib/ci/api/entities.rb
@@ -32,6 +32,10 @@ module Ci
expose :artifacts_file, using: ArtifactFile, if: ->(build, _) { build.artifacts? }
end
+ class BuildCredentials < Grape::Entity
+ expose :type, :url, :username, :password
+ end
+
class BuildDetails < Build
expose :commands
expose :repo_url
@@ -50,6 +54,8 @@ module Ci
expose :variables
expose :depends_on_builds, using: Build
+
+ expose :credentials, using: BuildCredentials
end
class Runner < Grape::Entity
diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb
index 5f131703d40..0ec358debc7 100644
--- a/lib/gitlab/chat_commands/command.rb
+++ b/lib/gitlab/chat_commands/command.rb
@@ -4,6 +4,7 @@ module Gitlab
COMMANDS = [
Gitlab::ChatCommands::IssueShow,
Gitlab::ChatCommands::IssueCreate,
+ Gitlab::ChatCommands::Deploy,
].freeze
def execute
diff --git a/lib/gitlab/chat_commands/deploy.rb b/lib/gitlab/chat_commands/deploy.rb
new file mode 100644
index 00000000000..0eed1fce0dc
--- /dev/null
+++ b/lib/gitlab/chat_commands/deploy.rb
@@ -0,0 +1,57 @@
+module Gitlab
+ module ChatCommands
+ class Deploy < BaseCommand
+ include Gitlab::Routing.url_helpers
+
+ def self.match(text)
+ /\Adeploy\s+(?<from>.*)\s+to+\s+(?<to>.*)\z/.match(text)
+ end
+
+ def self.help_message
+ 'deploy <environment> to <target-environment>'
+ end
+
+ def self.available?(project)
+ project.builds_enabled?
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :create_deployment, project)
+ end
+
+ def execute(match)
+ from = match[:from]
+ to = match[:to]
+
+ actions = find_actions(from, to)
+ return unless actions.present?
+
+ if actions.one?
+ play!(from, to, actions.first)
+ else
+ Result.new(:error, 'Too many actions defined')
+ end
+ end
+
+ private
+
+ def play!(from, to, action)
+ new_action = action.play(current_user)
+
+ Result.new(:success, "Deployment from #{from} to #{to} started. Follow the progress: #{url(new_action)}.")
+ end
+
+ def find_actions(from, to)
+ environment = project.environments.find_by(name: from)
+ return unless environment
+
+ environment.actions_for(to).select(&:starts_environment?)
+ end
+
+ def url(subject)
+ polymorphic_url(
+ [ subject.project.namespace.becomes(Namespace), subject.project, subject ])
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/chat_commands/result.rb b/lib/gitlab/chat_commands/result.rb
new file mode 100644
index 00000000000..324d7ef43a3
--- /dev/null
+++ b/lib/gitlab/chat_commands/result.rb
@@ -0,0 +1,5 @@
+module Gitlab
+ module ChatCommands
+ Result = Struct.new(:type, :message)
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/base.rb b/lib/gitlab/ci/build/credentials/base.rb
new file mode 100644
index 00000000000..29a7a27c963
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/base.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Base
+ def type
+ self.class.name.demodulize.underscore
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/factory.rb b/lib/gitlab/ci/build/credentials/factory.rb
new file mode 100644
index 00000000000..2423aa8857d
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/factory.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Factory
+ def initialize(build)
+ @build = build
+ end
+
+ def create!
+ credentials.select(&:valid?)
+ end
+
+ private
+
+ def credentials
+ providers.map { |provider| provider.new(@build) }
+ end
+
+ def providers
+ [Registry]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/credentials/registry.rb b/lib/gitlab/ci/build/credentials/registry.rb
new file mode 100644
index 00000000000..55eafcaed10
--- /dev/null
+++ b/lib/gitlab/ci/build/credentials/registry.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module Ci
+ module Build
+ module Credentials
+ class Registry < Base
+ attr_reader :username, :password
+
+ def initialize(build)
+ @username = 'gitlab-ci-token'
+ @password = build.token
+ end
+
+ def url
+ Gitlab.config.registry.host_port
+ end
+
+ def valid?
+ Gitlab.config.registry.enabled
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 20dcc024b4e..a55362f0b6b 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -108,7 +108,7 @@ module Gitlab
self.class.nodes.each_key do |key|
global_entry = deps[key]
- job_entry = @entries[key]
+ job_entry = self[key]
if global_entry.specified? && !job_entry.specified?
@entries[key] = global_entry
diff --git a/lib/gitlab/cycle_analytics/permissions.rb b/lib/gitlab/cycle_analytics/permissions.rb
new file mode 100644
index 00000000000..bef3b95ff1b
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/permissions.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module CycleAnalytics
+ class Permissions
+ STAGE_PERMISSIONS = {
+ issue: :read_issue,
+ code: :read_merge_request,
+ test: :read_build,
+ review: :read_merge_request,
+ staging: :read_build,
+ production: :read_issue,
+ }.freeze
+
+ def self.get(*args)
+ new(*args).get
+ end
+
+ def initialize(user:, project:)
+ @user = user
+ @project = project
+ @stage_permission_hash = {}
+ end
+
+ def get
+ ::CycleAnalytics::STAGES.each do |stage|
+ @stage_permission_hash[stage] = authorized_stage?(stage)
+ end
+
+ @stage_permission_hash
+ end
+
+ private
+
+ def authorized_stage?(stage)
+ return false unless authorize_project(:read_cycle_analytics)
+
+ STAGE_PERMISSIONS[stage] ? authorize_project(STAGE_PERMISSIONS[stage]) : true
+ end
+
+ def authorize_project(permission)
+ Ability.allowed?(@user, permission, @project)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb
new file mode 100644
index 00000000000..1d93a67dc56
--- /dev/null
+++ b/lib/gitlab/file_detector.rb
@@ -0,0 +1,63 @@
+require 'set'
+
+module Gitlab
+ # Module that can be used to detect if a path points to a special file such as
+ # a README or a CONTRIBUTING file.
+ module FileDetector
+ PATTERNS = {
+ readme: /\Areadme/i,
+ changelog: /\A(changelog|history|changes|news)/i,
+ license: /\A(licen[sc]e|copying)(\..+|\z)/i,
+ contributing: /\Acontributing/i,
+ version: 'version',
+ gitignore: '.gitignore',
+ koding: '.koding.yml',
+ gitlab_ci: '.gitlab-ci.yml',
+ avatar: /\Alogo\.(png|jpg|gif)\z/
+ }
+
+ # Returns an Array of file types based on the given paths.
+ #
+ # This method can be used to check if a list of file paths (e.g. of changed
+ # files) involve any special files such as a README or a LICENSE file.
+ #
+ # Example:
+ #
+ # types_in_paths(%w{README.md foo/bar.txt}) # => [:readme]
+ def self.types_in_paths(paths)
+ types = Set.new
+
+ paths.each do |path|
+ type = type_of(path)
+
+ types << type if type
+ end
+
+ types.to_a
+ end
+
+ # Returns the type of a file path, or nil if none could be detected.
+ #
+ # Returned types are Symbols such as `:readme`, `:version`, etc.
+ #
+ # Example:
+ #
+ # type_of('README.md') # => :readme
+ # type_of('VERSION') # => :version
+ def self.type_of(path)
+ name = File.basename(path)
+
+ PATTERNS.each do |type, search|
+ did_match = if search.is_a?(Regexp)
+ name =~ search
+ else
+ name.casecmp(search) == 0
+ end
+
+ return type if did_match
+ end
+
+ nil
+ end
+ end
+end
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 47ea8b7e82e..c12358ceef4 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -9,7 +9,7 @@ module Gitlab
# `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
+ 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
index bfbb089eb02..67eda983a74 100644
--- a/lib/mattermost/presenter.rb
+++ b/lib/mattermost/presenter.rb
@@ -24,20 +24,22 @@ module Mattermost
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
+ def present(subject)
+ return not_found unless subject
+
+ if subject.is_a?(Gitlab::ChatCommands::Result)
+ show_result(subject)
+ elsif subject.respond_to?(:count)
+ if subject.many?
+ multiple_resources(subject)
+ elsif subject.none?
+ not_found
else
- resource = resource.first
+ single_resource(subject)
end
+ else
+ single_resource(subject)
end
-
- single_resource(resource)
end
def access_denied
@@ -46,6 +48,15 @@ module Mattermost
private
+ def show_result(result)
+ case result.type
+ when :success
+ in_channel_response(result.message)
+ else
+ ephemeral_response(result.message)
+ end
+ end
+
def not_found
ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:")
end
@@ -54,7 +65,7 @@ module Mattermost
return error(resource) if resource.errors.any? || !resource.persisted?
message = "### #{title(resource)}"
- message << "\n\n#{resource.description}" if resource.description
+ message << "\n\n#{resource.description}" if resource.try(:description)
in_channel_response(message)
end
@@ -74,7 +85,10 @@ module Mattermost
end
def title(resource)
- "[#{resource.to_reference} #{resource.title}](#{url(resource)})"
+ reference = resource.try(:to_reference) || resource.try(:id)
+ title = resource.try(:title) || resource.try(:name)
+
+ "[#{reference} #{title}](#{url(resource)})"
end
def header_with_list(header, items)
diff --git a/package.json b/package.json
index e75e070451b..2a9fb808eef 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"eslint-plugin-import": "^2.0.1",
"eslint-plugin-jasmine": "^1.8.1",
"eslint-plugin-jsx-a11y": "^2.2.3",
- "eslint-plugin-react": "^6.4.1"
+ "eslint-plugin-react": "^6.4.1",
+ "istanbul": "^0.4.5"
}
}
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
new file mode 100644
index 00000000000..a971adf0539
--- /dev/null
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Projects::CycleAnalyticsController do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ project.team << [user, :master]
+ end
+
+ describe 'cycle analytics not set up flag' do
+ context 'with no data' do
+ it 'is true' do
+ get(:show,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param)
+
+ expect(response).to be_success
+ expect(assigns(:cycle_analytics_no_data)).to eq(true)
+ end
+ end
+
+ context 'with data' do
+ before do
+ issue = create(:issue, project: project, created_at: 4.days.ago)
+ milestone = create(:milestone, project: project, created_at: 5.days.ago)
+ issue.update(milestone: milestone)
+
+ create_merge_request_closing_issue(issue)
+ end
+
+ it 'is false' do
+ get(:show,
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param)
+
+ expect(response).to be_success
+ expect(assigns(:cycle_analytics_no_data)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index e7fe489e5eb..c443af09075 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -59,6 +59,12 @@ FactoryGirl.define do
self.when 'manual'
end
+ trait :teardown_environment do
+ options do
+ { environment: { action: 'stop' } }
+ end
+ end
+
trait :allowed_to_fail do
allow_failure true
end
diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb
index 2798db92f0f..0d19563d628 100644
--- a/spec/features/issues/filter_issues_spec.rb
+++ b/spec/features/issues/filter_issues_spec.rb
@@ -3,8 +3,8 @@ require 'rails_helper'
describe 'Filter issues', feature: true do
include WaitForAjax
- let!(:project) { create(:project) }
let!(:group) { create(:group) }
+ let!(:project) { create(:project, group: group) }
let!(:user) { create(:user)}
let!(:milestone) { create(:milestone, project: project) }
let!(:label) { create(:label, project: project) }
@@ -127,7 +127,7 @@ describe 'Filter issues', feature: true do
expect(page).to have_content wontfix.title
end
- find('body').click
+ find('.dropdown-menu-close-icon').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
@@ -135,7 +135,7 @@ describe 'Filter issues', feature: true do
wait_for_ajax
find('.dropdown-menu-labels a', text: label.title).click
- find('body').click
+ find('.dropdown-menu-close-icon').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
expect(find('.filtered-labels')).to have_content(label.title)
@@ -150,8 +150,8 @@ describe 'Filter issues', feature: true do
it "selects and unselects `won't fix`" do
find('.dropdown-menu-labels a', text: wontfix.title).click
find('.dropdown-menu-labels a', text: wontfix.title).click
- # Close label dropdown to load
- find('body').click
+
+ find('.dropdown-menu-close-icon').click
expect(page).not_to have_css('.filtered-labels')
end
end
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index cdd02a8c8e3..5c958455604 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -371,10 +371,12 @@ describe 'Issues', feature: true do
describe 'when I want to reset my incoming email token' do
let(:project1) { create(:project, namespace: @user.namespace) }
+ let(:issue) { create(:issue, project: project1) }
before do
allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true)
project1.team << [@user, :master]
+ project1.issues << issue
visit namespace_project_issues_path(@user.namespace, project1)
end
@@ -576,7 +578,10 @@ describe 'Issues', feature: true do
describe 'new issue by email' do
shared_examples 'show the email in the modal' do
+ let(:issue) { create(:issue, project: project) }
+
before do
+ project.issues << issue
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
visit namespace_project_issues_path(project.namespace, project)
diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb
new file mode 100644
index 00000000000..778b3a90cf3
--- /dev/null
+++ b/spec/features/merge_requests/deleted_source_branch_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe 'Deleted source branch', feature: true, js: true do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request) }
+
+ before do
+ login_as user
+ merge_request.project.team << [user, :master]
+ merge_request.update!(source_branch: 'this-branch-does-not-exist')
+ visit namespace_project_merge_request_path(
+ merge_request.project.namespace,
+ merge_request.project, merge_request
+ )
+ end
+
+ it 'shows a message about missing source branch' do
+ expect(page).to have_content(
+ 'Source branch this-branch-does-not-exist does not exist'
+ )
+ end
+
+ it 'hides Discussion, Commits and Changes tabs' do
+ within '.merge-request-details' do
+ expect(page).to have_no_content('Discussion')
+ expect(page).to have_no_content('Commits')
+ expect(page).to have_no_content('Changes')
+ end
+ end
+end
diff --git a/spec/features/merge_requests/merge_request_versions_spec.rb b/spec/features/merge_requests/merge_request_versions_spec.rb
index 23cee891bac..09451f41de4 100644
--- a/spec/features/merge_requests/merge_request_versions_spec.rb
+++ b/spec/features/merge_requests/merge_request_versions_spec.rb
@@ -3,11 +3,12 @@ require 'spec_helper'
feature 'Merge Request versions', js: true, feature: true do
let(:merge_request) { create(:merge_request, importing: true) }
let(:project) { merge_request.source_project }
+ let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
+ let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) }
+ let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
before do
login_as :admin
- merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
- merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
@@ -53,7 +54,7 @@ feature 'Merge Request versions', js: true, feature: true do
project.namespace,
project,
merge_request.iid,
- diff_id: 2,
+ diff_id: merge_request_diff3.id,
start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
)
end
diff --git a/spec/javascripts/activities_spec.js.es6 b/spec/javascripts/activities_spec.js.es6
index 9d855ef1060..8640cd44085 100644
--- a/spec/javascripts/activities_spec.js.es6
+++ b/spec/javascripts/activities_spec.js.es6
@@ -35,7 +35,7 @@
describe('Activities', () => {
beforeEach(() => {
fixture.load(fixtureTemplate);
- new Activities();
+ new gl.Activities();
});
for(let i = 0; i < filters.length; i++) {
diff --git a/spec/javascripts/environments/environment_item_spec.js.es6 b/spec/javascripts/environments/environment_item_spec.js.es6
index 54c93367b17..3c15e3b7719 100644
--- a/spec/javascripts/environments/environment_item_spec.js.es6
+++ b/spec/javascripts/environments/environment_item_spec.js.es6
@@ -135,7 +135,7 @@ describe('Environment item', () => {
});
it('should render environment name', () => {
- expect(component.$el.querySelector('.environment-name').textContent).toEqual(environment.name);
+ expect(component.$el.querySelector('.environment-name').textContent).toContain(environment.name);
});
describe('With deployment', () => {
diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js
index 575b87e6f17..f38e9cb8ef5 100644
--- a/spec/javascripts/merge_request_widget_spec.js
+++ b/spec/javascripts/merge_request_widget_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable space-before-function-paren, quotes, comma-dangle, dot-notation, indent, quote-props, no-var, padded-blocks, max-len */
/*= require merge_request_widget */
-/*= require lib/utils/timeago.js */
+/*= require lib/utils/timeago */
+/*= require lib/utils/datetime_utility */
(function() {
describe('MergeRequestWidget', function() {
@@ -54,6 +55,57 @@
});
});
+ describe('renderEnvironments', function() {
+ describe('should render correct timeago', function() {
+ beforeEach(function() {
+ this.environments = [{
+ id: 'test-environment-id',
+ url: 'testurl',
+ deployed_at: new Date().toISOString(),
+ deployed_at_formatted: true
+ }];
+ });
+
+ function getTimeagoText(template) {
+ var el = document.createElement('html');
+ el.innerHTML = template;
+ return el.querySelector('.js-environment-timeago').innerText.trim();
+ }
+
+ it('should render less than a minute ago text', function() {
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('less than a minute ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+
+ it('should render about an hour ago text', function() {
+ var oneHourAgo = new Date();
+ oneHourAgo.setHours(oneHourAgo.getHours() - 1);
+
+ this.environments[0].deployed_at = oneHourAgo.toISOString();
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('about an hour ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+
+ it('should render about 2 hours ago text', function() {
+ var twoHoursAgo = new Date();
+ twoHoursAgo.setHours(twoHoursAgo.getHours() - 2);
+
+ this.environments[0].deployed_at = twoHoursAgo.toISOString();
+ spyOn(this.class.$widgetBody, 'before').and.callFake(function(template) {
+ expect(getTimeagoText(template)).toBe('about 2 hours ago.');
+ });
+
+ this.class.renderEnvironments(this.environments);
+ });
+ });
+ });
+
return describe('getCIStatus', function() {
beforeEach(function() {
this.ciStatusData = {
diff --git a/spec/javascripts/pretty_time_spec.js.es6 b/spec/javascripts/pretty_time_spec.js.es6
new file mode 100644
index 00000000000..2e12d45f7a7
--- /dev/null
+++ b/spec/javascripts/pretty_time_spec.js.es6
@@ -0,0 +1,134 @@
+//= require lib/utils/pretty_time
+
+(() => {
+ const PrettyTime = gl.PrettyTime;
+
+ describe('PrettyTime methods', function () {
+ describe('parseSeconds', function () {
+ it('should correctly parse a negative value', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const zeroSeconds = parser(-1000);
+
+ expect(zeroSeconds.minutes).toBe(16);
+ expect(zeroSeconds.hours).toBe(0);
+ expect(zeroSeconds.days).toBe(0);
+ expect(zeroSeconds.weeks).toBe(0);
+ });
+
+ it('should correctly parse a zero value', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const zeroSeconds = parser(0);
+
+ expect(zeroSeconds.minutes).toBe(0);
+ expect(zeroSeconds.hours).toBe(0);
+ expect(zeroSeconds.days).toBe(0);
+ expect(zeroSeconds.weeks).toBe(0);
+ });
+
+ it('should correctly parse a small non-zero second values', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const subOneMinute = parser(10);
+
+ expect(subOneMinute.minutes).toBe(0);
+ expect(subOneMinute.hours).toBe(0);
+ expect(subOneMinute.days).toBe(0);
+ expect(subOneMinute.weeks).toBe(0);
+
+ const aboveOneMinute = parser(100);
+
+ expect(aboveOneMinute.minutes).toBe(1);
+ expect(aboveOneMinute.hours).toBe(0);
+ expect(aboveOneMinute.days).toBe(0);
+ expect(aboveOneMinute.weeks).toBe(0);
+
+ const manyMinutes = parser(1000);
+
+ expect(manyMinutes.minutes).toBe(16);
+ expect(manyMinutes.hours).toBe(0);
+ expect(manyMinutes.days).toBe(0);
+ expect(manyMinutes.weeks).toBe(0);
+ });
+
+ it('should correctly parse large second values', function () {
+ const parser = PrettyTime.parseSeconds;
+
+ const aboveOneHour = parser(4800);
+
+ expect(aboveOneHour.minutes).toBe(20);
+ expect(aboveOneHour.hours).toBe(1);
+ expect(aboveOneHour.days).toBe(0);
+ expect(aboveOneHour.weeks).toBe(0);
+
+ const aboveOneDay = parser(110000);
+
+ expect(aboveOneDay.minutes).toBe(33);
+ expect(aboveOneDay.hours).toBe(6);
+ expect(aboveOneDay.days).toBe(3);
+ expect(aboveOneDay.weeks).toBe(0);
+
+ const aboveOneWeek = parser(25000000);
+
+ expect(aboveOneWeek.minutes).toBe(26);
+ expect(aboveOneWeek.hours).toBe(0);
+ expect(aboveOneWeek.days).toBe(3);
+ expect(aboveOneWeek.weeks).toBe(173);
+ });
+ });
+
+ describe('stringifyTime', function () {
+ it('should stringify values with all non-zero units', function () {
+ const timeObject = {
+ weeks: 1,
+ days: 4,
+ hours: 7,
+ minutes: 20,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('1w 4d 7h 20m');
+ });
+
+ it('should stringify values with some non-zero units', function () {
+ const timeObject = {
+ weeks: 0,
+ days: 4,
+ hours: 0,
+ minutes: 20,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('4d 20m');
+ });
+
+ it('should stringify values with no non-zero units', function () {
+ const timeObject = {
+ weeks: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ };
+
+ const timeString = PrettyTime.stringifyTime(timeObject);
+
+ expect(timeString).toBe('0m');
+ });
+ });
+
+ describe('abbreviateTime', function () {
+ it('should abbreviate stringified times for weeks', function () {
+ const fullTimeString = '1w 3d 4h 5m';
+ expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('1w');
+ });
+
+ it('should abbreviate stringified times for non-weeks', function () {
+ const fullTimeString = '0w 3d 4h 5m';
+ expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d');
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js.es6
new file mode 100644
index 00000000000..651d1f0f975
--- /dev/null
+++ b/spec/javascripts/smart_interval_spec.js.es6
@@ -0,0 +1,159 @@
+//= require jquery
+//= require smart_interval
+
+(() => {
+ const DEFAULT_MAX_INTERVAL = 100;
+ const DEFAULT_STARTING_INTERVAL = 5;
+ const DEFAULT_SHORT_TIMEOUT = 75;
+ const DEFAULT_LONG_TIMEOUT = 1000;
+ const DEFAULT_INCREMENT_FACTOR = 2;
+
+ function createDefaultSmartInterval(config) {
+ const defaultParams = {
+ callback: () => {},
+ startingInterval: DEFAULT_STARTING_INTERVAL,
+ maxInterval: DEFAULT_MAX_INTERVAL,
+ incrementByFactorOf: DEFAULT_INCREMENT_FACTOR,
+ delayStartBy: 0,
+ lazyStart: false,
+ };
+
+ if (config) {
+ _.extend(defaultParams, config);
+ }
+
+ return new gl.SmartInterval(defaultParams);
+ }
+
+ describe('SmartInterval', function () {
+ describe('Increment Interval', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should increment the interval delay', function (done) {
+ const interval = this.smartInterval;
+ setTimeout(() => {
+ const intervalConfig = this.smartInterval.cfg;
+ const iterationCount = 4;
+ const maxIntervalAfterIterations = intervalConfig.startingInterval *
+ Math.pow(intervalConfig.incrementByFactorOf, (iterationCount - 1)); // 40
+ const currentInterval = interval.getCurrentInterval();
+
+ // Provide some flexibility for performance of testing environment
+ expect(currentInterval).toBeGreaterThan(intervalConfig.startingInterval);
+ expect(currentInterval <= maxIntervalAfterIterations).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT); // 4 iterations, increment by 2x = (5 + 10 + 20 + 40)
+ });
+
+ it('should not increment past maxInterval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ const currentInterval = interval.getCurrentInterval();
+ expect(currentInterval).toBe(interval.cfg.maxInterval);
+
+ done();
+ }, DEFAULT_LONG_TIMEOUT);
+ });
+ });
+
+ describe('Public methods', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should cancel an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.cancel();
+
+ const intervalId = interval.state.intervalId;
+ const currentInterval = interval.getCurrentInterval();
+ const intervalLowerLimit = interval.cfg.startingInterval;
+
+ expect(intervalId).toBeUndefined();
+ expect(currentInterval).toBe(intervalLowerLimit);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.cancel();
+
+ interval.resume();
+
+ const intervalId = interval.state.intervalId;
+
+ expect(intervalId).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+
+ describe('DOM Events', function () {
+ beforeEach(function () {
+ // This ensures DOM and DOM events are initialized for these specs.
+ fixture.set('<div></div>');
+
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should pause when page is not visible', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeUndefined();
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume when page is becomes visible at the previous interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeUndefined();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'visible';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should cancel on page unload', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ $(document).trigger('page:before-unload');
+ expect(interval.state.intervalId).toBeUndefined();
+ expect(interval.getCurrentInterval()).toBe(interval.cfg.startingInterval);
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/subbable_resource_spec.js.es6 b/spec/javascripts/subbable_resource_spec.js.es6
new file mode 100644
index 00000000000..df395296791
--- /dev/null
+++ b/spec/javascripts/subbable_resource_spec.js.es6
@@ -0,0 +1,65 @@
+/* eslint-disable */
+//= vue
+//= vue-resource
+//= require jquery
+//= require subbable_resource
+
+/*
+* Test that each rest verb calls the publish and subscribe function and passes the correct value back
+*
+*
+* */
+((global) => {
+ describe('Subbable Resource', function () {
+ describe('PubSub', function () {
+ beforeEach(function () {
+ this.MockResource = new global.SubbableResource('https://example.com');
+ });
+ it('should successfully add a single subscriber', function () {
+ const callback = () => {};
+ this.MockResource.subscribe(callback);
+
+ expect(this.MockResource.subscribers.length).toBe(1);
+ expect(this.MockResource.subscribers[0]).toBe(callback);
+ });
+
+ it('should successfully add multiple subscribers', function () {
+ const callbackOne = () => {};
+ const callbackTwo = () => {};
+ const callbackThree = () => {};
+
+ this.MockResource.subscribe(callbackOne);
+ this.MockResource.subscribe(callbackTwo);
+ this.MockResource.subscribe(callbackThree);
+
+ expect(this.MockResource.subscribers.length).toBe(3);
+ });
+
+ it('should successfully publish an update to a single subscriber', function () {
+ const state = { myprop: 1 };
+
+ const callbacks = {
+ one: (data) => expect(data.myprop).toBe(2),
+ two: (data) => expect(data.myprop).toBe(2),
+ three: (data) => expect(data.myprop).toBe(2)
+ };
+
+ const spyOne = spyOn(callbacks, 'one');
+ const spyTwo = spyOn(callbacks, 'two');
+ const spyThree = spyOn(callbacks, 'three');
+
+ this.MockResource.subscribe(callbacks.one);
+ this.MockResource.subscribe(callbacks.two);
+ this.MockResource.subscribe(callbacks.three);
+
+ state.myprop++;
+
+ this.MockResource.publish(state);
+
+ expect(spyOne).toHaveBeenCalled();
+ expect(spyTwo).toHaveBeenCalled();
+ expect(spyThree).toHaveBeenCalled();
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb
index 8cedbb0240f..bfc6818ac08 100644
--- a/spec/lib/gitlab/chat_commands/command_spec.rb
+++ b/spec/lib/gitlab/chat_commands/command_spec.rb
@@ -4,9 +4,9 @@ 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
+ subject { described_class.new(project, user, params).execute }
+
context 'when no command is available' do
let(:params) { { text: 'issue show 1' } }
let(:project) { create(:project, has_external_issue_tracker: true) }
@@ -51,5 +51,44 @@ describe Gitlab::ChatCommands::Command, service: true do
expect(subject[:text]).to match(/\/issues\/\d+/)
end
end
+
+ context 'when trying to do deployment' do
+ let(:params) { { text: 'deploy staging to production' } }
+ let!(:build) { create(:ci_build, project: project) }
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+ let!(:manual) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
+ end
+
+ context 'and user can not create deployment' do
+ it 'returns action' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to start_with('Whoops! That action is not allowed')
+ end
+ end
+
+ context 'and user does have deployment permission' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'returns action' do
+ expect(subject[:text]).to include('Deployment from staging to production started')
+ expect(subject[:response_type]).to be(:in_channel)
+ end
+
+ context 'when duplicate action exists' do
+ let!(:manual2) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
+ end
+
+ it 'returns error' do
+ expect(subject[:response_type]).to be(:ephemeral)
+ expect(subject[:text]).to include('Too many actions defined')
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb
new file mode 100644
index 00000000000..bd8099c92da
--- /dev/null
+++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe Gitlab::ChatCommands::Deploy, service: true do
+ describe '#execute' do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:regex_match) { described_class.match('deploy staging to production') }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ subject do
+ described_class.new(project, user).execute(regex_match)
+ end
+
+ context 'if no environment is defined' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'with environment' do
+ let!(:staging) { create(:environment, name: 'staging', project: project) }
+ let!(:build) { create(:ci_build, project: project) }
+ let!(:deployment) { create(:deployment, environment: staging, deployable: build) }
+
+ context 'without actions' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'with action' do
+ let!(:manual1) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production')
+ end
+
+ it 'returns success result' do
+ expect(subject.type).to eq(:success)
+ expect(subject.message).to include('Deployment from staging to production started')
+ end
+
+ context 'when duplicate action exists' do
+ let!(:manual2) do
+ create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production')
+ end
+
+ it 'returns error' do
+ expect(subject.type).to eq(:error)
+ expect(subject.message).to include('Too many actions defined')
+ end
+ end
+
+ context 'when teardown action exists' do
+ let!(:teardown) do
+ create(:ci_build, :manual, :teardown_environment,
+ project: project, pipeline: build.pipeline,
+ name: 'teardown', environment: 'production')
+ end
+
+ it 'returns success result' do
+ expect(subject.type).to eq(:success)
+ expect(subject.message).to include('Deployment from staging to production started')
+ end
+ end
+ end
+ end
+ end
+
+ describe 'self.match' do
+ it 'matches the environment' do
+ match = described_class.match('deploy staging to production')
+
+ expect(match[:from]).to eq('staging')
+ expect(match[:to]).to eq('production')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/credentials/factory_spec.rb b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
new file mode 100644
index 00000000000..10b4b7a8826
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/credentials/factory_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Build::Credentials::Factory do
+ let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
+
+ subject { Gitlab::Ci::Build::Credentials::Factory.new(build).create! }
+
+ class TestProvider
+ def initialize(build); end
+ end
+
+ before do
+ allow_any_instance_of(Gitlab::Ci::Build::Credentials::Factory).to receive(:providers).and_return([TestProvider])
+ end
+
+ context 'when provider is valid' do
+ before do
+ allow_any_instance_of(TestProvider).to receive(:valid?).and_return(true)
+ end
+
+ it 'generates an array of credentials objects' do
+ is_expected.to be_kind_of(Array)
+ is_expected.not_to be_empty
+ expect(subject.first).to be_kind_of(TestProvider)
+ end
+ end
+
+ context 'when provider is not valid' do
+ before do
+ allow_any_instance_of(TestProvider).to receive(:valid?).and_return(false)
+ end
+
+ it 'generates an array without specific credential object' do
+ is_expected.to be_kind_of(Array)
+ is_expected.to be_empty
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/credentials/registry_spec.rb b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
new file mode 100644
index 00000000000..84e44dd53e2
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/credentials/registry_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Build::Credentials::Registry do
+ let(:build) { create(:ci_build, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let(:registry_url) { 'registry.example.com:5005' }
+
+ subject { Gitlab::Ci::Build::Credentials::Registry.new(build) }
+
+ before do
+ stub_container_registry_config(host_port: registry_url)
+ end
+
+ it 'contains valid DockerRegistry credentials' do
+ expect(subject).to be_kind_of(Gitlab::Ci::Build::Credentials::Registry)
+
+ expect(subject.username).to eq 'gitlab-ci-token'
+ expect(subject.password).to eq build.token
+ expect(subject.url).to eq registry_url
+ expect(subject.type).to eq 'registry'
+ end
+
+ describe '.valid?' do
+ subject { Gitlab::Ci::Build::Credentials::Registry.new(build).valid? }
+
+ context 'when registry is enabled' do
+ before do
+ stub_container_registry_config(enabled: true)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb
index 5e5c5dcc385..e64c8d46bd8 100644
--- a/spec/lib/gitlab/ci/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb
@@ -13,7 +13,7 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- context 'when hash is valid' do
+ context 'when configuration is valid' do
context 'when some entries defined' do
let(:hash) do
{ before_script: ['ls', 'pwd'],
@@ -225,29 +225,44 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- context 'when hash is not valid' do
+ context 'when configuration is not valid' do
before { global.compose! }
- let(:hash) do
- { before_script: 'ls' }
- end
+ context 'when before script is not an array' do
+ let(:hash) do
+ { before_script: 'ls' }
+ end
- describe '#valid?' do
- it 'is not valid' do
- expect(global).not_to be_valid
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(global).not_to be_valid
+ end
end
- end
- describe '#errors' do
- it 'reports errors from child nodes' do
- expect(global.errors)
- .to include 'before_script config should be an array of strings'
+ describe '#errors' do
+ it 'reports errors from child nodes' do
+ expect(global.errors)
+ .to include 'before_script config should be an array of strings'
+ end
+ end
+
+ describe '#before_script_value' do
+ it 'returns nil' do
+ expect(global.before_script_value).to be_nil
+ end
end
end
- describe '#before_script_value' do
- it 'returns nil' do
- expect(global.before_script_value).to be_nil
+ context 'when job does not have commands' do
+ let(:hash) do
+ { before_script: ['echo 123'], rspec: { stage: 'test' } }
+ end
+
+ describe '#errors' do
+ it 'reports errors about missing script' do
+ expect(global.errors)
+ .to include "jobs:rspec script can't be blank"
+ end
end
end
end
@@ -281,7 +296,7 @@ describe Gitlab::Ci::Config::Entry::Global do
{ cache: { key: 'a' }, rspec: { script: 'ls' } }
end
- context 'when node exists' do
+ context 'when entry exists' do
it 'returns correct entry' do
expect(global[:cache])
.to be_an_instance_of Gitlab::Ci::Config::Entry::Cache
@@ -289,7 +304,7 @@ describe Gitlab::Ci::Config::Entry::Global do
end
end
- context 'when node does not exist' do
+ context 'when entry does not exist' do
it 'always return unspecified node' do
expect(global[:some][:unknown][:node])
.not_to be_specified
diff --git a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
new file mode 100644
index 00000000000..dc4f7dc69db
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
@@ -0,0 +1,127 @@
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::Permissions do
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+
+ subject { described_class.get(user: user, project: project) }
+
+ context 'user with no relation to the project' do
+ it 'has no permissions to issue stage' do
+ expect(subject[:issue]).to eq(false)
+ end
+
+ it 'has no permissions to test stage' do
+ expect(subject[:test]).to eq(false)
+ end
+
+ it 'has no permissions to staging stage' do
+ expect(subject[:staging]).to eq(false)
+ end
+
+ it 'has no permissions to production stage' do
+ expect(subject[:production]).to eq(false)
+ end
+
+ it 'has no permissions to code stage' do
+ expect(subject[:code]).to eq(false)
+ end
+
+ it 'has no permissions to review stage' do
+ expect(subject[:review]).to eq(false)
+ end
+
+ it 'has no permissions to plan stage' do
+ expect(subject[:plan]).to eq(false)
+ end
+ end
+
+ context 'user is master' do
+ before do
+ project.team << [user, :master]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has permissions to test stage' do
+ expect(subject[:test]).to eq(true)
+ end
+
+ it 'has permissions to staging stage' do
+ expect(subject[:staging]).to eq(true)
+ end
+
+ it 'has permissions to production stage' do
+ expect(subject[:production]).to eq(true)
+ end
+
+ it 'has permissions to code stage' do
+ expect(subject[:code]).to eq(true)
+ end
+
+ it 'has permissions to review stage' do
+ expect(subject[:review]).to eq(true)
+ end
+
+ it 'has permissions to plan stage' do
+ expect(subject[:plan]).to eq(true)
+ end
+ end
+
+ context 'user has no build permissions' do
+ before do
+ project.team << [user, :guest]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has no permissions to test stage' do
+ expect(subject[:test]).to eq(false)
+ end
+
+ it 'has no permissions to staging stage' do
+ expect(subject[:staging]).to eq(false)
+ end
+ end
+
+ context 'user has no merge request permissions' do
+ before do
+ project.team << [user, :guest]
+ end
+
+ it 'has permissions to issue stage' do
+ expect(subject[:issue]).to eq(true)
+ end
+
+ it 'has no permissions to code stage' do
+ expect(subject[:code]).to eq(false)
+ end
+
+ it 'has no permissions to review stage' do
+ expect(subject[:review]).to eq(false)
+ end
+ end
+
+ context 'user has no issue permissions' do
+ before do
+ project.team << [user, :developer]
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
+ end
+
+ it 'has permissions to code stage' do
+ expect(subject[:code]).to eq(true)
+ end
+
+ it 'has no permissions to issue stage' do
+ expect(subject[:issue]).to eq(false)
+ end
+
+ it 'has no permissions to production stage' do
+ expect(subject[:production]).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb
new file mode 100644
index 00000000000..e5ba13bbaf8
--- /dev/null
+++ b/spec/lib/gitlab/file_detector_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Gitlab::FileDetector do
+ describe '.types_in_paths' do
+ it 'returns the file types for the given paths' do
+ expect(described_class.types_in_paths(%w(README.md CHANGELOG VERSION VERSION))).
+ to eq(%i{readme changelog version})
+ end
+
+ it 'does not include unrecognized file paths' do
+ expect(described_class.types_in_paths(%w(README.md foo.txt))).
+ to eq(%i{readme})
+ end
+ end
+
+ describe '.type_of' do
+ it 'returns the type of a README file' do
+ expect(described_class.type_of('README.md')).to eq(:readme)
+ end
+
+ it 'returns the type of a changelog file' do
+ %w(CHANGELOG HISTORY CHANGES NEWS).each do |file|
+ expect(described_class.type_of(file)).to eq(:changelog)
+ end
+ end
+
+ it 'returns the type of a license file' do
+ %w(LICENSE LICENCE COPYING).each do |file|
+ expect(described_class.type_of(file)).to eq(:license)
+ end
+ end
+
+ it 'returns the type of a version file' do
+ expect(described_class.type_of('VERSION')).to eq(:version)
+ end
+
+ it 'returns the type of a .gitignore file' do
+ expect(described_class.type_of('.gitignore')).to eq(:gitignore)
+ end
+
+ it 'returns the type of a Koding config file' do
+ expect(described_class.type_of('.koding.yml')).to eq(:koding)
+ end
+
+ it 'returns the type of a GitLab CI config file' do
+ expect(described_class.type_of('.gitlab-ci.yml')).to eq(:gitlab_ci)
+ end
+
+ it 'returns the type of an avatar' do
+ %w(logo.gif logo.png logo.jpg).each do |file|
+ expect(described_class.type_of(file)).to eq(:avatar)
+ end
+ end
+
+ it 'returns nil for an unknown file' do
+ expect(described_class.type_of('foo.txt')).to be_nil
+ end
+ end
+end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 60bbe3fcd72..d06665197db 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -9,6 +9,7 @@ describe Environment, models: true do
it { is_expected.to delegate_method(:last_deployment).to(:deployments).as(:last) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
+ it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
@@ -187,4 +188,15 @@ describe Environment, models: true do
it { is_expected.to be false }
end
end
+
+ describe '#actions_for' do
+ let(:deployment) { create(:deployment, environment: environment) }
+ let(:pipeline) { deployment.deployable.pipeline }
+ let!(:review_action) { create(:ci_build, :manual, name: 'review-apps', pipeline: pipeline, environment: 'review/$CI_BUILD_REF_NAME' )}
+ let!(:production_action) { create(:ci_build, :manual, name: 'production', pipeline: pipeline, environment: 'production' )}
+
+ it 'returns a list of actions with matching environment' do
+ expect(environment.actions_for('review/master')).to contain_exactly(review_action)
+ end
+ end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 25458e20618..e183fa88873 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1572,7 +1572,7 @@ describe Project, models: true do
end
it 'expires the avatar cache' do
- expect(project.repository).to receive(:expire_avatar_cache).with(project.default_branch)
+ expect(project.repository).to receive(:expire_avatar_cache)
project.change_head(project.default_branch)
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 72ac41f3472..04afb8ebc98 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -464,11 +464,7 @@ describe Repository, models: true do
end
end
- describe "#changelog" do
- before do
- repository.send(:cache).expire(:changelog)
- end
-
+ describe "#changelog", caching: true do
it 'accepts changelog' do
expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changelog')])
@@ -500,17 +496,16 @@ describe Repository, models: true do
end
end
- describe "#license_blob" do
+ describe "#license_blob", caching: true do
before do
- repository.send(:cache).expire(:license_blob)
repository.remove_file(user, 'LICENSE', 'Remove LICENSE', 'master')
end
it 'handles when HEAD points to non-existent ref' do
repository.commit_file(user, 'LICENSE', 'Copyright!', 'Add LICENSE', 'master', false)
- rugged = double('rugged')
- expect(rugged).to receive(:head_unborn?).and_return(true)
- expect(repository).to receive(:rugged).and_return(rugged)
+
+ allow(repository).to receive(:file_on_head).
+ and_raise(Rugged::ReferenceError)
expect(repository.license_blob).to be_nil
end
@@ -537,22 +532,18 @@ describe Repository, models: true do
end
end
- describe '#license_key' do
+ describe '#license_key', caching: true do
before do
- repository.send(:cache).expire(:license_key)
repository.remove_file(user, 'LICENSE', 'Remove LICENSE', 'master')
end
- it 'handles when HEAD points to non-existent ref' do
- repository.commit_file(user, 'LICENSE', 'Copyright!', 'Add LICENSE', 'master', false)
- rugged = double('rugged')
- expect(rugged).to receive(:head_unborn?).and_return(true)
- expect(repository).to receive(:rugged).and_return(rugged)
-
+ it 'returns nil when no license is detected' do
expect(repository.license_key).to be_nil
end
- it 'returns nil when no license is detected' do
+ it 'returns nil when the repository does not exist' do
+ expect(repository).to receive(:exists?).and_return(false)
+
expect(repository.license_key).to be_nil
end
@@ -569,7 +560,7 @@ describe Repository, models: true do
end
end
- describe "#gitlab_ci_yml" do
+ describe "#gitlab_ci_yml", caching: true do
it 'returns valid file' do
files = [TestBlob.new('file'), TestBlob.new('.gitlab-ci.yml'), TestBlob.new('copying')]
expect(repository.tree).to receive(:blobs).and_return(files)
@@ -583,7 +574,7 @@ describe Repository, models: true do
end
it 'returns nil for empty repository' do
- expect(repository).to receive(:empty?).and_return(true)
+ allow(repository).to receive(:file_on_head).and_raise(Rugged::ReferenceError)
expect(repository.gitlab_ci_yml).to be_nil
end
end
@@ -778,7 +769,6 @@ describe Repository, models: true do
expect(repository).not_to receive(:expire_emptiness_caches)
expect(repository).to receive(:expire_branches_cache)
expect(repository).to receive(:expire_has_visible_content_cache)
- expect(repository).to receive(:expire_branch_count_cache)
repository.update_branch_with_hooks(user, 'new-feature') { new_rev }
end
@@ -797,7 +787,6 @@ describe Repository, models: true do
expect(empty_repository).to receive(:expire_emptiness_caches)
expect(empty_repository).to receive(:expire_branches_cache)
expect(empty_repository).to receive(:expire_has_visible_content_cache)
- expect(empty_repository).to receive(:expire_branch_count_cache)
empty_repository.commit_file(user, 'CHANGELOG', 'Changelog!',
'Updates file content', 'master', false)
@@ -811,8 +800,7 @@ describe Repository, models: true do
end
it 'returns false when a repository does not exist' do
- expect(repository.raw_repository).to receive(:rugged).
- and_raise(Gitlab::Git::Repository::NoRepository)
+ allow(repository).to receive(:refs_directory_exists?).and_return(false)
expect(repository.exists?).to eq(false)
end
@@ -916,34 +904,6 @@ describe Repository, models: true do
end
end
- describe '#expire_cache' do
- it 'expires all caches' do
- expect(repository).to receive(:expire_branch_cache)
-
- repository.expire_cache
- end
-
- it 'expires the caches for a specific branch' do
- expect(repository).to receive(:expire_branch_cache).with('master')
-
- repository.expire_cache('master')
- end
-
- it 'expires the emptiness caches for an empty repository' do
- expect(repository).to receive(:empty?).and_return(true)
- expect(repository).to receive(:expire_emptiness_caches)
-
- repository.expire_cache
- end
-
- it 'does not expire the emptiness caches for a non-empty repository' do
- expect(repository).to receive(:empty?).and_return(false)
- expect(repository).not_to receive(:expire_emptiness_caches)
-
- repository.expire_cache
- end
- end
-
describe '#expire_root_ref_cache' do
it 'expires the root reference cache' do
repository.root_ref
@@ -1003,12 +963,23 @@ describe Repository, models: true do
describe '#expire_emptiness_caches' do
let(:cache) { repository.send(:cache) }
- it 'expires the caches' do
+ it 'expires the caches for an empty repository' do
+ allow(repository).to receive(:empty?).and_return(true)
+
expect(cache).to receive(:expire).with(:empty?)
expect(repository).to receive(:expire_has_visible_content_cache)
repository.expire_emptiness_caches
end
+
+ it 'does not expire the cache for a non-empty repository' do
+ allow(repository).to receive(:empty?).and_return(false)
+
+ expect(cache).not_to receive(:expire).with(:empty?)
+ expect(repository).not_to receive(:expire_has_visible_content_cache)
+
+ repository.expire_emptiness_caches
+ end
end
describe :skip_merged_commit do
@@ -1120,24 +1091,12 @@ describe Repository, models: true do
repository.before_delete
end
- it 'flushes the tag count cache' do
- expect(repository).to receive(:expire_tag_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the branches cache' do
expect(repository).to receive(:expire_branches_cache)
repository.before_delete
end
- it 'flushes the branch count cache' do
- expect(repository).to receive(:expire_branch_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the root ref cache' do
expect(repository).to receive(:expire_root_ref_cache)
@@ -1162,36 +1121,18 @@ describe Repository, models: true do
allow(repository).to receive(:exists?).and_return(true)
end
- it 'flushes the caches that depend on repository data' do
- expect(repository).to receive(:expire_cache)
-
- repository.before_delete
- end
-
it 'flushes the tags cache' do
expect(repository).to receive(:expire_tags_cache)
repository.before_delete
end
- it 'flushes the tag count cache' do
- expect(repository).to receive(:expire_tag_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the branches cache' do
expect(repository).to receive(:expire_branches_cache)
repository.before_delete
end
- it 'flushes the branch count cache' do
- expect(repository).to receive(:expire_branch_count_cache)
-
- repository.before_delete
- end
-
it 'flushes the root ref cache' do
expect(repository).to receive(:expire_root_ref_cache)
@@ -1222,8 +1163,9 @@ describe Repository, models: true do
describe '#before_push_tag' do
it 'flushes the cache' do
- expect(repository).to receive(:expire_cache)
- expect(repository).to receive(:expire_tag_count_cache)
+ expect(repository).to receive(:expire_statistics_caches)
+ expect(repository).to receive(:expire_emptiness_caches)
+ expect(repository).to receive(:expire_tags_cache)
repository.before_push_tag
end
@@ -1240,17 +1182,23 @@ describe Repository, models: true do
describe '#after_import' do
it 'flushes and builds the cache' do
expect(repository).to receive(:expire_content_cache)
- expect(repository).to receive(:build_cache)
+ expect(repository).to receive(:expire_tags_cache)
+ expect(repository).to receive(:expire_branches_cache)
repository.after_import
end
end
describe '#after_push_commit' do
- it 'flushes the cache' do
- expect(repository).to receive(:expire_cache).with('master', '123')
+ it 'expires statistics caches' do
+ expect(repository).to receive(:expire_statistics_caches).
+ and_call_original
- repository.after_push_commit('master', '123')
+ expect(repository).to receive(:expire_branch_cache).
+ with('master').
+ and_call_original
+
+ repository.after_push_commit('master')
end
end
@@ -1302,7 +1250,8 @@ describe Repository, models: true do
describe '#before_remove_tag' do
it 'flushes the tag cache' do
- expect(repository).to receive(:expire_tag_count_cache)
+ expect(repository).to receive(:expire_tags_cache).and_call_original
+ expect(repository).to receive(:expire_statistics_caches).and_call_original
repository.before_remove_tag
end
@@ -1320,23 +1269,23 @@ describe Repository, models: true do
end
end
- describe '#expire_branch_count_cache' do
- let(:cache) { repository.send(:cache) }
-
+ describe '#expire_branches_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:branch_count)
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(branch_names branch_count)).
+ and_call_original
- repository.expire_branch_count_cache
+ repository.expire_branches_cache
end
end
- describe '#expire_tag_count_cache' do
- let(:cache) { repository.send(:cache) }
-
+ describe '#expire_tags_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:tag_count)
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(tag_names tag_count)).
+ and_call_original
- repository.expire_tag_count_cache
+ repository.expire_tags_cache
end
end
@@ -1412,170 +1361,316 @@ describe Repository, models: true do
describe '#avatar' do
it 'returns nil if repo does not exist' do
- expect(repository).to receive(:exists?).and_return(false)
+ expect(repository).to receive(:file_on_head).
+ and_raise(Rugged::ReferenceError)
expect(repository.avatar).to eq(nil)
end
it 'returns the first avatar file found in the repository' do
- expect(repository).to receive(:blob_at_branch).
- with('master', 'logo.png').
- and_return(true)
+ expect(repository).to receive(:file_on_head).
+ with(:avatar).
+ and_return(double(:tree, path: 'logo.png'))
expect(repository.avatar).to eq('logo.png')
end
it 'caches the output' do
- allow(repository).to receive(:blob_at_branch).
- with('master', 'logo.png').
- and_return(true)
-
- expect(repository.avatar).to eq('logo.png')
+ expect(repository).to receive(:file_on_head).
+ with(:avatar).
+ once.
+ and_return(double(:tree, path: 'logo.png'))
- expect(repository).not_to receive(:blob_at_branch)
- expect(repository.avatar).to eq('logo.png')
+ 2.times { expect(repository.avatar).to eq('logo.png') }
end
end
- describe '#expire_avatar_cache' do
+ describe '#expire_exists_cache' do
let(:cache) { repository.send(:cache) }
- before do
- allow(repository).to receive(:cache).and_return(cache)
+ it 'expires the cache' do
+ expect(cache).to receive(:expire).with(:exists?)
+
+ repository.expire_exists_cache
end
+ end
- context 'without a branch or revision' do
- it 'flushes the cache' do
- expect(cache).to receive(:expire).with(:avatar)
+ describe "#keep_around" do
+ it "does not fail if we attempt to reference bad commit" do
+ expect(repository.kept_around?('abc1234')).to be_falsey
+ end
- repository.expire_avatar_cache
- end
+ it "stores a reference to the specified commit sha so it isn't garbage collected" do
+ repository.keep_around(sample_commit.id)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_truthy
+ end
+
+ it "attempting to call keep_around on truncated ref does not fail" do
+ repository.keep_around(sample_commit.id)
+ ref = repository.send(:keep_around_ref_name, sample_commit.id)
+ path = File.join(repository.path, ref)
+ # Corrupt the reference
+ File.truncate(path, 0)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_falsey
+
+ repository.keep_around(sample_commit.id)
+
+ expect(repository.kept_around?(sample_commit.id)).to be_falsey
+
+ File.delete(path)
+ end
+ end
+
+ describe '#update_ref!' do
+ it 'can create a ref' do
+ repository.update_ref!('refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+
+ expect(repository.find_branch('foobar')).not_to be_nil
end
- context 'with a branch' do
- it 'does not flush the cache if the branch is not the default branch' do
- expect(cache).not_to receive(:expire)
+ it 'raises CommitError when the ref update fails' do
+ expect do
+ repository.update_ref!('refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+ end.to raise_error(Repository::CommitError)
+ end
+ end
+
+ describe '#contribution_guide', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:contributing).
+ and_return(Gitlab::Git::Tree.new(path: 'CONTRIBUTING.md')).
+ once
- repository.expire_avatar_cache('cats')
+ 2.times do
+ expect(repository.contribution_guide).
+ to be_an_instance_of(Gitlab::Git::Tree)
end
+ end
+ end
- it 'flushes the cache if the branch equals the default branch' do
- expect(cache).to receive(:expire).with(:avatar)
+ describe '#gitignore', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:gitignore).
+ and_return(Gitlab::Git::Tree.new(path: '.gitignore')).
+ once
- repository.expire_avatar_cache(repository.root_ref)
+ 2.times do
+ expect(repository.gitignore).to be_an_instance_of(Gitlab::Git::Tree)
end
end
+ end
- context 'with a branch and revision' do
- let(:commit) { double(:commit) }
+ describe '#koding_yml', caching: true do
+ it 'returns and caches the output' do
+ expect(repository).to receive(:file_on_head).
+ with(:koding).
+ and_return(Gitlab::Git::Tree.new(path: '.koding.yml')).
+ once
- before do
- allow(repository).to receive(:commit).and_return(commit)
+ 2.times do
+ expect(repository.koding_yml).to be_an_instance_of(Gitlab::Git::Tree)
end
+ end
+ end
- it 'does not flush the cache if the commit does not change any logos' do
- diff = double(:diff, new_path: 'test.txt')
+ describe '#readme', caching: true do
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:tree).with(:head).and_return(nil)
- expect(commit).to receive(:raw_diffs).and_return([diff])
- expect(cache).not_to receive(:expire)
+ expect(repository.readme).to be_nil
+ end
+ end
- repository.expire_avatar_cache(repository.root_ref, '123')
+ context 'with an existing repository' do
+ it 'returns the README' do
+ expect(repository.readme).to be_an_instance_of(Gitlab::Git::Blob)
end
+ end
+ end
- it 'flushes the cache if the commit changes any of the logos' do
- diff = double(:diff, new_path: Repository::AVATAR_FILES[0])
+ describe '#expire_statistics_caches' do
+ it 'expires the caches' do
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(size commit_count))
+
+ repository.expire_statistics_caches
+ end
+ end
- expect(commit).to receive(:raw_diffs).and_return([diff])
- expect(cache).to receive(:expire).with(:avatar)
+ describe '#expire_method_caches' do
+ it 'expires the caches of the given methods' do
+ expect_any_instance_of(RepositoryCache).to receive(:expire).with(:readme)
+ expect_any_instance_of(RepositoryCache).to receive(:expire).with(:gitignore)
- repository.expire_avatar_cache(repository.root_ref, '123')
- end
+ repository.expire_method_caches(%i(readme gitignore))
end
end
- describe '#expire_exists_cache' do
- let(:cache) { repository.send(:cache) }
+ describe '#expire_all_method_caches' do
+ it 'expires the caches of all methods' do
+ expect(repository).to receive(:expire_method_caches).
+ with(Repository::CACHED_METHODS)
+
+ repository.expire_all_method_caches
+ end
+ end
+ describe '#expire_avatar_cache' do
it 'expires the cache' do
- expect(cache).to receive(:expire).with(:exists?)
+ expect(repository).to receive(:expire_method_caches).with(%i(avatar))
- repository.expire_exists_cache
+ repository.expire_avatar_cache
end
end
- describe '#build_cache' do
- let(:cache) { repository.send(:cache) }
+ describe '#file_on_head' do
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:tree).with(:head).and_return(nil)
- it 'builds the caches if they do not already exist' do
- cache_keys = repository.cache_keys + repository.cache_keys_for_branches_and_tags
+ expect(repository.file_on_head(:readme)).to be_nil
+ end
+ end
- expect(cache).to receive(:exist?).
- exactly(cache_keys.length).
- times.
- and_return(false)
+ context 'with a repository that has no blobs' do
+ it 'returns nil' do
+ expect_any_instance_of(Tree).to receive(:blobs).and_return([])
+
+ expect(repository.file_on_head(:readme)).to be_nil
+ end
+ end
+
+ context 'with an existing repository' do
+ it 'returns a Gitlab::Git::Tree' do
+ expect(repository.file_on_head(:readme)).
+ to be_an_instance_of(Gitlab::Git::Tree)
+ end
+ end
+ end
+
+ describe '#head_tree' do
+ context 'with an existing repository' do
+ it 'returns a Tree' do
+ expect(repository.head_tree).to be_an_instance_of(Tree)
+ end
+ end
+
+ context 'with a non-existing repository' do
+ it 'returns nil' do
+ expect(repository).to receive(:head_commit).and_return(nil)
+
+ expect(repository.head_tree).to be_nil
+ end
+ end
+ end
- cache_keys.each do |key|
- expect(repository).to receive(key)
+ describe '#tree' do
+ context 'using a non-existing repository' do
+ before do
+ allow(repository).to receive(:head_commit).and_return(nil)
+ end
+
+ it 'returns nil' do
+ expect(repository.tree(:head)).to be_nil
end
- repository.build_cache
+ it 'returns nil when using a path' do
+ expect(repository.tree(:head, 'README.md')).to be_nil
+ end
end
- it 'does not build any caches that already exist' do
- cache_keys = repository.cache_keys + repository.cache_keys_for_branches_and_tags
+ context 'using an existing repository' do
+ it 'returns a Tree' do
+ expect(repository.tree(:head)).to be_an_instance_of(Tree)
+ end
+ end
+ end
- expect(cache).to receive(:exist?).
- exactly(cache_keys.length).
- times.
- and_return(true)
+ describe '#size' do
+ context 'with a non-existing repository' do
+ it 'returns 0' do
+ expect(repository).to receive(:exists?).and_return(false)
- cache_keys.each do |key|
- expect(repository).not_to receive(key)
+ expect(repository.size).to eq(0.0)
end
+ end
- repository.build_cache
+ context 'with an existing repository' do
+ it 'returns the repository size as a Float' do
+ expect(repository.size).to be_an_instance_of(Float)
+ end
end
end
- describe "#keep_around" do
- it "does not fail if we attempt to reference bad commit" do
- expect(repository.kept_around?('abc1234')).to be_falsey
+ describe '#commit_count' do
+ context 'with a non-existing repository' do
+ it 'returns 0' do
+ expect(repository).to receive(:root_ref).and_return(nil)
+
+ expect(repository.commit_count).to eq(0)
+ end
end
- it "stores a reference to the specified commit sha so it isn't garbage collected" do
- repository.keep_around(sample_commit.id)
+ context 'with an existing repository' do
+ it 'returns the commit count' do
+ expect(repository.commit_count).to be_an_instance_of(Fixnum)
+ end
+ end
+ end
- expect(repository.kept_around?(sample_commit.id)).to be_truthy
+ describe '#cache_method_output', caching: true do
+ context 'with a non-existing repository' do
+ let(:value) do
+ repository.cache_method_output(:cats, fallback: 10) do
+ raise Rugged::ReferenceError
+ end
+ end
+
+ it 'returns a fallback value' do
+ expect(value).to eq(10)
+ end
+
+ it 'does not cache the data' do
+ value
+
+ expect(repository.instance_variable_defined?(:@cats)).to eq(false)
+ expect(repository.send(:cache).exist?(:cats)).to eq(false)
+ end
end
- it "attempting to call keep_around on truncated ref does not fail" do
- repository.keep_around(sample_commit.id)
- ref = repository.send(:keep_around_ref_name, sample_commit.id)
- path = File.join(repository.path, ref)
- # Corrupt the reference
- File.truncate(path, 0)
+ context 'with an existing repository' do
+ it 'caches the output' do
+ object = double
- expect(repository.kept_around?(sample_commit.id)).to be_falsey
+ expect(object).to receive(:number).once.and_return(10)
- repository.keep_around(sample_commit.id)
+ 2.times do
+ val = repository.cache_method_output(:cats) { object.number }
- expect(repository.kept_around?(sample_commit.id)).to be_falsey
+ expect(val).to eq(10)
+ end
- File.delete(path)
+ expect(repository.send(:cache).exist?(:cats)).to eq(true)
+ expect(repository.instance_variable_get(:@cats)).to eq(10)
+ end
end
end
- describe '#update_ref!' do
- it 'can create a ref' do
- repository.update_ref!('refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
+ describe '#refresh_method_caches' do
+ it 'refreshes the caches of the given types' do
+ expect(repository).to receive(:expire_method_caches).
+ with(%i(readme license_blob license_key))
- expect(repository.find_branch('foobar')).not_to be_nil
- end
+ expect(repository).to receive(:readme)
+ expect(repository).to receive(:license_blob)
+ expect(repository).to receive(:license_key)
- it 'raises CommitError when the ref update fails' do
- expect do
- repository.update_ref!('refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA)
- end.to raise_error(Repository::CommitError)
+ repository.refresh_method_caches(%i(readme license))
end
end
end
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 8f605757186..fe6b875b997 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -14,7 +14,7 @@ describe API::API, api: true do
describe "GET /projects/:id/repository/branches" do
it "returns an array of project branches" do
- project.repository.expire_cache
+ project.repository.expire_all_method_caches
get api("/projects/#{project.id}/repository/branches", user)
expect(response).to have_http_status(200)
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 01148f0a05e..1c25fd04339 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -3,10 +3,12 @@ require 'rails_helper'
describe API::API, api: true do
include ApiHelpers
+ let(:project) { create(:empty_project, :public) }
+ let(:admin) { create(:admin) }
+
describe 'GET /projects/:project_id/snippets/:id' do
# TODO (rspeicher): Deprecated; remove in 9.0
it 'always exposes expires_at as nil' do
- admin = create(:admin)
snippet = create(:project_snippet, author: admin)
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin)
@@ -17,9 +19,9 @@ describe API::API, api: true do
end
describe 'GET /projects/:project_id/snippets/' do
+ let(:user) { create(:user) }
+
it 'returns all snippets available to team member' do
- project = create(:project, :public)
- user = create(:user)
project.team << [user, :developer]
public_snippet = create(:project_snippet, :public, project: project)
internal_snippet = create(:project_snippet, :internal, project: project)
@@ -34,8 +36,6 @@ describe API::API, api: true do
end
it 'hides private snippets from regular user' do
- project = create(:project, :public)
- user = create(:user)
create(:project_snippet, :private, project: project)
get api("/projects/#{project.id}/snippets/", user)
@@ -45,16 +45,16 @@ describe API::API, api: true do
end
describe 'POST /projects/:project_id/snippets/' do
- it 'creates a new snippet' do
- admin = create(:admin)
- project = create(:project)
- params = {
+ let(:params) do
+ {
title: 'Test Title',
file_name: 'test.rb',
code: 'puts "hello world"',
visibility_level: Gitlab::VisibilityLevel::PUBLIC
}
+ end
+ it 'creates a new snippet' do
post api("/projects/#{project.id}/snippets/", admin), params
expect(response).to have_http_status(201)
@@ -64,12 +64,20 @@ describe API::API, api: true do
expect(snippet.file_name).to eq(params[:file_name])
expect(snippet.visibility_level).to eq(params[:visibility_level])
end
+
+ it 'returns 400 for missing parameters' do
+ params.delete(:title)
+
+ post api("/projects/#{project.id}/snippets/", admin), params
+
+ expect(response).to have_http_status(400)
+ end
end
describe 'PUT /projects/:project_id/snippets/:id/' do
+ let(:snippet) { create(:project_snippet, author: admin) }
+
it 'updates snippet' do
- admin = create(:admin)
- snippet = create(:project_snippet, author: admin)
new_content = 'New content'
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content
@@ -78,9 +86,24 @@ describe API::API, api: true do
snippet.reload
expect(snippet.content).to eq(new_content)
end
+
+ it 'returns 404 for invalid snippet id' do
+ put api("/projects/#{snippet.project.id}/snippets/1234", admin), title: 'foo'
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
+
+ it 'returns 400 for missing parameters' do
+ put api("/projects/#{project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(400)
+ end
end
describe 'DELETE /projects/:project_id/snippets/:id/' do
+ let(:snippet) { create(:project_snippet, author: admin) }
+
it 'deletes snippet' do
admin = create(:admin)
snippet = create(:project_snippet, author: admin)
@@ -89,18 +112,31 @@ describe API::API, api: true do
expect(response).to have_http_status(200)
end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/projects/#{snippet.project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
end
describe 'GET /projects/:project_id/snippets/:id/raw' do
- it 'returns raw text' do
- admin = create(:admin)
- snippet = create(:project_snippet, author: admin)
+ let(:snippet) { create(:project_snippet, author: admin) }
+ it 'returns raw text' do
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", admin)
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'text/plain'
expect(response.body).to eq(snippet.content)
end
+
+ it 'returns 404 for invalid snippet id' do
+ delete api("/projects/#{snippet.project.id}/snippets/1234", admin)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
end
end
diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb
index a611c5e3823..a09d8689ff2 100644
--- a/spec/requests/ci/api/builds_spec.rb
+++ b/spec/requests/ci/api/builds_spec.rb
@@ -17,6 +17,10 @@ describe Ci::API::API do
let!(:build) { create(:ci_build, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
let(:user_agent) { 'gitlab-ci-multi-runner 1.5.2 (1-5-stable; go1.6.3; linux/amd64)' }
+ before do
+ stub_container_registry_config(enabled: false)
+ end
+
shared_examples 'no builds available' do
context 'when runner sends version in User-Agent' do
context 'for stable version' do
@@ -53,6 +57,41 @@ describe Ci::API::API do
it 'updates runner info' do
expect { register_builds }.to change { runner.reload.contacted_at }
end
+
+ context 'registry credentials' do
+ let(:registry_credentials) do
+ { 'type' => 'registry',
+ 'url' => 'registry.example.com:5005',
+ 'username' => 'gitlab-ci-token',
+ 'password' => build.token }
+ end
+
+ context 'when registry is enabled' do
+ before do
+ stub_container_registry_config(enabled: true, host_port: 'registry.example.com:5005')
+ end
+
+ it 'sends registry credentials key' do
+ register_builds info: { platform: :darwin }
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).to include(registry_credentials)
+ end
+ end
+
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false, host_port: 'registry.example.com:5005')
+ end
+
+ it 'does not send registry credentials' do
+ register_builds info: { platform: :darwin }
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).not_to include(registry_credentials)
+ end
+ end
+ end
end
context 'when builds are finished' do
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 7aba4f08088..f15c45cbaac 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -261,7 +261,7 @@ describe "Authentication", "routing" do
end
describe "Groups", "routing" do
- let(:name) { 'complex.group-name' }
+ let(:name) { 'complex.group-namegit' }
it "to #show" do
expect(get("/groups/#{name}")).to route_to('groups#show', id: name)
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index 62f9982e840..9d7702f5c96 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -27,27 +27,14 @@ describe GitPushService, services: true do
it { is_expected.to be_truthy }
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
- it 'flushes the visible content cache' do
- expect(project.repository).to receive(:expire_has_visible_content_cache)
-
- subject
- end
-
- it 'flushes the branches cache' do
- expect(project.repository).to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'flushes the branch count cache' do
- expect(project.repository).to receive(:expire_branch_count_cache)
+ it 'calls the after_create_branch hook' do
+ expect(project.repository).to receive(:after_create_branch)
subject
end
@@ -56,21 +43,8 @@ describe GitPushService, services: true do
context 'existing branch' do
it { is_expected.to be_truthy }
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
-
- subject
- end
-
- it 'does not flush the branches cache' do
- expect(project.repository).not_to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'does not flush the branch count cache' do
- expect(project.repository).not_to receive(:expire_branch_count_cache)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
@@ -81,27 +55,14 @@ describe GitPushService, services: true do
it { is_expected.to be_truthy }
- it 'flushes the visible content cache' do
- expect(project.repository).to receive(:expire_has_visible_content_cache)
-
- subject
- end
-
- it 'flushes the branches cache' do
- expect(project.repository).to receive(:expire_branches_cache)
-
- subject
- end
-
- it 'flushes the branch count cache' do
- expect(project.repository).to receive(:expire_branch_count_cache)
+ it 'calls the after_push_commit hook' do
+ expect(project.repository).to receive(:after_push_commit).with('master')
subject
end
- it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache).
- with('master', newrev)
+ it 'calls the after_remove_branch hook' do
+ expect(project.repository).to receive(:after_remove_branch)
subject
end
@@ -598,6 +559,51 @@ describe GitPushService, services: true do
end
end
+ describe '#update_caches' do
+ let(:service) do
+ described_class.new(project,
+ user,
+ oldrev: sample_commit.parent_id,
+ newrev: sample_commit.id,
+ ref: 'refs/heads/master')
+ end
+
+ context 'on the default branch' do
+ before do
+ allow(service).to receive(:is_default_branch?).and_return(true)
+ end
+
+ it 'flushes the caches of any special files that have been changed' do
+ commit = double(:commit)
+ diff = double(:diff, new_path: 'README.md')
+
+ expect(commit).to receive(:raw_diffs).with(deltas_only: true).
+ and_return([diff])
+
+ service.push_commits = [commit]
+
+ expect(ProjectCacheWorker).to receive(:perform_async).
+ with(project.id, %i(readme))
+
+ service.update_caches
+ end
+ end
+
+ context 'on a non-default branch' do
+ before do
+ allow(service).to receive(:is_default_branch?).and_return(false)
+ end
+
+ it 'does not flush any conditional caches' do
+ expect(ProjectCacheWorker).to receive(:perform_async).
+ with(project.id, []).
+ and_call_original
+
+ service.update_caches
+ end
+ end
+ end
+
def execute_service(project, user, oldrev, newrev, ref)
service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref )
service.execute
diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb
index 0879e3ab4c8..bd074b9bd71 100644
--- a/spec/services/git_tag_push_service_spec.rb
+++ b/spec/services/git_tag_push_service_spec.rb
@@ -18,7 +18,7 @@ describe GitTagPushService, services: true do
end
it 'flushes general cached data' do
- expect(project.repository).to receive(:expire_cache)
+ expect(project.repository).to receive(:before_push_tag)
subject
end
@@ -28,12 +28,6 @@ describe GitTagPushService, services: true do
subject
end
-
- it 'flushes the tag count cache' do
- expect(project.repository).to receive(:expire_tag_count_cache)
-
- subject
- end
end
describe "Git Tag Push Data" do
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 0220f7e1db2..e515bc9f89c 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -227,16 +227,6 @@ describe MergeRequests::RefreshService, services: true do
end
end
- context 'when the source branch is deleted' do
- it 'does not create a MergeRequestDiff record' do
- refresh_service = service.new(@project, @user)
-
- expect do
- refresh_service.execute(@oldrev, Gitlab::Git::BLANK_SHA, 'refs/heads/master')
- end.not_to change { MergeRequestDiff.count }
- end
- end
-
def reload_mrs
@merge_request.reload
@fork_merge_request.reload
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 150e21574a1..2a5709c6322 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -543,7 +543,10 @@ describe SystemNoteService, services: true do
let(:comment_url) { jira_api_comment_url(jira_issue.id) }
let(:success_message) { "JiraService SUCCESS: Successfully posted to http://jira.example.net." }
- before { stub_jira_urls(jira_issue.id) }
+ before do
+ stub_jira_urls(jira_issue.id)
+ jira_service_settings
+ end
noteable_types = ["merge_requests", "commit"]
@@ -569,16 +572,16 @@ describe SystemNoteService, services: true do
end
end
- context 'in JIRA issue tracker' do
- before { jira_service_settings }
-
- describe "new reference" do
- subject { described_class.cross_reference(jira_issue, commit, author) }
+ describe "new reference" do
+ context 'for commits' do
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, commit, author)
- it { is_expected.to eq(success_message) }
+ expect(result).to eq(success_message)
+ end
it "creates remote link" do
- subject
+ described_class.cross_reference(jira_issue, commit, author)
expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
body: hash_including(
@@ -593,18 +596,18 @@ describe SystemNoteService, services: true do
).once
end
end
- end
- context 'in commit' do
- context 'in JIRA issue tracker' do
- before { jira_service_settings }
+ context 'for issues' do
+ let(:issue) { create(:issue, project: project) }
- subject { described_class.cross_reference(jira_issue, issue, author) }
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, issue, author)
- it { is_expected.to eq(success_message) }
+ expect(result).to eq(success_message)
+ end
it "creates remote link" do
- subject
+ described_class.cross_reference(jira_issue, issue, author)
expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
body: hash_including(
@@ -619,6 +622,32 @@ describe SystemNoteService, services: true do
).once
end
end
+
+ context 'for snippets' do
+ let(:snippet) { create(:snippet, project: project) }
+
+ it "creates comment" do
+ result = described_class.cross_reference(jira_issue, snippet, author)
+
+ expect(result).to eq(success_message)
+ end
+
+ it "creates remote link" do
+ described_class.cross_reference(jira_issue, snippet, author)
+
+ expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with(
+ body: hash_including(
+ GlobalID: "GitLab",
+ object: {
+ url: namespace_project_snippet_url(project.namespace, project, snippet),
+ title: "GitLab: Mentioned on snippet - #{snippet.title}",
+ icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ status: { resolved: false }
+ }
+ )
+ ).once
+ end
+ end
end
describe "existing reference" do
@@ -627,9 +656,11 @@ describe SystemNoteService, services: true do
allow_any_instance_of(JIRA::Resource::Issue).to receive(:comments).and_return([OpenStruct.new(body: message)])
end
- subject { described_class.cross_reference(jira_issue, commit, author) }
+ it "does not return success message" do
+ result = described_class.cross_reference(jira_issue, commit, author)
- it { is_expected.not_to eq(success_message) }
+ expect(result).not_to eq(success_message)
+ end
it 'does not try to create comment and remote link' do
subject
diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb
index bfa8c0ff2c6..855c28b584e 100644
--- a/spec/workers/project_cache_worker_spec.rb
+++ b/spec/workers/project_cache_worker_spec.rb
@@ -2,62 +2,78 @@ require 'spec_helper'
describe ProjectCacheWorker do
let(:project) { create(:project) }
+ let(:worker) { described_class.new }
- subject { described_class.new }
-
- describe '.perform_async' do
- it 'schedules the job when no lease exists' do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:exists?).
- and_return(false)
+ describe '#perform' do
+ before do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).
+ and_return(true)
+ end
- expect_any_instance_of(described_class).to receive(:perform)
+ context 'with a non-existing project' do
+ it 'does nothing' do
+ expect(worker).not_to receive(:update_repository_size)
- described_class.perform_async(project.id)
+ worker.perform(-1)
+ end
end
- it 'does not schedule the job when a lease exists' do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:exists?).
- and_return(true)
+ context 'with an existing project without a repository' do
+ it 'does nothing' do
+ allow_any_instance_of(Repository).to receive(:exists?).and_return(false)
- expect_any_instance_of(described_class).not_to receive(:perform)
+ expect(worker).not_to receive(:update_repository_size)
- described_class.perform_async(project.id)
+ worker.perform(project.id)
+ end
end
- end
- describe '#perform' do
- context 'when an exclusive lease can be obtained' do
- before do
- allow(subject).to receive(:try_obtain_lease_for).with(project.id).
- and_return(true)
- end
+ context 'with an existing project' do
+ it 'updates the repository size' do
+ expect(worker).to receive(:update_repository_size).and_call_original
- it 'updates project cache data' do
- expect_any_instance_of(Repository).to receive(:size)
- expect_any_instance_of(Repository).to receive(:commit_count)
+ worker.perform(project.id)
+ end
- expect_any_instance_of(Project).to receive(:update_repository_size)
- expect_any_instance_of(Project).to receive(:update_commit_count)
+ it 'updates the commit count' do
+ expect_any_instance_of(Project).to receive(:update_commit_count).
+ and_call_original
- subject.perform(project.id)
+ worker.perform(project.id)
end
- it 'handles missing repository data' do
- expect_any_instance_of(Repository).to receive(:exists?).and_return(false)
- expect_any_instance_of(Repository).not_to receive(:size)
+ it 'refreshes the method caches' do
+ expect_any_instance_of(Repository).to receive(:refresh_method_caches).
+ with(%i(readme)).
+ and_call_original
- subject.perform(project.id)
+ worker.perform(project.id, %i(readme))
end
end
+ end
- context 'when an exclusive lease can not be obtained' do
- it 'does nothing' do
- allow(subject).to receive(:try_obtain_lease_for).with(project.id).
+ describe '#update_repository_size' do
+ context 'when a lease could not be obtained' do
+ it 'does not update the repository size' do
+ allow(worker).to receive(:try_obtain_lease_for).
+ with(project.id, :update_repository_size).
and_return(false)
- expect(subject).not_to receive(:update_caches)
+ expect(project).not_to receive(:update_repository_size)
+
+ worker.update_repository_size(project)
+ end
+ end
+
+ context 'when a lease could be obtained' do
+ it 'updates the repository size' do
+ allow(worker).to receive(:try_obtain_lease_for).
+ with(project.id, :update_repository_size).
+ and_return(true)
+
+ expect(project).to receive(:update_repository_size).and_call_original
- subject.perform(project.id)
+ worker.update_repository_size(project)
end
end
end