summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml1
-rw-r--r--app/assets/images/i2p-step.svg4
-rw-r--r--app/assets/javascripts/dispatcher.js3
-rw-r--r--app/assets/javascripts/environments/components/environment.vue2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js15
-rw-r--r--app/assets/javascripts/importer_status.js2
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue28
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue56
-rw-r--r--app/assets/javascripts/issue_show/index.js3
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js9
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js4
-rw-r--r--app/assets/javascripts/locale/zh_CN/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_HK/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_TW/app.js1
-rw-r--r--app/assets/javascripts/notes.js101
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/user_callout.js11
-rw-r--r--app/assets/stylesheets/framework/mobile.scss5
-rw-r--r--app/assets/stylesheets/framework/variables.scss7
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss255
-rw-r--r--app/assets/stylesheets/pages/profile.scss25
-rw-r--r--app/assets/stylesheets/pages/projects.scss6
-rw-r--r--app/controllers/admin/conversational_development_index_controller.rb5
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/concerns/lfs_request.rb4
-rw-r--r--app/controllers/projects/git_http_client_controller.rb24
-rw-r--r--app/controllers/projects/git_http_controller.rb75
-rw-r--r--app/controllers/projects/imports_controller.rb9
-rw-r--r--app/controllers/projects/issues_controller.rb13
-rw-r--r--app/controllers/projects/jobs_controller.rb11
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb6
-rw-r--r--app/controllers/projects/protected_tags_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb2
-rw-r--r--app/helpers/application_helper.rb6
-rw-r--r--app/helpers/conversational_development_index_helper.rb16
-rw-r--r--app/helpers/issuables_helper.rb20
-rw-r--r--app/helpers/submodule_helper.rb11
-rw-r--r--app/models/abuse_report.rb3
-rw-r--r--app/models/application_setting.rb6
-rw-r--r--app/models/ci/build.rb60
-rw-r--r--app/models/commit_status.rb12
-rw-r--r--app/models/concerns/editable.rb7
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/protected_ref.rb28
-rw-r--r--app/models/conversational_development_index/card.rb26
-rw-r--r--app/models/conversational_development_index/idea_to_production_step.rb19
-rw-r--r--app/models/conversational_development_index/metric.rb21
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/key.rb9
-rw-r--r--app/models/lfs_objects_project.rb3
-rw-r--r--app/models/member.rb4
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/models/members/project_member.rb4
-rw-r--r--app/models/namespace.rb5
-rw-r--r--app/models/note.rb1
-rw-r--r--app/models/project.rb34
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/protected_tag.rb6
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/spam_log.rb3
-rw-r--r--app/models/user.rb5
-rw-r--r--app/policies/group_policy.rb17
-rw-r--r--app/presenters/conversational_development_index/metric_presenter.rb144
-rw-r--r--app/serializers/build_artifact_entity.rb37
-rw-r--r--app/serializers/build_details_entity.rb50
-rw-r--r--app/serializers/build_entity.rb2
-rw-r--r--app/serializers/merge_request_entity.rb2
-rw-r--r--app/serializers/pipeline_details_entity.rb7
-rw-r--r--app/serializers/pipeline_entity.rb20
-rw-r--r--app/serializers/pipeline_serializer.rb2
-rw-r--r--app/serializers/runner_entity.rb18
-rw-r--r--app/services/create_deployment_service.rb76
-rw-r--r--app/services/projects/create_service.rb5
-rw-r--r--app/services/projects/destroy_service.rb14
-rw-r--r--app/services/submit_usage_ping_service.rb41
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/destroy_service.rb17
-rw-r--r--app/uploaders/lfs_object_uploader.rb16
-rw-r--r--app/views/admin/background_jobs/show.html.haml2
-rw-r--r--app/views/admin/conversational_development_index/_callout.html.haml13
-rw-r--r--app/views/admin/conversational_development_index/_card.html.haml25
-rw-r--r--app/views/admin/conversational_development_index/_disabled.html.haml9
-rw-r--r--app/views/admin/conversational_development_index/_no_data.html.haml7
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml35
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/logs/show.html.haml2
-rw-r--r--app/views/admin/monitoring/_head.html.haml (renamed from app/views/admin/background_jobs/_head.html.haml)4
-rw-r--r--app/views/admin/requests_profiles/index.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml2
-rw-r--r--app/views/admin/users/_user.html.haml14
-rw-r--r--app/views/admin/users/show.html.haml21
-rw-r--r--app/views/dashboard/projects/index.html.haml2
-rw-r--r--app/views/layouts/nav/_admin.html.haml4
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml4
-rw-r--r--app/views/projects/protected_tags/_index.html.haml5
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml4
-rw-r--r--app/views/projects/protected_tags/show.html.haml2
-rw-r--r--app/views/shared/_user_callout.html.haml2
-rw-r--r--app/views/shared/icons/_convdev_no_data.svg40
-rw-r--r--app/views/shared/icons/_convdev_no_index.svg67
-rw-r--r--app/views/shared/icons/_convdev_overview.svg64
-rw-r--r--app/views/shared/icons/_i2p_step_1.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_10.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_2.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_3.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_4.svg6
-rw-r--r--app/views/shared/icons/_i2p_step_5.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_6.svg15
-rw-r--r--app/views/shared/icons/_i2p_step_7.svg7
-rw-r--r--app/views/shared/icons/_i2p_step_8.svg4
-rw-r--r--app/views/shared/icons/_i2p_step_9.svg4
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/build_success_worker.rb11
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb18
-rw-r--r--app/workers/repository_fork_worker.rb38
-rw-r--r--app/workers/repository_import_worker.rb24
-rw-r--r--changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml4
-rw-r--r--changelogs/unreleased/27614-improve-instant-comments-exp.yml4
-rw-r--r--changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml4
-rw-r--r--changelogs/unreleased/29690-rotate-otp-key-base.yml4
-rw-r--r--changelogs/unreleased/30469-convdev-index.yml4
-rw-r--r--changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml4
-rw-r--r--changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml4
-rw-r--r--changelogs/unreleased/32118-new-environment-btn-copy.yml4
-rw-r--r--changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml4
-rw-r--r--changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml4
-rw-r--r--changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml4
-rw-r--r--changelogs/unreleased/dz-fix-submodule-subgroup.yml4
-rw-r--r--changelogs/unreleased/mk-fix-git-over-http-rejections.yml4
-rw-r--r--changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml4
-rw-r--r--changelogs/unreleased/zj-job-view-goes-real-time.yml4
-rw-r--r--config/initializers/forbid_sidekiq_in_transactions.rb49
-rw-r--r--config/routes/admin.rb2
-rw-r--r--config/webpack.config.js14
-rw-r--r--db/fixtures/development/11_keys.rb11
-rw-r--r--db/fixtures/development/14_pipelines.rb4
-rw-r--r--db/fixtures/development/17_cycle_analytics.rb9
-rw-r--r--db/fixtures/development/21_conversational_development_index_metrics.rb40
-rw-r--r--db/migrate/20170523121229_create_conversational_development_index_metrics.rb39
-rw-r--r--db/schema.rb27
-rw-r--r--doc/ci/environments.md6
-rw-r--r--doc/ci/variables/README.md6
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/raketasks/user_management.md79
-rw-r--r--doc/user/profile/account/delete_account.md3
-rw-r--r--lib/api/helpers/internal_helpers.rb16
-rw-r--r--lib/api/internal.rb34
-rw-r--r--lib/api/users.rb2
-rw-r--r--lib/feature.rb12
-rw-r--r--lib/gitlab/checks/change_access.rb48
-rw-r--r--lib/gitlab/ci_access.rb9
-rw-r--r--lib/gitlab/etag_caching/router.rb8
-rw-r--r--lib/gitlab/git/diff.rb30
-rw-r--r--lib/gitlab/git_access.rb92
-rw-r--r--lib/gitlab/git_access_status.rb15
-rw-r--r--lib/gitlab/git_access_wiki.rb12
-rw-r--r--lib/gitlab/i18n.rb5
-rw-r--r--lib/gitlab/otp_key_rotator.rb87
-rw-r--r--lib/tasks/gitlab/two_factor.rake16
-rw-r--r--lib/tasks/import.rake2
-rw-r--r--locale/zh_CN/gitlab.po225
-rw-r--r--locale/zh_CN/gitlab.po.time_stamp0
-rw-r--r--locale/zh_HK/gitlab.po225
-rw-r--r--locale/zh_HK/gitlab.po.time_stamp0
-rw-r--r--locale/zh_TW/gitlab.po225
-rw-r--r--locale/zh_TW/gitlab.po.time_stamp0
-rw-r--r--spec/controllers/admin/users_controller_spec.rb15
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb45
-rw-r--r--spec/controllers/registrations_controller_spec.rb2
-rw-r--r--spec/factories/ci/builds.rb3
-rw-r--r--spec/factories/conversational_development_index_metrics.rb33
-rw-r--r--spec/factories/forked_project_links.rb4
-rw-r--r--spec/factories/projects.rb16
-rw-r--r--spec/features/admin/admin_conversational_development_index_spec.rb40
-rw-r--r--spec/features/admin/admin_users_spec.rb6
-rw-r--r--spec/features/dashboard/projects_spec.rb9
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb17
-rw-r--r--spec/features/merge_requests/conflicts_spec.rb2
-rw-r--r--spec/features/merge_requests/diffs_spec.rb6
-rw-r--r--spec/features/projects/compare_spec.rb1
-rw-r--r--spec/features/projects/environments/environments_spec.rb4
-rw-r--r--spec/helpers/issuables_helper_spec.rb18
-rw-r--r--spec/helpers/submodule_helper_spec.rb8
-rw-r--r--spec/javascripts/environments/environment_spec.js2
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js15
-rw-r--r--spec/javascripts/issue_show/components/edited_spec.js49
-rw-r--r--spec/javascripts/issue_show/mock_data.js12
-rw-r--r--spec/javascripts/lib/utils/ajax_cache_spec.js31
-rw-r--r--spec/javascripts/notes_spec.js133
-rw-r--r--spec/javascripts/pipelines/header_component_spec.js5
-rw-r--r--spec/lib/gitlab/checks/change_access_spec.rb57
-rw-r--r--spec/lib/gitlab/ci_access_spec.rb15
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb11
-rw-r--r--spec/lib/gitlab/git/diff_collection_spec.rb3
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb38
-rw-r--r--spec/lib/gitlab/git_access_spec.rb290
-rw-r--r--spec/lib/gitlab/git_access_wiki_spec.rb7
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml2
-rw-r--r--spec/lib/gitlab/otp_key_rotator_spec.rb70
-rw-r--r--spec/models/abuse_report_spec.rb4
-rw-r--r--spec/models/ci/build_spec.rb97
-rw-r--r--spec/models/concerns/editable_spec.rb11
-rw-r--r--spec/models/project_spec.rb16
-rw-r--r--spec/policies/group_policy_spec.rb32
-rw-r--r--spec/presenters/conversational_development_index/metric_presenter_spec.rb36
-rw-r--r--spec/requests/git_http_spec.rb662
-rw-r--r--spec/requests/lfs_http_spec.rb38
-rw-r--r--spec/serializers/build_artifact_entity_spec.rb14
-rw-r--r--spec/serializers/build_details_entity_spec.rb67
-rw-r--r--spec/serializers/build_entity_spec.rb3
-rw-r--r--spec/serializers/merge_request_entity_spec.rb2
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb120
-rw-r--r--spec/serializers/pipeline_entity_spec.rb48
-rw-r--r--spec/serializers/runner_entity_spec.rb23
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb15
-rw-r--r--spec/services/create_deployment_service_spec.rb246
-rw-r--r--spec/services/projects/create_service_spec.rb8
-rw-r--r--spec/services/submit_usage_ping_service_spec.rb101
-rw-r--r--spec/support/cycle_analytics_helpers.rb43
-rw-r--r--spec/support/git_http_helpers.rb22
-rw-r--r--spec/uploaders/lfs_object_uploader_spec.rb31
-rw-r--r--spec/workers/gitlab_usage_ping_worker_spec.rb18
-rw-r--r--spec/workers/repository_fork_worker_spec.rb26
-rw-r--r--spec/workers/repository_import_worker_spec.rb23
230 files changed, 4921 insertions, 1200 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index dea11bb9f61..b442e48a3d0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -430,6 +430,7 @@ gitlab:assets:compile:
USE_DB: "false"
SKIP_STORAGE_VALIDATION: "true"
WEBPACK_REPORT: "true"
+ NO_COMPRESSION: "true"
script:
- yarn install --pure-lockfile --production --cache-folder .yarn-cache
- bundle exec rake gitlab:assets:compile
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg
new file mode 100644
index 00000000000..8886092ed82
--- /dev/null
+++ b/app/assets/images/i2p-step.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120">
+ <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+</svg>
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 53b25da18e5..baa20d0c34a 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -393,6 +393,9 @@ import ShortcutsBlob from './shortcuts_blob';
case 'users:show':
new UserCallout();
break;
+ case 'admin:conversational_development_index:show':
+ new UserCallout();
+ break;
case 'snippets:show':
new LineHighlighter();
new BlobViewer();
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index c9e489dd90e..28597c799df 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -255,7 +255,7 @@ export default {
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
- New Environment
+ New environment
</a>
</div>
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b8a923cf619..401dec1a370 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import { glEmojiTag } from '~/behaviors/gl_emoji';
import glRegexp from '~/lib/utils/regexp';
+import AjaxCache from '~/lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
@@ -35,6 +36,7 @@ class GfmAutoComplete {
// This triggers at.js again
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
+ $input.on('clear-commands-cache.atwho', () => this.clearCache());
});
}
@@ -375,11 +377,14 @@ class GfmAutoComplete {
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
} else {
- $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => {
- this.loadData($input, at, data);
- }).fail(() => { this.isLoadingData[at] = false; });
+ AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ .then((data) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
}
}
+
loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
@@ -389,6 +394,10 @@ class GfmAutoComplete {
return $input.trigger('keyup');
}
+ clearCache() {
+ this.cachedData = {};
+ }
+
static isLoading(data) {
let dataToInspect = data;
if (data && data.length > 0) {
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 34e4a257ff9..5b4ca94ed30 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -56,6 +56,8 @@
if (job.import_status === 'finished') {
job_item.removeClass("active").addClass("success");
return status_field.html('<span><i class="fa fa-check"></i> done</span>');
+ } else if (job.import_status === 'scheduled') {
+ return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled");
} else if (job.import_status === 'started') {
return status_field.html("<i class='fa fa-spinner fa-spin'></i> started");
} else {
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 800bb9f1fe8..e14414d3f68 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -7,6 +7,7 @@ import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
import formComponent from './form.vue';
import '../../lib/utils/url_utility';
@@ -50,6 +51,21 @@ export default {
required: false,
default: '',
},
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
issuableTemplates: {
type: Array,
required: false,
@@ -86,6 +102,9 @@ export default {
titleText: this.initialTitleText,
descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
});
return {
@@ -98,10 +117,14 @@ export default {
formState() {
return this.store.formState;
},
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
},
components: {
descriptionComponent,
titleComponent,
+ editedComponent,
formComponent,
},
methods: {
@@ -240,6 +263,11 @@ export default {
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 3281ec6b172..5ae617356e0 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -16,11 +16,6 @@
type: String,
required: true,
},
- updatedAt: {
- type: String,
- required: false,
- default: '',
- },
taskStatus: {
type: String,
required: false,
@@ -31,7 +26,6 @@
return {
preAnimation: false,
pulseAnimation: false,
- timeAgoEl: $('.js-issue-edited-ago'),
};
},
watch: {
@@ -39,12 +33,6 @@
this.animateChange();
this.$nextTick(() => {
- const toolTipTime = gl.utils.formatDate(this.updatedAt);
-
- this.timeAgoEl.attr('datetime', this.updatedAt)
- .attr('title', toolTipTime)
- .tooltip('fixTitle');
-
this.renderGFM();
});
},
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
new file mode 100644
index 00000000000..d59e6d11032
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -0,0 +1,56 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small
+ class="edited-text"
+ >
+ Edited
+ <time-ago-tooltip
+ v-if="updatedAt"
+ placement="bottom"
+ :time="updatedAt"
+ />
+ <span
+ v-if="hasUpdatedBy"
+ >
+ by
+ <a
+ class="author_link"
+ :href="updatedByPath"
+ >
+ <span>{{updatedByName}}</span>
+ </a>
+ </span>
+ </small>
+</template>
+
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index faf79471946..14b2a1e18e9 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -42,6 +42,9 @@ document.addEventListener('DOMContentLoaded', () => {
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
},
});
},
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 4a16c3cb4dc..27c2d349f52 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -4,6 +4,9 @@ export default class Store {
titleText,
descriptionHtml,
descriptionText,
+ updatedAt,
+ updatedByName,
+ updatedByPath,
}) {
this.state = {
titleHtml,
@@ -11,7 +14,9 @@ export default class Store {
descriptionHtml,
descriptionText,
taskStatus: '',
- updatedAt: '',
+ updatedAt,
+ updatedByName,
+ updatedByPath,
};
this.formState = {
title: '',
@@ -30,6 +35,8 @@ export default class Store {
this.state.descriptionText = data.description_text;
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
+ this.state.updatedByName = data.updated_by_name;
+ this.state.updatedByPath = data.updated_by_path;
}
stateShouldUpdate(data) {
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index f1fe95e12e8..7477b5a5214 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -6,8 +6,8 @@ class AjaxCache extends Cache {
this.pendingRequests = { };
}
- retrieve(endpoint) {
- if (this.hasData(endpoint)) {
+ retrieve(endpoint, forceRetrieve) {
+ if (this.hasData(endpoint) && !forceRetrieve) {
return Promise.resolve(this.get(endpoint));
}
diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js
new file mode 100644
index 00000000000..9525bc88190
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_CN/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Introducing Cycle Analytics":["周期分析简介"],"Last %d day":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"New Issue":["新议题"],"Not available":["数据不足"],"Not enough data":["数据不足"],"OpenedNDaysAgo|Opened":["开始于"],"Pipeline Health":["流水线健康指标"],"ProjectLifecycle|Stage":["项目生命周期"],"Read more":["了解更多"],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Showing %d event":["显示 %d 个事件"],"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.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"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.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"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.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"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.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"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.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"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.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"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.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"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.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"You need permission.":["您需要相关的权限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js
new file mode 100644
index 00000000000..fd0bcd988c5
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_HK/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Showing %d event":["顯示 %d 個事件"],"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.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"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.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"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.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"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.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"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.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"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.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"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.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"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.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js
new file mode 100644
index 00000000000..79904d17bf6
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_TW/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["送交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Showing %d event":["顯示 %d 個事件"],"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.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"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.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"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.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"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.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"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.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"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.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"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.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"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.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 0ca7cabfc5a..929965de5c1 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -16,6 +16,7 @@ import autosize from 'vendor/autosize';
import Dropzone from 'dropzone';
import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
+import AjaxCache from '~/lib/utils/ajax_cache';
import CommentTypeToggle from './comment_type_toggle';
import './autosave';
import './dropzone_input';
@@ -66,7 +67,6 @@ const normalizeNewlines = function(str) {
this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
- this.flashErrors = [];
this.cleanBinding();
this.addBinding();
@@ -325,6 +325,9 @@ const normalizeNewlines = function(str) {
if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
this.setupNewNote($newNote);
@@ -1118,12 +1121,14 @@ const normalizeNewlines = function(str) {
};
Notes.prototype.addFlash = function(...flashParams) {
- this.flashErrors.push(new Flash(...flashParams));
+ this.flashInstance = new Flash(...flashParams);
};
Notes.prototype.clearFlash = function() {
- this.flashErrors.forEach(flash => flash.flashContainer.remove());
- this.flashErrors = [];
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
};
Notes.prototype.cleanForm = function($form) {
@@ -1187,7 +1192,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.getFormData = function($form) {
return {
formData: $form.serialize(),
- formContent: $form.find('.js-note-text').val(),
+ formContent: _.escape($form.find('.js-note-text').val()),
formAction: $form.attr('action'),
};
};
@@ -1207,19 +1212,46 @@ const normalizeNewlines = function(str) {
};
/**
+ * Gets appropriate description from slash commands found in provided `formContent`
+ */
+ Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) {
+ let tempFormContent;
+
+ // Identify executed slash commands from `formContent`
+ const executedCommands = availableSlashCommands.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
+ }
+ } else {
+ tempFormContent = 'Applying command';
+ }
+
+ return tempFormContent;
+ };
+
+ /**
* Create placeholder note DOM element populated with comment body
* that we will show while comment is being posted.
* Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary element.
*/
- Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
+ Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
const discussionClass = isDiscussionNote ? 'discussion' : '';
- const escapedFormContent = _.escape(formContent);
const $tempNote = $(
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
- <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}">
+ </a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
@@ -1232,7 +1264,7 @@ const normalizeNewlines = function(str) {
</div>
<div class="note-body">
<div class="note-text">
- <p>${escapedFormContent}</p>
+ <p>${formContent}</p>
</div>
</div>
</div>
@@ -1244,6 +1276,23 @@ const normalizeNewlines = function(str) {
};
/**
+ * Create Placeholder System Note DOM element populated with slash command description
+ */
+ Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ };
+
+ /**
* This method does following tasks step-by-step whenever a new comment
* is submitted by user (both main thread comments as well as discussion comments).
*
@@ -1274,7 +1323,9 @@ const normalizeNewlines = function(str) {
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
const { formData, formContent, formAction } = this.getFormData($form);
- const uniqueId = _.uniqueId('tempNote_');
+ let noteUniqueId;
+ let systemNoteUniqueId;
+ let hasSlashCommands = false;
let $notesContainer;
let tempFormContent;
@@ -1295,16 +1346,28 @@ const normalizeNewlines = function(str) {
tempFormContent = formContent;
if (this.hasSlashCommands(formContent)) {
tempFormContent = this.stripSlashCommands(formContent);
+ hasSlashCommands = true;
}
+ // Show placeholder note
if (tempFormContent) {
- // Show placeholder note
+ noteUniqueId = _.uniqueId('tempNote_');
$notesContainer.append(this.createPlaceholderNote({
formContent: tempFormContent,
- uniqueId,
+ uniqueId: noteUniqueId,
isDiscussionNote,
currentUsername: gon.current_username,
currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }));
+ }
+
+ // Show placeholder system note
+ if (hasSlashCommands) {
+ systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ $notesContainer.append(this.createPlaceholderSystemNote({
+ formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
+ uniqueId: systemNoteUniqueId,
}));
}
@@ -1322,7 +1385,13 @@ const normalizeNewlines = function(str) {
gl.utils.ajaxPost(formAction, formData)
.then((note) => {
// Submission successful! remove placeholder
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ // Reset cached commands list when command is applied
+ if (hasSlashCommands) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ }
+
// Clear previous form errors
this.clearFlashWrapper();
@@ -1359,7 +1428,11 @@ const normalizeNewlines = function(str) {
$form.trigger('ajax:success', [note]);
}).fail(() => {
// Submission failed, remove placeholder note and show Flash error message
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ if (hasSlashCommands) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 068e9698e1d..9d045886262 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+ this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown();
this.bindEvents();
@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
- .find('.create-new-protected-tag code')
+ .find('.js-create-new-protected-tag code')
.text(tagName);
}
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index b9d57cbcad4..ff2208baeab 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,11 +1,10 @@
import Cookies from 'js-cookie';
-const USER_CALLOUT_COOKIE = 'user_callout_dismissed';
-
export default class UserCallout {
- constructor() {
- this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE);
- this.userCalloutBody = $('.user-callout');
+ constructor(className = 'user-callout') {
+ this.userCalloutBody = $(`.${className}`);
+ this.cookieName = this.userCalloutBody.data('uid');
+ this.isCalloutDismissed = Cookies.get(this.cookieName);
this.init();
}
@@ -18,7 +17,7 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 });
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 678af978edd..0140dcf19c3 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -112,11 +112,6 @@
}
}
- .issue-edited-ago,
- .note_edited_ago {
- display: none;
- }
-
aside:not(.right-sidebar) {
display: none;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 975a4b40383..b3a86b92d93 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -569,3 +569,10 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
+
+/*
+Convdev Index
+*/
+$color-high-score: $green-400;
+$color-average-score: $orange-400;
+$color-low-score: $red-400;
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
new file mode 100644
index 00000000000..0413114c279
--- /dev/null
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -0,0 +1,255 @@
+$space-between-cards: 8px;
+
+.convdev-empty svg {
+ margin: 64px auto 32px;
+ max-width: 420px;
+}
+
+.convdev-header {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+
+ .convdev-header-title {
+ font-size: 48px;
+ line-height: 1;
+ margin: 0;
+ }
+
+ .convdev-header-subtitle {
+ font-size: 22px;
+ line-height: 1;
+ color: $gl-text-color-secondary;
+ margin-left: 8px;
+ font-weight: 500;
+
+ a {
+ font-size: 18px;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $blue-500;
+ }
+ }
+ }
+}
+
+.convdev-cards {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.convdev-card-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ width: 50%;
+ border-color: $border-color;
+ margin: 0 0 32px;
+ padding: $space-between-cards / 2;
+ position: relative;
+
+ @media (min-width: $screen-xs-min) {
+ width: percentage(1 / 4);
+ }
+
+ @media (min-width: $screen-sm-min) {
+ width: percentage(1 / 5);
+ }
+
+ @media (min-width: $screen-md-min) {
+ width: percentage(1 / 6);
+ }
+
+ @media (min-width: $screen-lg-min) {
+ width: percentage(1 / 10);
+ }
+}
+
+.convdev-card {
+ border: solid 1px $border-color;
+ border-radius: 3px;
+ border-top-width: 3px;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.convdev-card-low {
+ border-top-color: $color-low-score;
+
+ .card-score-big {
+ background-color: $red-25;
+ }
+}
+
+.convdev-card-average {
+ border-top-color: $color-average-score;
+
+ .card-score-big {
+ background-color: $orange-25;
+ }
+}
+
+.convdev-card-high {
+ border-top-color: $color-high-score;
+
+ .card-score-big {
+ background-color: $green-25;
+ }
+}
+
+.convdev-card-title {
+ margin: $gl-padding auto auto;
+ max-width: 100px;
+
+ h3 {
+ font-size: 14px;
+ margin: 0 0 2px;
+ }
+
+ .text-light {
+ font-size: 13px;
+ line-height: 1.25;
+ color: $gl-text-color-secondary;
+ }
+}
+
+.card-scores {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin: $gl-padding $gl-btn-padding;
+ line-height: 1;
+}
+
+.card-score {
+ color: $gl-text-color-secondary;
+
+ .card-score-name {
+ font-size: 13px;
+ margin-top: 4px;
+ }
+}
+
+.card-score-value {
+ font-size: 16px;
+ color: $gl-text-color;
+ font-weight: 500;
+}
+
+.card-score-big {
+ border-top: 2px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ font-size: 22px;
+ padding: 10px 0;
+ font-weight: 500;
+}
+
+.card-buttons {
+ display: flex;
+
+ > * {
+ font-size: 16px;
+ color: $gl-text-color-secondary;
+ padding: 10px;
+ flex-grow: 1;
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+
+ + * {
+ border-left: solid 1px $border-color;
+ }
+ }
+}
+
+.convdev-steps {
+ margin-top: $gl-padding;
+ height: 1px;
+ min-width: 100%;
+ justify-content: space-around;
+ position: relative;
+ background: $border-color;
+}
+
+.convdev-step {
+ $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
+ @each $pos in $step-positions {
+ $i: index($step-positions, $pos);
+
+ &:nth-child(#{$i}) {
+ left: $pos;
+ }
+ }
+
+ position: absolute;
+ transform-origin: 75% 50%;
+ padding: 8px;
+ height: 50px;
+ width: 50px;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: solid 1px $border-color;
+ background: $white-light;
+ transform: translate(-50%, -50%);
+ color: $gl-text-color-secondary;
+ fill: $gl-text-color-secondary;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &:hover {
+ padding: 8px 10px;
+ fill: currentColor;
+ z-index: 100;
+ height: auto;
+ width: auto;
+
+ .convdev-step-title {
+ max-height: 2em;
+ opacity: 1;
+ transition: opacity 0.2s;
+ }
+
+ svg {
+ transform: scale(1.5);
+ margin: $gl-btn-padding;
+ }
+ }
+
+ svg {
+ transition: transform 0.1s;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+}
+
+.convdev-step-title {
+ max-height: 0;
+ opacity: 0;
+ text-transform: uppercase;
+ margin: $gl-vert-padding 0 0;
+ text-align: center;
+ font-size: 12px;
+}
+
+.convdev-high-score {
+ color: $color-high-score;
+}
+
+.convdev-average-score {
+ color: $color-average-score;
+}
+
+.convdev-low-score {
+ color: $color-low-score;
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index fe084eb9397..c207159f606 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -287,6 +287,7 @@ table.u2f-registrations {
.user-callout {
margin: 0 auto;
+ max-width: $screen-lg-min;
.bordered-box {
border: 1px solid $blue-300;
@@ -295,14 +296,15 @@ table.u2f-registrations {
position: relative;
display: flex;
justify-content: center;
+ align-items: center;
}
.landing {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
+ padding: 32px;
.close {
position: absolute;
+ top: 20px;
right: 20px;
opacity: 1;
@@ -330,11 +332,20 @@ table.u2f-registrations {
height: 110px;
vertical-align: top;
}
+
+ &.convdev {
+ margin: 0 0 0 30px;
+
+ svg {
+ height: 127px;
+ }
+ }
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
+ max-width: 570px;
}
}
@@ -348,12 +359,20 @@ table.u2f-registrations {
.landing {
.svg-container,
.user-callout-copy {
- margin: 0;
+ margin: 0 auto;
display: block;
svg {
height: 75px;
}
+
+ &.convdev {
+ margin: $gl-padding auto 0;
+
+ svg {
+ height: 120px;
+ }
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 24ab2bedea2..4719bf826dc 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -675,14 +675,16 @@ pre.light-well {
}
}
-.new_protected_branch {
+.new_protected_branch,
+.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
-.create-new-protected-branch-button {
+.create-new-protected-branch-button,
+.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
diff --git a/app/controllers/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb
new file mode 100644
index 00000000000..921169d3e2b
--- /dev/null
+++ b/app/controllers/admin/conversational_development_index_controller.rb
@@ -0,0 +1,5 @@
+class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController
+ def show
+ @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 563bcc65bd6..bace99dad58 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -138,7 +138,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
format.html { redirect_to admin_users_path, notice: "The user is being deleted." }
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index ae91e02488a..2b6afaa6233 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -106,4 +106,8 @@ module LfsRequest
def objects
@objects ||= (params[:objects] || []).to_a
end
+
+ def has_authentication_ability?(capability)
+ (authentication_abilities || []).include?(capability)
+ end
end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 9a1bf037a95..7f3205a8001 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -128,32 +128,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@authentication_result = Gitlab::Auth.find_for_git_client(
login, password, project: project, ip: request.ip)
- return false unless @authentication_result.success?
-
- if download_request?
- authentication_has_download_access?
- else
- authentication_has_upload_access?
- end
+ @authentication_result.success?
end
def ci?
authentication_result.ci?(project)
end
-
- def authentication_has_download_access?
- has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code)
- end
-
- def authentication_has_upload_access?
- has_authentication_ability?(:push_code)
- end
-
- def has_authentication_ability?(capability)
- (authentication_abilities || []).include?(capability)
- end
-
- def authentication_project
- authentication_result.project
- end
end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 9e4edcae101..b6b62da7b60 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -1,38 +1,27 @@
class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
+ before_action :access_check
+
+ rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
+ rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
- if upload_pack? && upload_pack_allowed?
- log_user_activity
-
- render_ok
- elsif receive_pack? && receive_pack_allowed?
- render_ok
- elsif http_blocked?
- render_http_not_allowed
- else
- render_denied
- end
+ log_user_activity if upload_pack?
+
+ render_ok
end
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- if upload_pack? && upload_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
# POST /foo/bar.git/git-receive-pack" (git push)
def git_receive_pack
- if receive_pack? && receive_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
private
@@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController
git_command == 'git-upload-pack'
end
- def receive_pack?
- git_command == 'git-receive-pack'
- end
-
def git_command
if action_name == 'info_refs'
params[:service]
@@ -62,47 +47,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
end
- def render_http_not_allowed
- render plain: access_check.message, status: :forbidden
+ def render_403(exception)
+ render plain: exception.message, status: :forbidden
end
- def render_denied
- if user && can?(user, :read_project, project)
- render plain: access_denied_message, status: :forbidden
- else
- # Do not leak information about project existence
- render_not_found
- end
- end
-
- def access_denied_message
- 'Access denied'
+ def render_404(exception)
+ render plain: exception.message, status: :not_found
end
- def upload_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.upload_pack
-
- access_check.allowed? || ci?
+ def access
+ @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities)
end
- def access
- @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities)
+ def access_actor
+ return user if user
+ return :ci if ci?
end
def access_check
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
- @access_check ||= access.check(git_command, '_any')
- end
-
- def http_blocked?
- !access.protocol_allowed?
- end
-
- def receive_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.receive_pack
-
- access_check.allowed?
+ access.check(git_command, '_any')
end
def access_klass
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index a1b84afcd91..4b143434ea5 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -14,14 +14,7 @@ class Projects::ImportsController < Projects::ApplicationController
@project.import_url = params[:project][:import_url]
if @project.save
- @project.reload
-
- if @project.import_failed?
- @project.import_retry
- else
- @project.import_start
- @project.add_import_job
- end
+ @project.reload.import_schedule
end
redirect_to namespace_project_import_path(@project.namespace, @project)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 59df1e7b86a..8b1efd0c572 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -199,14 +199,21 @@ class Projects::IssuesController < Projects::ApplicationController
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: {
+ response = {
title: view_context.markdown_field(@issue, :title),
title_text: @issue.title,
description: view_context.markdown_field(@issue, :description),
description_text: @issue.description,
- task_status: @issue.task_status,
- updated_at: @issue.updated_at
+ task_status: @issue.task_status
}
+
+ if @issue.is_edited?
+ response[:updated_at] = @issue.updated_at
+ response[:updated_by_name] = @issue.last_edited_by.name
+ response[:updated_by_path] = user_path(@issue.last_edited_by)
+ end
+
+ render json: response
end
def create_merge_request
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index d2cd1cfdab8..cb4f46388fd 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -45,6 +45,17 @@ class Projects::JobsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@build, {}, BuildDetailsEntity)
+ end
+ end
end
def trace
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index ba24fa9acfe..d1719f12072 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params
params.require(:protected_branch).permit(:name,
- merge_access_levels_attributes: [:access_level, :id],
- push_access_levels_attributes: [:access_level, :id])
+ merge_access_levels_attributes: access_level_attributes,
+ push_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 083a70968e5..b51bdf7aa78 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok }
end
end
+
+ protected
+
+ def access_level_attributes
+ %i(access_level id)
+ end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index c61ddf145e6..a5dbd7e46ae 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
end
def protected_ref_params
- params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 3ca14dee33c..cd2003586be 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -25,7 +25,7 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, current_user.id)
+ current_user.delete_async(deleted_by: current_user)
respond_to do |format|
format.html do
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 36d9090b3ae..71154da7ec5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -181,7 +181,7 @@ module ApplicationHelper
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
- return if object.last_edited_at == object.created_at || object.last_edited_at.blank?
+ return unless object.is_edited?
content_tag :small, class: 'edited-text' do
output = content_tag(:span, 'Edited ')
@@ -275,8 +275,8 @@ module ApplicationHelper
'active' if condition
end
- def show_user_callout?
- cookies[:user_callout_dismissed].nil?
+ def show_callout?(name)
+ cookies[name] != 'true'
end
def linkedin_url(user)
diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb
new file mode 100644
index 00000000000..1ff54415811
--- /dev/null
+++ b/app/helpers/conversational_development_index_helper.rb
@@ -0,0 +1,16 @@
+module ConversationalDevelopmentIndexHelper
+ def score_level(score)
+ if score < 33.33
+ 'low'
+ elsif score < 66.66
+ 'average'
+ else
+ 'high'
+ end
+ end
+
+ def format_score(score)
+ precision = score < 1 ? 2 : 1
+ number_with_precision(score, precision: precision)
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index c380a10c82d..5e8f0849969 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -200,7 +200,7 @@ module IssuablesHelper
end
def issuable_initial_data(issuable)
- {
+ data = {
endpoint: namespace_project_issue_path(@project.namespace, @project, issuable),
canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable),
@@ -217,7 +217,23 @@ module IssuablesHelper
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
initialDescriptionText: issuable.description
- }.to_json
+ }
+
+ data.merge!(updated_at_by(issuable))
+
+ data.to_json
+ end
+
+ def updated_at_by(issuable)
+ return {} unless issuable.is_edited?
+
+ {
+ updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: issuable.last_edited_by.name,
+ path: user_path(issuable.last_edited_by)
+ }
+ }
end
private
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index c0763a8a9c4..8e0a1e2ecdf 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -13,6 +13,17 @@ module SubmoduleHelper
if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
namespace, project = $1, $2
+ gitlab_hosts = [Gitlab.config.gitlab.url,
+ Gitlab.config.gitlab_shell.ssh_path_prefix]
+
+ gitlab_hosts.each do |host|
+ if url.start_with?(host)
+ namespace, _, project = url.sub(host, '').rpartition('/')
+ break
+ end
+ end
+
+ namespace.sub!(/\A\//, '')
project.rstrip!
project.sub!(/\.git\z/, '')
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 0d7c2d20029..4cbd90c5817 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base
alias_method :author, :reporter
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def notify
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 9e04976e8fd..3b49cb4e3bf 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -199,7 +199,7 @@ class ApplicationSetting < ActiveRecord::Base
ApplicationSetting.define_attribute_methods
end
- def self.defaults_ce
+ def self.defaults
{
after_sign_up_text: nil,
akismet_enabled: false,
@@ -250,10 +250,6 @@ class ApplicationSetting < ActiveRecord::Base
}
end
- def self.defaults
- defaults_ce
- end
-
def self.create_from_defaults
create(defaults)
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 58dfdd87652..cec1ca89a6a 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -47,8 +47,8 @@ module Ci
before_destroy { unscoped_project }
after_create :execute_hooks
- after_save :update_project_statistics, if: :artifacts_size_changed?
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics_after_save, on: [:create, :update]
+ after_commit :update_project_statistics, on: :destroy
class << self
# This is needed for url_for to work,
@@ -138,6 +138,17 @@ module Ci
ExpandVariables.expand(environment, simple_variables) if environment
end
+ def environment_url
+ return @environment_url if defined?(@environment_url)
+
+ @environment_url =
+ if unexpanded_url = options&.dig(:environment, :url)
+ ExpandVariables.expand(unexpanded_url, simple_variables)
+ else
+ persisted_environment&.external_url
+ end
+ end
+
def has_environment?
environment.present?
end
@@ -198,20 +209,23 @@ module Ci
# All variables, including those dependent on other variables
def variables
- variables = simple_variables
- variables += persisted_environment.predefined_variables if persisted_environment.present?
- variables
+ simple_variables.concat(persisted_environment_variables)
end
def merge_request
- merge_requests = MergeRequest.includes(:merge_request_diff)
- .where(source_branch: ref,
- source_project: pipeline.project)
- .reorder(iid: :asc)
-
- merge_requests.find do |merge_request|
- merge_request.commits_sha.include?(pipeline.sha)
- end
+ return @merge_request if defined?(@merge_request)
+
+ @merge_request ||=
+ begin
+ merge_requests = MergeRequest.includes(:merge_request_diff)
+ .where(source_branch: ref,
+ source_project: pipeline.project)
+ .reorder(iid: :desc)
+
+ merge_requests.find do |merge_request|
+ merge_request.commits_sha.include?(pipeline.sha)
+ end
+ end
end
def repo_url
@@ -335,7 +349,7 @@ module Ci
end
def has_expiring_artifacts?
- artifacts_expire_at.present?
+ artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
def keep_artifacts!
@@ -462,6 +476,18 @@ module Ci
variables.concat(legacy_variables)
end
+ def persisted_environment_variables
+ return [] unless persisted_environment
+
+ variables = persisted_environment.predefined_variables
+
+ if url = environment_url
+ variables << { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ variables
+ end
+
def legacy_variables
variables = [
{ key: 'CI_BUILD_ID', value: id.to_s, public: true },
@@ -491,5 +517,11 @@ module Ci
ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size])
end
+
+ def update_project_statistics_after_save
+ if previous_changes.include?('artifacts_size')
+ update_project_statistics
+ end
+ end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index fe63728ea23..8b4ed49269d 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -18,7 +18,7 @@ class CommitStatus < ActiveRecord::Base
validates :name, presence: true
alias_attribute :author, :user
-
+
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
end
@@ -83,14 +83,15 @@ class CommitStatus < ActiveRecord::Base
next if transition.loopback?
commit_status.run_after_commit do
- pipeline.try do |pipeline|
+ if pipeline
if complete? || manual?
PipelineProcessWorker.perform_async(pipeline.id)
else
PipelineUpdateWorker.perform_async(pipeline.id)
end
- ExpireJobCacheWorker.perform_async(commit_status.id)
end
+
+ ExpireJobCacheWorker.perform_async(commit_status.id)
end
end
@@ -126,6 +127,11 @@ class CommitStatus < ActiveRecord::Base
false
end
+ # To be overriden when inherrited from
+ def retryable?
+ false
+ end
+
def stuck?
false
end
diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb
new file mode 100644
index 00000000000..c62c7e1e936
--- /dev/null
+++ b/app/models/concerns/editable.rb
@@ -0,0 +1,7 @@
+module Editable
+ extend ActiveSupport::Concern
+
+ def is_edited?
+ last_edited_at.present? && last_edited_at != created_at
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 075ec575f9d..ea10d004c9c 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -15,6 +15,7 @@ module Issuable
include Taskable
include TimeTrackable
include Importable
+ include Editable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 62eaec2407f..47e71c58557 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -8,32 +8,44 @@ module ProtectedRef
validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+ end
+
+ def commit
+ project.commit(self.name)
+ end
+
+ class_methods do
+ def protected_ref_access_levels(*types)
+ types.each do |type|
+ has_many :"#{type}_access_levels", dependent: :destroy
+
+ validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
- def self.protected_ref_accessible_to?(ref, user, action:)
+ accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
+ end
+ end
+
+ def protected_ref_accessible_to?(ref, user, action:)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.check_access(user)
end
end
- def self.developers_can?(action, ref)
+ def developers_can?(action, ref)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.access_level == Gitlab::Access::DEVELOPER
end
end
- def self.access_levels_for_ref(ref, action:)
+ def access_levels_for_ref(ref, action:)
self.matching(ref).map(&:"#{action}_access_levels").flatten
end
- def self.matching(ref_name, protected_refs: nil)
+ def matching(ref_name, protected_refs: nil)
ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
end
end
- def commit
- project.commit(self.name)
- end
-
private
def ref_matcher
diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb
new file mode 100644
index 00000000000..e8f09dc9161
--- /dev/null
+++ b/app/models/conversational_development_index/card.rb
@@ -0,0 +1,26 @@
+module ConversationalDevelopmentIndex
+ class Card
+ attr_accessor :metric, :title, :description, :feature, :blog, :docs
+
+ def initialize(metric:, title:, description:, feature:, blog:, docs: nil)
+ self.metric = metric
+ self.title = title
+ self.description = description
+ self.feature = feature
+ self.blog = blog
+ self.docs = docs
+ end
+
+ def instance_score
+ metric.instance_score(feature)
+ end
+
+ def leader_score
+ metric.leader_score(feature)
+ end
+
+ def percentage_score
+ metric.percentage_score(feature)
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb
new file mode 100644
index 00000000000..6e1753c9f30
--- /dev/null
+++ b/app/models/conversational_development_index/idea_to_production_step.rb
@@ -0,0 +1,19 @@
+module ConversationalDevelopmentIndex
+ class IdeaToProductionStep
+ attr_accessor :metric, :title, :features
+
+ def initialize(metric:, title:, features:)
+ self.metric = metric
+ self.title = title
+ self.features = features
+ end
+
+ def percentage_score
+ sum = features.sum do |feature|
+ metric.percentage_score(feature)
+ end
+
+ sum / features.size.to_f
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
new file mode 100644
index 00000000000..f42f516f99a
--- /dev/null
+++ b/app/models/conversational_development_index/metric.rb
@@ -0,0 +1,21 @@
+module ConversationalDevelopmentIndex
+ class Metric < ActiveRecord::Base
+ include Presentable
+
+ self.table_name = 'conversational_development_index_metrics'
+
+ def instance_score(feature)
+ self["instance_#{feature}"]
+ end
+
+ def leader_score(feature)
+ self["leader_#{feature}"]
+ end
+
+ def percentage_score(feature)
+ return 100 if leader_score(feature).zero?
+
+ 100 * instance_score(feature) / leader_score(feature)
+ end
+ end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index be944da5a67..5bb2cdc5eff 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -222,6 +222,16 @@ class Group < Namespace
User.where(id: members_with_parents.select(:user_id))
end
+ def max_member_access_for_user(user)
+ return GroupMember::OWNER if user.admin?
+
+ members_with_parents.
+ where(user_id: user).
+ reorder(access_level: :desc).
+ first&.
+ access_level || GroupMember::NO_ACCESS
+ end
+
def mattermost_team_params
max_length = 59
diff --git a/app/models/key.rb b/app/models/key.rb
index b7956052c3f..cb8f10f6d55 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,6 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include AfterCommitQueue
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -25,10 +24,10 @@ class Key < ActiveRecord::Base
delegate :name, :email, to: :user, prefix: true
- after_create :add_to_shell
- after_create :notify_user
+ after_commit :add_to_shell, on: :create
+ after_commit :notify_user, on: :create
after_create :post_create_hook
- after_destroy :remove_from_shell
+ after_commit :remove_from_shell, on: :destroy
after_destroy :post_destroy_hook
def key=(value)
@@ -93,6 +92,6 @@ class Key < ActiveRecord::Base
end
def notify_user
- run_after_commit { NotificationService.new.new_key(self) }
+ NotificationService.new.new_key(self)
end
end
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 007eed5600a..b0625c52b62 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base
validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
- after_create :update_project_statistics
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics, on: [:create, :destroy]
private
diff --git a/app/models/member.rb b/app/models/member.rb
index 7228e82e978..29f9d61e870 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -200,6 +200,10 @@ class Member < ActiveRecord::Base
source_type
end
+ def access_field
+ access_level
+ end
+
def invite?
self.invite_token.present?
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 28e10bc6172..47040f95533 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -25,10 +25,6 @@ class GroupMember < Member
source
end
- def access_field
- access_level
- end
-
# Because source_type is `Namespace`...
def real_source_type
'Group'
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b3a91feb091..c0e17f4bfc8 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -79,10 +79,6 @@ class ProjectMember < Member
end
end
- def access_field
- access_level
- end
-
def project
source
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index aebee06d560..b48d73dcae7 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -6,6 +6,7 @@ class Namespace < ActiveRecord::Base
include Gitlab::ShellAdapter
include Gitlab::CurrentSettings
include Routable
+ include AfterCommitQueue
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -242,7 +243,9 @@ class Namespace < ActiveRecord::Base
# Remove namespace directroy async with delay so
# GitLab has time to remove all projects first
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ run_after_commit do
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 832c68243fb..563af47f314 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -13,6 +13,7 @@ class Note < ActiveRecord::Base
include AfterCommitQueue
include ResolvableNote
include IgnorableColumn
+ include Editable
ignore_column :original_discussion_id
diff --git a/app/models/project.rb b/app/models/project.rb
index 446329557d5..0caf7387450 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -165,7 +165,7 @@ class Project < ActiveRecord::Base
has_many :todos, dependent: :destroy
has_many :notification_settings, dependent: :destroy, as: :source
- has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
+ has_one :import_data, dependent: :delete, class_name: "ProjectImportData"
has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
has_many :container_repositories, dependent: :destroy
@@ -298,8 +298,16 @@ class Project < ActiveRecord::Base
scope :excluding_project, ->(project) { where.not(id: project) }
state_machine :import_status, initial: :none do
+ event :import_schedule do
+ transition [:none, :finished, :failed] => :scheduled
+ end
+
+ event :force_import_start do
+ transition [:none, :finished, :failed] => :started
+ end
+
event :import_start do
- transition [:none, :finished] => :started
+ transition scheduled: :started
end
event :import_finish do
@@ -307,18 +315,23 @@ class Project < ActiveRecord::Base
end
event :import_fail do
- transition started: :failed
+ transition [:scheduled, :started] => :failed
end
event :import_retry do
transition failed: :started
end
+ state :scheduled
state :started
state :finished
state :failed
- after_transition any => :finished, do: :reset_cache_and_import_attrs
+ after_transition [:none, :finished, :failed] => :scheduled do |project, _|
+ project.run_after_commit { add_import_job }
+ end
+
+ after_transition started: :finished, do: :reset_cache_and_import_attrs
end
class << self
@@ -471,7 +484,9 @@ class Project < ActiveRecord::Base
end
def reset_cache_and_import_attrs
- ProjectCacheWorker.perform_async(self.id)
+ run_after_commit do
+ ProjectCacheWorker.perform_async(self.id)
+ end
self.import_data&.destroy
end
@@ -530,9 +545,17 @@ class Project < ActiveRecord::Base
end
def import_in_progress?
+ import_started? || import_scheduled?
+ end
+
+ def import_started?
import? && import_status == 'started'
end
+ def import_scheduled?
+ import_status == 'scheduled'
+ end
+
def import_failed?
import_status == 'failed'
end
@@ -1226,6 +1249,7 @@ class Project < ActiveRecord::Base
{ key: 'CI_PROJECT_ID', value: id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: path, public: true },
{ key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: path_with_namespace.parameterize, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: web_url, public: true }
]
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 28b7d5ad072..5f0d0802ac9 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :merge_access_levels, dependent: :destroy
- has_many :push_access_levels, dependent: :destroy
-
- validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
- validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
-
- accepts_nested_attributes_for :push_access_levels
- accepts_nested_attributes_for :merge_access_levels
+ protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
def self.protected?(project, ref_name)
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 83964095516..f38109c0e52 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :create_access_levels, dependent: :destroy
-
- validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
-
- accepts_nested_attributes_for :create_access_levels
+ protected_ref_access_levels :create
def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present?
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 882e2fa0594..6c3358685fe 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -8,6 +8,7 @@ class Snippet < ActiveRecord::Base
include Awardable
include Mentionable
include Spammable
+ include Editable
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index dd21ee15c6c..56a115d1db4 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base
validates :user, presence: true
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def text
diff --git a/app/models/user.rb b/app/models/user.rb
index e6eb9d09656..9ed42d6b6f5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -809,6 +809,11 @@ class User < ActiveRecord::Base
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def delete_async(deleted_by:, params: {})
+ block if params[:hard_delete]
+ DeleteUserWorker.perform_async(deleted_by.id, id, params)
+ end
+
def notification_service
NotificationService.new
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 87398303c68..fb07298c6c2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -4,22 +4,25 @@ class GroupPolicy < BasePolicy
return unless @user
globally_viewable = @subject.public? || (@subject.internal? && !@user.external?)
- member = @subject.users_with_parents.include?(@user)
- owner = @user.admin? || @subject.has_owner?(@user)
- master = owner || @subject.has_master?(@user)
+ access_level = @subject.max_member_access_for_user(@user)
+ owner = access_level >= GroupMember::OWNER
+ master = access_level >= GroupMember::MASTER
+ reporter = access_level >= GroupMember::REPORTER
can_read = false
can_read ||= globally_viewable
- can_read ||= member
- can_read ||= @user.admin?
+ can_read ||= access_level >= GroupMember::GUEST
can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
can! :read_group if can_read
+ if reporter
+ can! :admin_label
+ end
+
# Only group masters and group owners can create new projects
if master
can! :create_projects
can! :admin_milestones
- can! :admin_label
end
# Only group owner and administrators can admin group
@@ -31,7 +34,7 @@ class GroupPolicy < BasePolicy
can! :create_subgroup if @user.can_create_group
end
- if globally_viewable && @subject.request_access_enabled && !member
+ if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS
can! :request_access
end
end
diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb
new file mode 100644
index 00000000000..bb65ba2646b
--- /dev/null
+++ b/app/presenters/conversational_development_index/metric_presenter.rb
@@ -0,0 +1,144 @@
+module ConversationalDevelopmentIndex
+ class MetricPresenter < Gitlab::View::Presenter::Simple
+ def cards
+ [
+ Card.new(
+ metric: subject,
+ title: 'Issues',
+ description: 'created per active user',
+ feature: 'issues',
+ blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Comments',
+ description: 'created per active user',
+ feature: 'notes',
+ blog: 'http://conversationaldevelopment.com/why/'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Milestones',
+ description: 'created per active user',
+ feature: 'milestones',
+ blog: 'http://conversationaldevelopment.com/shorten-cycle/',
+ docs: help_page_path('user/project/milestones/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Boards',
+ description: 'created per active user',
+ feature: 'boards',
+ blog: 'http://jpattonassociates.com/user-story-mapping/',
+ docs: help_page_path('user/project/issue_board')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Merge Requests',
+ description: 'per active user',
+ feature: 'merge_requests',
+ blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html',
+ docs: help_page_path('user/project/merge_requests/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Pipelines',
+ description: 'created per active user',
+ feature: 'ci_pipelines',
+ blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html',
+ docs: help_page_path('ci/README')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Environments',
+ description: 'created per active user',
+ feature: 'environments',
+ blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/',
+ docs: help_page_path('ci/environments')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Deployments',
+ description: 'created per active user',
+ feature: 'deployments',
+ blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Monitoring',
+ description: 'fraction of all projects',
+ feature: 'projects_prometheus_active',
+ blog: 'https://prometheus.io/docs/introduction/overview/',
+ docs: help_page_path('user/project/integrations/prometheus')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Service Desk',
+ description: 'issues created per active user',
+ feature: 'service_desk_issues',
+ blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic',
+ docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html'
+ )
+ ]
+ end
+
+ def idea_to_production_steps
+ [
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Idea',
+ features: %w(issues)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Issue',
+ features: %w(issues notes)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Plan',
+ features: %w(milestones boards)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Code',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Commit',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Test',
+ features: %w(ci_pipelines)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Review',
+ features: %w(ci_pipelines environments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Staging',
+ features: %w(environments deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Production',
+ features: %w(deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Feedback',
+ features: %w(projects_prometheus_active service_desk_issues)
+ )
+ ]
+ end
+
+ def average_percentage_score
+ cards.sum(&:percentage_score) / cards.size.to_f
+ end
+ end
+end
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index dde17aa68b8..cb55c98f7c6 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -1,14 +1,39 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
- expose :name do |build|
- build.name
+ expose :name do |job|
+ job.name
end
- expose :path do |build|
+ expose :artifacts_expired?, as: :expired
+ expose :artifacts_expire_at, as: :expire_at
+
+ expose :path do |job|
download_namespace_project_job_artifacts_path(
- build.project.namespace,
- build.project,
- build)
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
+ keep_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :browse_path do |job|
+ browse_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ private
+
+ alias_method :job, :object
+
+ def project
+ job.project
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
new file mode 100644
index 00000000000..0063920e603
--- /dev/null
+++ b/app/serializers/build_details_entity.rb
@@ -0,0 +1,50 @@
+class BuildDetailsEntity < BuildEntity
+ expose :coverage, :erased_at, :duration
+ expose :tag_list, as: :tags
+
+ expose :user, using: UserEntity
+
+ expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build|
+ erase_namespace_project_job_path(project.namespace, project, build)
+ end
+
+ expose :artifacts, using: BuildArtifactEntity
+ expose :runner, using: RunnerEntity
+ expose :pipeline, using: PipelineEntity
+
+ expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do
+ expose :iid do |build|
+ build.merge_request.iid
+ end
+
+ expose :path do |build|
+ namespace_project_merge_request_path(project.namespace, project, build.merge_request)
+ end
+ end
+
+ expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build|
+ new_namespace_project_issue_path(project.namespace, project, issue: build_failed_issue_options)
+ end
+
+ expose :raw_path do |build|
+ raw_namespace_project_build_path(project.namespace, project, build)
+ end
+
+ private
+
+ def build_failed_issue_options
+ {
+ title: "Build Failed ##{build.id}",
+ description: namespace_project_job_url(project.namespace, project, build)
+ }
+ end
+
+ def current_user
+ request.current_user
+ end
+
+ def project
+ build.project
+ end
+end
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
index 05dd8270e92..c01efa9dd5c 100644
--- a/app/serializers/build_entity.rb
+++ b/app/serializers/build_entity.rb
@@ -8,7 +8,7 @@ class BuildEntity < Grape::Entity
path_to(:namespace_project_job, build)
end
- expose :retry_path do |build|
+ expose :retry_path, if: -> (*) { build&.retryable? } do |build|
path_to(:retry_namespace_project_job, build)
end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index f7eb75395b5..7bb981041cc 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -29,7 +29,7 @@ class MergeRequestEntity < IssuableEntity
expose :merge_commit_sha
expose :merge_commit_message
- expose :head_pipeline, with: PipelineEntity, as: :pipeline
+ expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline
# Booleans
expose :work_in_progress?, as: :work_in_progress
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
new file mode 100644
index 00000000000..d58572a5f87
--- /dev/null
+++ b/app/serializers/pipeline_details_entity.rb
@@ -0,0 +1,7 @@
+class PipelineDetailsEntity < PipelineEntity
+ expose :details do
+ expose :stages, using: StageEntity
+ expose :artifacts, using: BuildArtifactEntity
+ expose :manual_actions, using: BuildActionEntity
+ end
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 486f8c36fbd..6d1fd9d459f 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -7,6 +7,8 @@ class PipelineEntity < Grape::Entity
expose :coverage
expose :source
+ expose :created_at, :updated_at
+
expose :path do |pipeline|
namespace_project_pipeline_path(
pipeline.project.namespace,
@@ -14,15 +16,6 @@ class PipelineEntity < Grape::Entity
pipeline)
end
- expose :details do
- expose :detailed_status, as: :status, with: StatusEntity
- expose :duration
- expose :finished_at
- expose :stages, using: StageEntity
- expose :artifacts, using: BuildArtifactEntity
- expose :manual_actions, using: BuildActionEntity
- end
-
expose :flags do
expose :latest?, as: :latest
expose :stuck?, as: :stuck
@@ -31,6 +24,12 @@ class PipelineEntity < Grape::Entity
expose :can_cancel?, as: :cancelable
end
+ expose :details do
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :duration
+ expose :finished_at
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -47,7 +46,6 @@ class PipelineEntity < Grape::Entity
end
expose :commit, using: CommitEntity
- expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
retry_namespace_project_pipeline_path(pipeline.project.namespace,
@@ -61,7 +59,7 @@ class PipelineEntity < Grape::Entity
pipeline.id)
end
- expose :created_at, :updated_at
+ expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
private
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index e37af63774c..b428ff69fe8 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,7 +1,7 @@
class PipelineSerializer < BaseSerializer
InvalidResourceError = Class.new(StandardError)
- entity PipelineEntity
+ entity PipelineDetailsEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
new file mode 100644
index 00000000000..ed7dacc2dbd
--- /dev/null
+++ b/app/serializers/runner_entity.rb
@@ -0,0 +1,18 @@
+class RunnerEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :description
+
+ expose :edit_path,
+ if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner|
+ edit_namespace_project_runner_path(project.namespace, project, runner)
+ end
+
+ private
+
+ alias_method :runner, :object
+
+ def project
+ request.project
+ end
+end
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 47f9b2c621c..46823418bb0 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -1,71 +1,59 @@
-class CreateDeploymentService < BaseService
- def execute(deployable = nil)
+class CreateDeploymentService
+ attr_reader :job
+
+ delegate :expanded_environment_name,
+ :environment_url,
+ :project,
+ to: :job
+
+ def initialize(job)
+ @job = job
+ end
+
+ def execute
return unless executable?
ActiveRecord::Base.transaction do
- @deployable = deployable
+ environment.external_url = environment_url if environment_url
+ environment.fire_state_event(action)
- @environment = environment
- @environment.external_url = expanded_url if expanded_url
- @environment.fire_state_event(action)
+ return unless environment.save
+ return if environment.stopped?
- return unless @environment.save
- return if @environment.stopped?
-
- deploy.tap do |deployment|
- deployment.update_merge_request_metrics!
- end
+ deploy.tap(&:update_merge_request_metrics!)
end
end
private
def executable?
- project && name.present?
+ project && job.environment.present? && environment
end
def deploy
project.deployments.create(
- environment: @environment,
- ref: params[:ref],
- tag: params[:tag],
- sha: params[:sha],
- user: current_user,
- deployable: @deployable,
- on_stop: options[:on_stop])
+ environment: environment,
+ ref: job.ref,
+ tag: job.tag,
+ sha: job.sha,
+ user: job.user,
+ deployable: job,
+ on_stop: on_stop)
end
def environment
- @environment ||= project.environments.find_or_create_by(name: expanded_name)
- end
-
- def expanded_name
- ExpandVariables.expand(name, variables)
- end
-
- def expanded_url
- return unless url
-
- @expanded_url ||= ExpandVariables.expand(url, variables)
- end
-
- def name
- params[:environment]
- end
-
- def url
- options[:url]
+ @environment ||= job.persisted_environment
end
- def options
- params[:options] || {}
+ def environment_options
+ @environment_options ||= job.options&.dig(:environment) || {}
end
- def variables
- params[:variables] || []
+ def on_stop
+ environment_options[:on_stop]
end
def action
- options[:action] || 'start'
+ environment_options[:action] || 'start'
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 535d93385e6..e874a2d8789 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -48,15 +48,14 @@ module Projects
save_project_and_import_data(import_data)
- @project.import_start if @project.import?
-
after_create_actions if @project.persisted?
if @project.errors.empty?
- @project.add_import_job if @project.import?
+ @project.import_schedule if @project.import?
else
fail(error: @project.errors.full_messages.join(', '))
end
+
@project
rescue ActiveRecord::RecordInvalid => e
message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} "
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 06d8d143231..e2b2660ea71 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -7,11 +7,9 @@ module Projects
DELETED_FLAG = '+deleted'.freeze
def async_execute
- project.transaction do
- project.update_attribute(:pending_delete, true)
- job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
- Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
- end
+ project.update_attribute(:pending_delete, true)
+ job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
end
def execute
@@ -62,7 +60,11 @@ module Projects
if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path)
+
+ project.run_after_commit do
+ # self is now project
+ GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path)
+ end
else
false
end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
new file mode 100644
index 00000000000..17857ca62f2
--- /dev/null
+++ b/app/services/submit_usage_ping_service.rb
@@ -0,0 +1,41 @@
+class SubmitUsagePingService
+ URL = 'https://version.gitlab.com/usage_data'.freeze
+
+ include Gitlab::CurrentSettings
+
+ def execute
+ return false unless current_application_settings.usage_ping_enabled?
+
+ response = HTTParty.post(
+ URL,
+ body: Gitlab::UsageData.to_json(force_refresh: true),
+ headers: { 'Content-type' => 'application/json' }
+ )
+
+ store_metrics(response)
+
+ true
+ rescue HTTParty::Error => e
+ Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
+
+ false
+ end
+
+ private
+
+ def store_metrics(response)
+ return unless response['conv_index'].present?
+
+ ConversationalDevelopmentIndex::Metric.create!(
+ response['conv_index'].slice(
+ 'leader_issues', 'instance_issues', 'leader_notes', 'instance_notes',
+ 'leader_milestones', 'instance_milestones', 'leader_boards', 'instance_boards',
+ 'leader_merge_requests', 'instance_merge_requests', 'leader_ci_pipelines',
+ 'instance_ci_pipelines', 'leader_environments', 'instance_environments',
+ 'leader_deployments', 'instance_deployments', 'leader_projects_prometheus_active',
+ 'instance_projects_prometheus_active', 'leader_service_desk_issues',
+ 'instance_service_desk_issues'
+ )
+ )
+ end
+end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index facf21a7f5c..ab532a1fdcf 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -16,7 +16,7 @@ module Users
def record_activity
Gitlab::UserActivities.record(@author.id)
- Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}")
+ Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})")
end
end
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 9eb6a600f6b..673afb8b5b9 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -6,12 +6,27 @@ module Users
@current_user = current_user
end
+ # Synchronously destroys +user+
+ #
+ # The operation will fail if the user is the sole owner of any groups. To
+ # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in
+ # +options+.
+ #
+ # The user's contributions will be migrated to a global ghost user. To
+ # force the contributions to be destroyed, pass `hard_delete: true` in
+ # +options+.
+ #
+ # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform
+ # a hard deletion without destroying solo-owned groups, pass
+ # `delete_solo_owned_groups: false, hard_delete: true` in +options+.
def execute(user, options = {})
+ delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
+
unless Ability.allowed?(current_user, :destroy_user, user)
raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!"
end
- if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present?
+ if !delete_solo_owned_groups && user.solo_owned_groups.present?
user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user'
return user
end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index 95a891111e1..02589959c2f 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -12,4 +12,20 @@ class LfsObjectUploader < GitlabUploader
def filename
model.oid[4..-1]
end
+
+ def work_dir
+ File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ end
+
+ private
+
+ # To prevent LFS files from moving across filesystems, override the default
+ # implementation:
+ # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183
+ def workfile_path(for_file = original_filename)
+ # To be safe, keep this directory outside of the the cache directory
+ # because calling CarrierWave.clean_cache_files! will remove any files in
+ # the cache directory.
+ File.join(work_dir, @cache_id, version_name.to_s, for_file)
+ end
end
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index ac36bb5bb17..e5842bd1ea0 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Background Jobs"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title Background Jobs
diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml
new file mode 100644
index 00000000000..33a4dab1e00
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_callout.html.haml
@@ -0,0 +1,13 @@
+.prepend-top-default
+.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
+ .bordered-box.landing.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss ConvDev introduction' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .user-callout-copy
+ %h4
+ Introducing Your Conversational Development Index
+ %p
+ Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ .svg-container.convdev
+ = custom_icon('convdev_overview')
diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml
new file mode 100644
index 00000000000..6c8688e06ae
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_card.html.haml
@@ -0,0 +1,25 @@
+.convdev-card-wrapper
+ .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" }
+ .convdev-card-title
+ %h3
+ = card.title
+ .text-light
+ = card.description
+ .card-scores
+ .card-score
+ .card-score-value
+ = format_score(card.instance_score)
+ .card-score-name You
+ .card-score
+ .card-score-value
+ = format_score(card.leader_score)
+ .card-score-name Lead
+ .card-score-big
+ = number_to_percentage(card.percentage_score, precision: 1)
+ .card-buttons
+ - if card.blog
+ %a{ href: card.blog }
+ = icon('info-circle', 'aria-hidden' => 'true')
+ - if card.docs
+ %a{ href: card.docs }
+ = icon('question-circle', 'aria-hidden' => 'true')
diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml
new file mode 100644
index 00000000000..975d7df3da6
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_disabled.html.haml
@@ -0,0 +1,9 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_index')
+ %h4 Usage ping is not enabled
+ %p
+ ConvDev is only shown when the
+ = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank'
+ is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective
+ = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary'
diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml
new file mode 100644
index 00000000000..b23d2b5ec3a
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_no_data.html.haml
@@ -0,0 +1,7 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_data')
+ %h4 Data is still calculating...
+ %p
+ In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
+ = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank'
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
new file mode 100644
index 00000000000..833d4c612f8
--- /dev/null
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -0,0 +1,35 @@
+- @no_container = true
+- page_title 'ConvDev Index'
+
+= render 'admin/monitoring/head'
+
+.container
+ - if show_callout?('convdev_intro_callout_dismissed')
+ = render 'callout'
+
+ .prepend-top-default
+ - if !current_application_settings.usage_ping_enabled
+ = render 'disabled'
+ - elsif @metric.blank?
+ = render 'no_data'
+ - else
+ .convdev
+ .convdev-header
+ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
+ = number_to_percentage(@metric.average_percentage_score, precision: 1)
+ .convdev-header-subtitle
+ index
+ %br
+ score
+ = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev')
+
+ .convdev-cards.card-container
+ - @metric.cards.each do |card|
+ = render 'card', card: card
+
+ .convdev-steps.visible-lg
+ - @metric.idea_to_production_steps.each_with_index do |step, index|
+ .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" }
+ = custom_icon("i2p_step_#{index + 1}")
+ %h4.convdev-step-title
+ = step.title
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 4deccf4aa93..8adb966064c 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Health Check"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index 5e585ce789b..487f1cf5c4f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -3,7 +3,7 @@
- loggers = [Gitlab::GitLogger, Gitlab::AppLogger,
Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger]
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
index b3530915068..901e30275fd 100644
--- a/app/views/admin/background_jobs/_head.html.haml
+++ b/app/views/admin/monitoring/_head.html.haml
@@ -3,6 +3,10 @@
= render 'shared/nav_scroll'
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
= nav_link(controller: :system_info) do
= link_to admin_system_info_path, title: 'System Info' do
%span
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index c7b63d9de98..b7db18b2d32 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title 'Requests Profiles'
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 9b9559c7fe5..fd0281e4961 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "System Info"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
.prepend-top-default
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 46d2e3b3de1..4cf4a57ba18 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -34,9 +34,15 @@
- if user.access_locked?
%li
= link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' }
- - if user.can_be_removed? && can?(current_user, :destroy_user, user)
+ - if can?(current_user, :destroy_user, user)
%li.divider
+ - if user.can_be_removed?
+ %li
+ = link_to 'Remove user', admin_user_path(user),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" },
+ method: :delete
%li
- = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
- class: 'btn btn-remove btn-block',
- method: :delete
+ = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" },
+ class: 'btn btn-remove btn-block',
+ method: :delete
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 89d0bbb7126..b556ff056c0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -177,7 +177,7 @@
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
- if @user.solo_owned_groups.present?
%p
@@ -188,3 +188,22 @@
- else
%p
You don't have access to delete this user.
+
+ .panel.panel-danger
+ .panel-heading
+ Remove user and contributions
+ .panel-body
+ - if can?(current_user, :destroy_user, @user)
+ %p
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ - else
+ %p
+ You don't have access to delete this user.
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 2890ae7173b..5e63a61e21b 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -9,7 +9,7 @@
= render "projects/last_push"
%div{ class: container_class }
- - if show_user_callout?
+ - if show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
- if @projects.any? || params[:name]
diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml
index 86779eeaf15..6df0adfd742 100644
--- a/app/views/layouts/nav/_admin.html.haml
+++ b/app/views/layouts/nav/_admin.html.haml
@@ -9,8 +9,8 @@
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
- = link_to admin_system_info_path, title: 'Monitoring' do
+ = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_conversational_development_index_path, title: 'Monitoring' do
%span
Monitoring
= nav_link(controller: :broadcast_messages) do
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 7bf271c2fc5..d909b0bfbbd 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -63,7 +63,7 @@
.wiki= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field= @issue.description
- = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
+ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript.
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index af9a080f0a2..dd5b346d922 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
.panel.panel-default
.panel-heading
%h3.panel-title
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
index c8531f96f97..9b6923210f7 100644
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -2,7 +2,7 @@
= dropdown_tag('Select tag or create wildcard',
options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle',
- filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tag",
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do
+ %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
Create wildcard
%code
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
index 0bfb1ad191d..663cbd7cd64 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -4,13 +4,14 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
- Protected tags
+ Protected Tags
%p.prepend-top-20
- By default, Protected tags are designed to:
+ By default, protected tags are designed to:
%ul
%li Prevent tag creation by everybody except Masters
%li Prevent <strong>anyone</strong> from updating the tag
%li Prevent <strong>anyone</strong> from deleting the tag
+ %p.append-bottom-0 Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}.
.col-lg-9
- if can? current_user, :admin_project, @project
= render 'projects/protected_tags/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
index 54249ec0db1..f11ce0483a9 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -19,4 +19,4 @@
- if can_admin_project
%td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
index 728afd75b50..d432a5c9113 100644
--- a/app/views/projects/protected_tags/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -1,4 +1,4 @@
-.panel.panel-default.protected-tags-list.js-protected-tags-list
+.panel.panel-default.protected-tags-list
- if @protected_tags.empty?
.panel-heading
%h3.panel-title
@@ -13,6 +13,8 @@
%col{ width: "25%" }
%col{ width: "25%" }
%col{ width: "50%" }
+ - if can_admin_project
+ %col
%thead
%tr
%th Protected tag (#{@protected_tags.size})
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 94c3612a449..16fc02fe9f4 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -5,7 +5,7 @@
%h4.prepend-top-0.ref-name
= @protected_ref.name
- .col-lg-9
+ .col-lg-9.edit_protected_tag
%h5 Matching Tags
- if @matching_refs.present?
.table-responsive
diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml
index 8308baa7829..17ffcba69d8 100644
--- a/app/views/shared/_user_callout.html.haml
+++ b/app/views/shared/_user_callout.html.haml
@@ -1,4 +1,4 @@
-.user-callout
+.user-callout{ data: { uid: 'user_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss customize experience box' }
diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg
new file mode 100644
index 00000000000..ed32b2333e7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_data.svg
@@ -0,0 +1,40 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/>
+ <g transform="translate(214 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/>
+ <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(118 7)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g fill-rule="nonzero">
+ <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/>
+ <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/>
+ </g>
+ <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(26 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(38 42)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M4 14h106v4H4z"/>
+ <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg
new file mode 100644
index 00000000000..95c00e81d10
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_index.svg
@@ -0,0 +1,67 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200">
+ <g fill="none" fill-rule="evenodd" transform="translate(3 11)">
+ <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/>
+ <g transform="translate(0 2)">
+ <rect width="110" height="168" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/>
+ <g transform="translate(19 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(67 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(36 40)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M2 12h106v4H2z"/>
+ <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(122)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(243)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg
new file mode 100644
index 00000000000..2f31113bad7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_overview.svg
@@ -0,0 +1,64 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <rect id="a" width="58" height="98" y="17" rx="6"/>
+ <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/>
+ <rect id="c" width="58" height="98.394" rx="6"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(1)">
+ <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/>
+ <g transform="translate(74)">
+ <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#a"/>
+ <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 45.185)">
+ <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/>
+ <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/>
+ <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/>
+ </g>
+ <g transform="translate(10.81)">
+ <circle cx="18.19" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/>
+ <g transform="translate(10 11)">
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/>
+ <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/>
+ <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/>
+ </g>
+ </g>
+ </g>
+ <g transform="translate(144.5)">
+ <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#b"/>
+ <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(19 46.185)">
+ <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/>
+ <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/>
+ </g>
+ <g transform="translate(14.413)">
+ <circle cx="18.087" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/>
+ <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/>
+ </g>
+ </g>
+ <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <g transform="translate(0 16.754)">
+ <use fill="#FFF" xlink:href="#c"/>
+ <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 29.618)">
+ <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/>
+ </g>
+ </g>
+ <g transform="translate(10.41)">
+ <circle cx="18.589" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/>
+ <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg
new file mode 100644
index 00000000000..9dedcd5291a
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_1.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/>
+ <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/>
+ <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/>
+ <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg
new file mode 100644
index 00000000000..dd6fd1457ff
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_10.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/>
+ <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/>
+ <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/>
+ <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/>
+ <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/>
+ <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg
new file mode 100644
index 00000000000..b8805b90275
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_2.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg
new file mode 100644
index 00000000000..6c783ed8289
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_3.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg
new file mode 100644
index 00000000000..af804c838e0
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_4.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/>
+ <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg
new file mode 100644
index 00000000000..e54f707019e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_5.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg
new file mode 100644
index 00000000000..c57baccc06b
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_6.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg
new file mode 100644
index 00000000000..e9083de3afa
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_7.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+ <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/>
+ <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/>
+ <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg
new file mode 100644
index 00000000000..62676b0e12e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_8.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg
new file mode 100644
index 00000000000..e4285a14425
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_9.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/>
+</svg>
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index cf0540afb38..fbc335f6176 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -7,7 +7,7 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project)
-- updated_tooltip = time_ago_with_tooltip(project.updated_at)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_at)
%li.project-row{ class: css_class }
= cache(cache_key) do
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index c239253c8d5..f246bd7a586 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -100,7 +100,7 @@
Snippets
%div{ class: container_class }
- - if @user == current_user && show_user_callout?
+ - if @user == current_user && show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
.tab-content
#activity.tab-pane
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index e17add7421f..bf009dfab0f 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -11,15 +11,6 @@ class BuildSuccessWorker
private
def create_deployment(build)
- service = CreateDeploymentService.new(
- build.project, build.user,
- environment: build.environment,
- sha: build.sha,
- ref: build.ref,
- tag: build.tag,
- options: build.options.to_h[:environment],
- variables: build.variables)
-
- service.execute(build)
+ CreateDeploymentService.new(build).execute
end
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index 2f02235b0ac..0a55aab63fd 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -3,29 +3,17 @@ class GitlabUsagePingWorker
include Sidekiq::Worker
include CronjobQueue
- include HTTParty
def perform
- return unless current_application_settings.usage_ping_enabled
-
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
return unless try_obtain_lease
- begin
- HTTParty.post(url,
- body: Gitlab::UsageData.to_json(force_refresh: true),
- headers: { 'Content-type' => 'application/json' }
- )
- rescue HTTParty::Error => e
- Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
- end
+ SubmitUsagePingService.new.execute
end
+ private
+
def try_obtain_lease
Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain
end
-
- def url
- 'https://version.gitlab.com/usage_data'
- end
end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index efc99ec962a..a338523dc6b 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,4 +1,6 @@
class RepositoryForkWorker
+ ForkError = Class.new(StandardError)
+
include Sidekiq::Worker
include Gitlab::ShellAdapter
include DedicatedSidekiqQueue
@@ -8,29 +10,31 @@ class RepositoryForkWorker
source_path: source_path,
target_path: target_path)
- project = Project.find_by_id(project_id)
-
- unless project.present?
- logger.error("Project #{project_id} no longer exists!")
- return
- end
+ project = Project.find(project_id)
+ project.import_start
result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path,
project.repository_storage_path, target_path)
- unless result
- logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}")
- project.mark_import_as_failed('The project could not be forked.')
- return
- end
+ raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result
project.repository.after_import
-
- unless project.valid_repo?
- logger.error("Project #{project_id} had an invalid repository after fork")
- project.mark_import_as_failed('The forked repository is invalid.')
- return
- end
+ raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
project.import_finish
+ rescue ForkError => ex
+ fail_fork(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_fork(project, ex.message)
+ raise ForkError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_fork(project, message)
+ Rails.logger.error(message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index b33ba2ed7c1..625476b7e01 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,4 +1,6 @@
class RepositoryImportWorker
+ ImportError = Class.new(StandardError)
+
include Sidekiq::Worker
include DedicatedSidekiqQueue
@@ -10,6 +12,8 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
+ project.import_start
+
Gitlab::Metrics.add_event(:import_repository,
import_url: @project.import_url,
path: @project.path_with_namespace)
@@ -17,13 +21,23 @@ class RepositoryImportWorker
project.update_columns(import_jid: self.jid, import_error: nil)
result = Projects::ImportService.new(project, current_user).execute
-
- if result[:status] == :error
- project.mark_import_as_failed(result[:message])
- return
- end
+ raise ImportError, result[:message] if result[:status] == :error
project.repository.after_import
project.import_finish
+ rescue ImportError => ex
+ fail_import(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_import(project, ex.message)
+ raise ImportError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_import(project, message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml
new file mode 100644
index 00000000000..af9fe3b5041
--- /dev/null
+++ b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml
@@ -0,0 +1,4 @@
+---
+title: Add $CI_ENVIRONMENT_URL to predefined variables for pipelines
+merge_request: 11695
+author:
diff --git a/changelogs/unreleased/27614-improve-instant-comments-exp.yml b/changelogs/unreleased/27614-improve-instant-comments-exp.yml
new file mode 100644
index 00000000000..4db676801f1
--- /dev/null
+++ b/changelogs/unreleased/27614-improve-instant-comments-exp.yml
@@ -0,0 +1,4 @@
+---
+title: Improve user experience around slash commands in instant comments
+merge_request: 11612
+author:
diff --git a/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml
new file mode 100644
index 00000000000..2308a528580
--- /dev/null
+++ b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to be hard-deleted from the admin panel
+merge_request: 11874
+author:
diff --git a/changelogs/unreleased/29690-rotate-otp-key-base.yml b/changelogs/unreleased/29690-rotate-otp-key-base.yml
new file mode 100644
index 00000000000..94d73a24758
--- /dev/null
+++ b/changelogs/unreleased/29690-rotate-otp-key-base.yml
@@ -0,0 +1,4 @@
+---
+title: Add a Rake task to aid in rotating otp_key_base
+merge_request: 11881
+author:
diff --git a/changelogs/unreleased/30469-convdev-index.yml b/changelogs/unreleased/30469-convdev-index.yml
new file mode 100644
index 00000000000..0bdd9c4a699
--- /dev/null
+++ b/changelogs/unreleased/30469-convdev-index.yml
@@ -0,0 +1,4 @@
+---
+title: Add ConvDev Index page to admin area
+merge_request: 11377
+author:
diff --git a/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml
new file mode 100644
index 00000000000..e71910dbd67
--- /dev/null
+++ b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml
@@ -0,0 +1,4 @@
+---
+title: Add slugify project path to CI enviroment variables
+merge_request: 11838
+author: Ivan Chernov
diff --git a/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml
new file mode 100644
index 00000000000..f61aa0a6b6e
--- /dev/null
+++ b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml
@@ -0,0 +1,4 @@
+---
+title: Increase individual diff collapse limit to 100 KB, and render limit to 200 KB
+merge_request:
+author:
diff --git a/changelogs/unreleased/32118-new-environment-btn-copy.yml b/changelogs/unreleased/32118-new-environment-btn-copy.yml
new file mode 100644
index 00000000000..16a51c3db6a
--- /dev/null
+++ b/changelogs/unreleased/32118-new-environment-btn-copy.yml
@@ -0,0 +1,4 @@
+---
+title: Make New environment empty state btn lowercase
+merge_request:
+author:
diff --git a/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml
new file mode 100644
index 00000000000..93037d6181e
--- /dev/null
+++ b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml
@@ -0,0 +1,4 @@
+---
+title: Use zopfli compression for frontend assets
+merge_request: 11798
+author:
diff --git a/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml
new file mode 100644
index 00000000000..3b98525167d
--- /dev/null
+++ b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml
@@ -0,0 +1,4 @@
+---
+title: Allow group reporters to manage group labels
+merge_request:
+author:
diff --git a/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml
new file mode 100644
index 00000000000..07dd0872d3b
--- /dev/null
+++ b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml
@@ -0,0 +1,4 @@
+---
+title: Add Chinese translation of Cycle Analytics Page to I18N
+merge_request: 11644
+author:Huang Tao
diff --git a/changelogs/unreleased/dz-fix-submodule-subgroup.yml b/changelogs/unreleased/dz-fix-submodule-subgroup.yml
new file mode 100644
index 00000000000..20c7c9ce657
--- /dev/null
+++ b/changelogs/unreleased/dz-fix-submodule-subgroup.yml
@@ -0,0 +1,4 @@
+---
+title: Fix submodule link to then project under subgroup
+merge_request: 11906
+author:
diff --git a/changelogs/unreleased/mk-fix-git-over-http-rejections.yml b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml
new file mode 100644
index 00000000000..e75740e913f
--- /dev/null
+++ b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml
@@ -0,0 +1,4 @@
+---
+title: Fix Git-over-HTTP error statuses and improve error messages
+merge_request: 11398
+author:
diff --git a/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml
new file mode 100644
index 00000000000..161bce45601
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml
@@ -0,0 +1,4 @@
+---
+title: Fix LFS timeouts when trying to save large files
+merge_request:
+author:
diff --git a/changelogs/unreleased/zj-job-view-goes-real-time.yml b/changelogs/unreleased/zj-job-view-goes-real-time.yml
new file mode 100644
index 00000000000..376c9dfa65f
--- /dev/null
+++ b/changelogs/unreleased/zj-job-view-goes-real-time.yml
@@ -0,0 +1,4 @@
+---
+title: Job details page update real time
+merge_request: 11651
+author:
diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb
new file mode 100644
index 00000000000..a78711fe599
--- /dev/null
+++ b/config/initializers/forbid_sidekiq_in_transactions.rb
@@ -0,0 +1,49 @@
+module Sidekiq
+ module Worker
+ mattr_accessor :skip_transaction_check
+ self.skip_transaction_check = false
+
+ def self.skipping_transaction_check(&block)
+ skip_transaction_check = self.skip_transaction_check
+ self.skip_transaction_check = true
+ yield
+ ensure
+ self.skip_transaction_check = skip_transaction_check
+ end
+
+ module ClassMethods
+ module NoSchedulingFromTransactions
+ NESTING = ::Rails.env.test? ? 1 : 0
+
+ %i(perform_async perform_at perform_in).each do |name|
+ define_method(name) do |*args|
+ return super(*args) if Sidekiq::Worker.skip_transaction_check
+ return super(*args) unless ActiveRecord::Base.connection.open_transactions > NESTING
+
+ raise <<-MSG.strip_heredoc
+ `#{self}.#{name}` cannot be called inside a transaction as this can lead to
+ race conditions when the worker runs before the transaction is committed and
+ tries to access a model that has not been saved yet.
+
+ Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead.
+ MSG
+ end
+ end
+ end
+
+ prepend NoSchedulingFromTransactions
+ end
+ end
+end
+
+module ActiveRecord
+ class Base
+ module SkipTransactionCheckAfterCommit
+ def committed!(*)
+ Sidekiq::Worker.skipping_transaction_check { super }
+ end
+ end
+
+ prepend SkipTransactionCheckAfterCommit
+ end
+end
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index ccfd85aed63..c7b639b7b3c 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -72,6 +72,8 @@ namespace :admin do
resource :system_info, controller: 'system_info', only: [:show]
resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ }
+ get 'conversational_development_index' => 'conversational_development_index#show'
+
resources :projects, only: [:index]
scope(path: 'projects/*namespace_id',
diff --git a/config/webpack.config.js b/config/webpack.config.js
index c99298239b2..61f1eaaacd1 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -16,6 +16,7 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
var WEBPACK_REPORT = process.env.WEBPACK_REPORT;
+var NO_COMPRESSION = process.env.NO_COMPRESSION;
var config = {
// because sqljs requires fs.
@@ -221,11 +222,18 @@ if (IS_PRODUCTION) {
}),
new webpack.DefinePlugin({
'process.env': { NODE_ENV: JSON.stringify('production') }
- }),
- new CompressionPlugin({
- asset: '[path].gz[query]',
})
);
+
+ // zopfli requires a lot of compute time and is disabled in CI
+ if (!NO_COMPRESSION) {
+ config.plugins.push(
+ new CompressionPlugin({
+ asset: '[path].gz[query]',
+ algorithm: 'zopfli',
+ })
+ );
+ }
}
if (IS_DEV_SERVER) {
diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb
index 51e22137d6f..c405ecfdaf3 100644
--- a/db/fixtures/development/11_keys.rb
+++ b/db/fixtures/development/11_keys.rb
@@ -1,17 +1,26 @@
require './spec/support/sidekiq'
+
# Creating keys runs a gitlab-shell worker. Since we may not have the right
# gitlab-shell path set (yet) we need to disable this for these fixtures.
Sidekiq::Testing.disable! do
Gitlab::Seeder.quiet do
+ # We want to run `add_to_shell` immediately instead of after the commit, so
+ # that it falls under `Sidekiq::Testing.disable!`.
+ Key.skip_callback(:commit, :after, :add_to_shell)
+
User.first(10).each do |user|
key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0="
- user.keys.create(
+ key = user.keys.create(
title: "Sample key #{user.id}",
key: key
)
+ Sidekiq::Worker.skipping_transaction_check do
+ key.add_to_shell
+ end
+
print '.'
end
end
diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb
index 68767f0e585..5de5339b70e 100644
--- a/db/fixtures/development/14_pipelines.rb
+++ b/db/fixtures/development/14_pipelines.rb
@@ -112,6 +112,10 @@ class Gitlab::Seeder::Pipelines
setup_artifacts(build)
setup_build_log(build)
+
+ build.project.environments.
+ find_or_create_by(name: build.expanded_environment_name)
+
build.save
end
end
diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb
index 75457b2d369..7c1d758dada 100644
--- a/db/fixtures/development/17_cycle_analytics.rb
+++ b/db/fixtures/development/17_cycle_analytics.rb
@@ -212,12 +212,9 @@ class Gitlab::Seeder::CycleAnalytics
merge_requests.each do |merge_request|
Timecop.travel 12.hours.from_now
- CreateDeploymentService.new(merge_request.project, @user, {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: @project.repository.commit('master').sha
- }).execute
+ job = merge_request.head_pipeline.builds.where.not(environment: nil).last
+
+ CreateDeploymentService.new(job).execute
end
end
end
diff --git a/db/fixtures/development/21_conversational_development_index_metrics.rb b/db/fixtures/development/21_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..4cd0a82ed1a
--- /dev/null
+++ b/db/fixtures/development/21_conversational_development_index_metrics.rb
@@ -0,0 +1,40 @@
+Gitlab::Seeder.quiet do
+ conversational_development_index_metric = ConversationalDevelopmentIndex::Metric.new(
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1
+ )
+
+ if conversational_development_index_metric.save
+ print '.'
+ else
+ puts conversational_development_index_metric.errors.full_messages
+ print 'F'
+ end
+end
diff --git a/db/migrate/20170523121229_create_conversational_development_index_metrics.rb b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..9f9ec526055
--- /dev/null
+++ b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb
@@ -0,0 +1,39 @@
+class CreateConversationalDevelopmentIndexMetrics < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :conversational_development_index_metrics do |t|
+ t.float :leader_issues, null: false
+ t.float :instance_issues, null: false
+
+ t.float :leader_notes, null: false
+ t.float :instance_notes, null: false
+
+ t.float :leader_milestones, null: false
+ t.float :instance_milestones, null: false
+
+ t.float :leader_boards, null: false
+ t.float :instance_boards, null: false
+
+ t.float :leader_merge_requests, null: false
+ t.float :instance_merge_requests, null: false
+
+ t.float :leader_ci_pipelines, null: false
+ t.float :instance_ci_pipelines, null: false
+
+ t.float :leader_environments, null: false
+ t.float :instance_environments, null: false
+
+ t.float :leader_deployments, null: false
+ t.float :instance_deployments, null: false
+
+ t.float :leader_projects_prometheus_active, null: false
+ t.float :instance_projects_prometheus_active, null: false
+
+ t.float :leader_service_desk_issues, null: false
+ t.float :instance_service_desk_issues, null: false
+
+ t.timestamps null: false
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fa1c5dc15c4..0496ce2ced3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -371,6 +371,31 @@ ActiveRecord::Schema.define(version: 20170525174156) do
add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree
add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree
+ create_table "conversational_development_index_metrics", force: :cascade do |t|
+ t.float "leader_issues", null: false
+ t.float "instance_issues", null: false
+ t.float "leader_notes", null: false
+ t.float "instance_notes", null: false
+ t.float "leader_milestones", null: false
+ t.float "instance_milestones", null: false
+ t.float "leader_boards", null: false
+ t.float "instance_boards", null: false
+ t.float "leader_merge_requests", null: false
+ t.float "instance_merge_requests", null: false
+ t.float "leader_ci_pipelines", null: false
+ t.float "instance_ci_pipelines", null: false
+ t.float "leader_environments", null: false
+ t.float "instance_environments", null: false
+ t.float "leader_deployments", null: false
+ t.float "instance_deployments", null: false
+ t.float "leader_projects_prometheus_active", null: false
+ t.float "instance_projects_prometheus_active", null: false
+ t.float "leader_service_desk_issues", null: false
+ t.float "instance_service_desk_issues", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "deploy_keys_projects", force: :cascade do |t|
t.integer "deploy_key_id", null: false
t.integer "project_id", null: false
@@ -1446,8 +1471,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.string "token"
t.boolean "pipeline_events", default: false, null: false
t.boolean "confidential_issues_events", default: false, null: false
- t.boolean "job_events", default: false, null: false
t.boolean "repository_update_events", default: false, null: false
+ t.boolean "job_events", default: false, null: false
end
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index 169e0fbae3d..3393030210e 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -94,6 +94,12 @@ the name given in `.gitlab-ci.yml` (with any variables expanded), while the
second is a "cleaned-up" version of the name, suitable for use in URLs, DNS,
etc.
+>**Note:**
+Starting with GitLab 9.3, the environment URL is exposed to the Runner via
+`$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if
+the URL was not defined there, the external URL from the environment would be
+used.
+
To sum up, with the above `.gitlab-ci.yml` we have achieved that:
- All branches will run the `test` and `build` jobs.
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 76ad7c564a3..56ff245f9f9 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -43,6 +43,7 @@ future GitLab releases.**
| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled |
| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job |
| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. |
+| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job |
| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally |
| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started |
| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` |
@@ -56,9 +57,10 @@ future GitLab releases.**
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run |
| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally |
-| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built |
+| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) |
| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built |
| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name |
+| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. |
| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project |
| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry |
| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project |
@@ -122,7 +124,7 @@ global defined variables in your job, define an empty array:
```yaml
job_name:
- variables: []
+ variables: {}
```
You are able to use other variables inside your variable definition (or escape them with `$$`):
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index fab5d14ac54..2c9aa437932 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -438,7 +438,7 @@ in your job, define an empty array:
```yaml
job_name:
- variables: []
+ variables: {}
```
Job variables priority is defined in the [variables documentation][variables].
diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md
index 044b104f5c2..3ae46019daf 100644
--- a/doc/raketasks/user_management.md
+++ b/doc/raketasks/user_management.md
@@ -71,6 +71,85 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users
bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production
```
+## Rotate Two-factor Authentication (2FA) encryption key
+
+GitLab stores the secret data enabling 2FA to work in an encrypted database
+column. The encryption key for this data is known as `otp_key_base`, and is
+stored in `config/secrets.yml`.
+
+
+If that file is leaked, but the individual 2FA secrets have not, it's possible
+to re-encrypt those secrets with a new encryption key. This allows you to change
+the leaked key without forcing all users to change their 2FA details.
+
+First, look up the old key. This is in the `config/secrets.yml` file, but
+**make sure you're working with the production section**. The line you're
+interested in will look like this:
+
+```yaml
+production:
+ otp_key_base: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
+```
+
+Next, generate a new secret:
+
+```
+# omnibus-gitlab
+sudo gitlab-rake secret
+
+# installation from source
+bundle exec rake secret RAILS_ENV=production
+```
+
+Now you need to stop the GitLab server, back up the existing secrets file and
+update the database:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo cp config/secrets.yml config/secrets.yml.bak
+sudo gitlab-rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key>
+
+# installation from source
+sudo /etc/init.d/gitlab stop
+cp config/secrets.yml config/secrets.yml.bak
+bundle exec rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> RAILS_ENV=production
+```
+
+The `<old key>` value can be read from `config/secrets.yml`; `<new key>` was
+generated earlier. The **encrypted** values for the user 2FA secrets will be
+written to the specified `filename` - you can use this to rollback in case of
+error.
+
+Finally, change `config/secrets.yml` to set `otp_key_base` to `<new key>` and
+restart. Again, make sure you're operating in the **production** section.
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+```
+
+If there are any problems (perhaps using the wrong value for `old_key`), you can
+restore your backup of `config/secrets.yml` and rollback the changes:
+
+```
+# omnibus-gitlab
+sudo gitlab-ctl stop
+sudo gitlab-rake gitlab:two_factor:rotate_key:rollback filename=backup.csv
+sudo cp config/secrets.yml.bak config/secrets.yml
+sudo gitlab-ctl start
+
+# installation from source
+sudo /etc/init.d/gitlab start
+bundle exec rake gitlab:two_factor:rotate_key:rollback filename=backup.csv RAILS_ENV=production
+cp config/secrets.yml.bak config/secrets.yml
+sudo /etc/init.d/gitlab start
+
+```
+
## Clear authentication tokens for all users. Important! Data loss!
Clear authentication tokens for all users in the GitLab database. This
diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md
index 6e274a152e5..e7596f5c577 100644
--- a/doc/user/profile/account/delete_account.md
+++ b/doc/user/profile/account/delete_account.md
@@ -25,7 +25,8 @@ Instead of being deleted, these records will be moved to a system-wide
When a user is deleted from an abuse report or spam log, these associated
records are not ghosted and will be removed, along with any groups the user
is a sole owner of. Administrators can also request this behaviour when
-deleting users from the [API](../../../api/users.md#user-deletion)
+deleting users from the [API](../../../api/users.md#user-deletion) or the
+admin area.
[ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393
[ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273
diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb
index 264df7271a3..d3732d67622 100644
--- a/lib/api/helpers/internal_helpers.rb
+++ b/lib/api/helpers/internal_helpers.rb
@@ -42,6 +42,22 @@ module API
@project, @wiki = Gitlab::RepoPath.parse(params[:project])
end
end
+
+ # Project id to pass between components that don't share/don't have
+ # access to the same filesystem mounts
+ def gl_repository
+ Gitlab::GlRepository.gl_repository(project, wiki?)
+ end
+
+ # Return the repository full path so that gitlab-shell has it when
+ # handling ssh commands
+ def repository_path
+ if wiki?
+ project.wiki.repository.path_to_repo
+ else
+ project.repository.path_to_repo
+ end
+ end
end
end
end
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index 9ebd4841296..38631953014 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -32,31 +32,23 @@ module API
actor.update_last_used_at if actor.is_a?(Key)
- access_checker = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
- access_status = access_checker
+ access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
+ access_checker = access_checker_klass
.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities)
- .check(params[:action], params[:changes])
- response = { status: access_status.status, message: access_status.message }
-
- if access_status.status
- log_user_activity(actor)
-
- # Project id to pass between components that don't share/don't have
- # access to the same filesystem mounts
- response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?)
-
- # Return the repository full path so that gitlab-shell has it when
- # handling ssh commands
- response[:repository_path] =
- if wiki?
- project.wiki.repository.path_to_repo
- else
- project.repository.path_to_repo
- end
+ begin
+ access_checker.check(params[:action], params[:changes])
+ rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e
+ return { status: false, message: e.message }
end
- response
+ log_user_activity(actor)
+
+ {
+ status: true,
+ gl_repository: gl_repository,
+ repository_path: repository_path
+ }
end
post "/lfs_authenticate" do
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 2070dbd8bc7..e8694e90cf2 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -293,7 +293,7 @@ module API
user = User.find_by(id: params[:id])
not_found!('User') unless user
- DeleteUserWorker.perform_async(current_user.id, user.id, hard_delete: params[:hard_delete])
+ user.delete_async(deleted_by: current_user, params: params)
end
desc 'Block a user. Available only for admins.'
diff --git a/lib/feature.rb b/lib/feature.rb
index 2e2b343f82c..5650a1c1334 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -27,6 +27,18 @@ class Feature
all.map(&:name).include?(feature.name)
end
+ def enabled?(key)
+ get(key).enabled?
+ end
+
+ def enable(key)
+ get(key).enable
+ end
+
+ def disable(key)
+ get(key).disable
+ end
+
private
def flipper
diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb
index c984eb20606..b6805230348 100644
--- a/lib/gitlab/checks/change_access.rb
+++ b/lib/gitlab/checks/change_access.rb
@@ -1,6 +1,20 @@
module Gitlab
module Checks
class ChangeAccess
+ ERROR_MESSAGES = {
+ push_code: 'You are not allowed to push code to this project.',
+ delete_default_branch: 'The default branch of a project cannot be deleted.',
+ force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.',
+ non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.',
+ non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.',
+ merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.',
+ push_protected_branch: 'You are not allowed to push code to protected branches on this project.',
+ change_existing_tags: 'You are not allowed to change existing tags on this project.',
+ update_protected_tag: 'Protected tags cannot be updated.',
+ delete_protected_tag: 'Protected tags cannot be deleted.',
+ create_protected_tag: 'You are not allowed to create this tag as it is protected.'
+ }.freeze
+
attr_reader :user_access, :project, :skip_authorization, :protocol
def initialize(
@@ -17,22 +31,20 @@ module Gitlab
end
def exec
- return GitAccessStatus.new(true) if skip_authorization
+ return true if skip_authorization
- error = push_checks || branch_checks || tag_checks
+ push_checks
+ branch_checks
+ tag_checks
- if error
- GitAccessStatus.new(false, error)
- else
- GitAccessStatus.new(true)
- end
+ true
end
protected
def push_checks
if user_access.cannot_do_action?(:push_code)
- "You are not allowed to push code to this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code]
end
end
@@ -40,7 +52,7 @@ module Gitlab
return unless @branch_name
if deletion? && @branch_name == project.default_branch
- return "The default branch of a project cannot be deleted."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch]
end
protected_branch_checks
@@ -50,7 +62,7 @@ module Gitlab
return unless ProtectedBranch.protected?(project, @branch_name)
if forced_push?
- return "You are not allowed to force push code to a protected branch on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch]
end
if deletion?
@@ -62,22 +74,22 @@ module Gitlab
def protected_branch_deletion_checks
unless user_access.can_delete_branch?(@branch_name)
- return 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.'
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch]
end
unless protocol == 'web'
- 'You can only delete protected branches using the web interface.'
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch]
end
end
def protected_branch_push_checks
if matching_merge_request?
unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name)
- "You are not allowed to merge code into protected branches on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch]
end
else
unless user_access.can_push_to_branch?(@branch_name)
- "You are not allowed to push code to protected branches on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch]
end
end
end
@@ -86,7 +98,7 @@ module Gitlab
return unless @tag_name
if tag_exists? && user_access.cannot_do_action?(:admin_project)
- return "You are not allowed to change existing tags on this project."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags]
end
protected_tag_checks
@@ -95,11 +107,11 @@ module Gitlab
def protected_tag_checks
return unless ProtectedTag.protected?(project, @tag_name)
- return "Protected tags cannot be updated." if update?
- return "Protected tags cannot be deleted." if deletion?
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update?
+ raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion?
unless user_access.can_create_tag?(@tag_name)
- return "You are not allowed to create this tag as it is protected."
+ raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag]
end
end
diff --git a/lib/gitlab/ci_access.rb b/lib/gitlab/ci_access.rb
new file mode 100644
index 00000000000..def1373d8cf
--- /dev/null
+++ b/lib/gitlab/ci_access.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ # For backwards compatibility, generic CI (which is a build without a user) is
+ # allowed to :build_download_code without any other checks.
+ class CiAccess
+ def can_do_action?(action)
+ action == :build_download_code
+ end
+ end
+end
diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb
index 2f9d8bfc266..ca49eda51fb 100644
--- a/lib/gitlab/etag_caching/router.rb
+++ b/lib/gitlab/etag_caching/router.rb
@@ -9,8 +9,8 @@ module Gitlab
# - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route
# - Ending in `issues/id`/realtime_changes` for the `issue_title` route
USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes
- commit pipelines merge_requests new
- environments].freeze
+ commit pipelines merge_requests builds
+ new environments].freeze
RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
@@ -44,6 +44,10 @@ module Gitlab
'project_pipeline'
),
Gitlab::EtagCaching::Router::Route.new(
+ %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z),
+ 'project_build'
+ ),
+ Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
'environments'
)
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index 7e21994a084..8926aa19925 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -20,13 +20,25 @@ module Gitlab
# We need this accessor because of `to_hash` and `init_from_hash`
attr_accessor :too_large
- # The maximum size of a diff to display.
- SIZE_LIMIT = 100.kilobytes
+ class << self
+ # The maximum size of a diff to display.
+ def size_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 200.kilobytes
+ else
+ 100.kilobytes
+ end
+ end
- # The maximum size before a diff is collapsed.
- COLLAPSE_LIMIT = 10.kilobytes
+ # The maximum size before a diff is collapsed.
+ def collapse_limit
+ if Feature.enabled?('gitlab_git_diff_size_limit_increase')
+ 100.kilobytes
+ else
+ 10.kilobytes
+ end
+ end
- class << self
def between(repo, head, base, options = {}, *paths)
straight = options.delete(:straight) || false
@@ -231,7 +243,7 @@ module Gitlab
def too_large?
if @too_large.nil?
- @too_large = @diff.bytesize >= SIZE_LIMIT
+ @too_large = @diff.bytesize >= self.class.size_limit
else
@too_large
end
@@ -246,7 +258,7 @@ module Gitlab
def collapsed?
return @collapsed if defined?(@collapsed)
- @collapsed = !expanded && @diff.bytesize >= COLLAPSE_LIMIT
+ @collapsed = !expanded && @diff.bytesize >= self.class.collapse_limit
end
def collapse!
@@ -318,14 +330,14 @@ module Gitlab
hunk.each_line do |line|
size += line.content.bytesize
- if size >= SIZE_LIMIT
+ if size >= self.class.size_limit
too_large!
return true
end
end
end
- if !expanded && size >= COLLAPSE_LIMIT
+ if !expanded && size >= self.class.collapse_limit
collapse!
return true
end
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 99724db8da2..0a19d24eb20 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -3,33 +3,39 @@
module Gitlab
class GitAccess
UnauthorizedError = Class.new(StandardError)
+ NotFoundError = Class.new(StandardError)
ERROR_MESSAGES = {
upload: 'You are not allowed to upload code for this project.',
download: 'You are not allowed to download code from this project.',
deploy_key_upload:
'This deploy key does not have write access to this project.',
- no_repo: 'A repository for this project does not exist yet.'
+ no_repo: 'A repository for this project does not exist yet.',
+ project_not_found: 'The project you were looking for could not be found.',
+ account_blocked: 'Your account has been blocked.',
+ command_not_allowed: "The command you're trying to execute is not allowed.",
+ upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.',
+ receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.'
}.freeze
DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze
PUSH_COMMANDS = %w{ git-receive-pack }.freeze
ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS
- attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities
+ attr_reader :actor, :project, :protocol, :authentication_abilities
def initialize(actor, project, protocol, authentication_abilities:)
@actor = actor
@project = project
@protocol = protocol
@authentication_abilities = authentication_abilities
- @user_access = UserAccess.new(user, project: project)
end
def check(cmd, changes)
check_protocol!
check_active_user!
check_project_accessibility!
+ check_command_disabled!(cmd)
check_command_existence!(cmd)
check_repository_existence!
@@ -40,9 +46,7 @@ module Gitlab
check_push_access!(changes)
end
- build_status_object(true)
- rescue UnauthorizedError => ex
- build_status_object(false, ex.message)
+ true
end
def guest_can_download_code?
@@ -73,19 +77,39 @@ module Gitlab
return if deploy_key?
if user && !user_access.allowed?
- raise UnauthorizedError, "Your account has been blocked."
+ raise UnauthorizedError, ERROR_MESSAGES[:account_blocked]
end
end
def check_project_accessibility!
if project.blank? || !can_read_project?
- raise UnauthorizedError, 'The project you were looking for could not be found.'
+ raise NotFoundError, ERROR_MESSAGES[:project_not_found]
+ end
+ end
+
+ def check_command_disabled!(cmd)
+ if upload_pack?(cmd)
+ check_upload_pack_disabled!
+ elsif receive_pack?(cmd)
+ check_receive_pack_disabled!
+ end
+ end
+
+ def check_upload_pack_disabled!
+ if http? && upload_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:upload_pack_disabled_over_http]
+ end
+ end
+
+ def check_receive_pack_disabled!
+ if http? && receive_pack_disabled_over_http?
+ raise UnauthorizedError, ERROR_MESSAGES[:receive_pack_disabled_over_http]
end
end
def check_command_existence!(cmd)
unless ALL_COMMANDS.include?(cmd)
- raise UnauthorizedError, "The command you're trying to execute is not allowed."
+ raise UnauthorizedError, ERROR_MESSAGES[:command_not_allowed]
end
end
@@ -138,11 +162,9 @@ module Gitlab
# Iterate over all changes to find if user allowed all of them to be applied
changes_list.each do |change|
- status = check_single_change_access(change)
- unless status.allowed?
- # If user does not have access to make at least one change - cancel all push
- raise UnauthorizedError, status.message
- end
+ # If user does not have access to make at least one change, cancel all
+ # push by allowing the exception to bubble up
+ check_single_change_access(change)
end
end
@@ -168,14 +190,40 @@ module Gitlab
actor.is_a?(DeployKey)
end
+ def ci?
+ actor == :ci
+ end
+
def can_read_project?
- if deploy_key
+ if deploy_key?
deploy_key.has_access_to?(project)
elsif user
user.can?(:read_project, project)
+ elsif ci?
+ true # allow CI (build without a user) for backwards compatibility
end || Guest.can?(:read_project, project)
end
+ def http?
+ protocol == 'http'
+ end
+
+ def upload_pack?(command)
+ command == 'git-upload-pack'
+ end
+
+ def receive_pack?(command)
+ command == 'git-receive-pack'
+ end
+
+ def upload_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.upload_pack
+ end
+
+ def receive_pack_disabled_over_http?
+ !Gitlab.config.gitlab_shell.receive_pack
+ end
+
protected
def user
@@ -185,15 +233,19 @@ module Gitlab
case actor
when User
actor
- when DeployKey
- nil
when Key
- actor.user
+ actor.user unless actor.is_a?(DeployKey)
+ when :ci
+ nil
end
end
- def build_status_object(status, message = '')
- Gitlab::GitAccessStatus.new(status, message)
+ def user_access
+ @user_access ||= if ci?
+ CiAccess.new
+ else
+ UserAccess.new(user, project: project)
+ end
end
end
end
diff --git a/lib/gitlab/git_access_status.rb b/lib/gitlab/git_access_status.rb
deleted file mode 100644
index 09bb01be694..00000000000
--- a/lib/gitlab/git_access_status.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module Gitlab
- class GitAccessStatus
- attr_accessor :status, :message
- alias_method :allowed?, :status
-
- def initialize(status, message = '')
- @status = status
- @message = message
- end
-
- def to_json(opts = nil)
- { status: @status, message: @message }.to_json(opts)
- end
- end
-end
diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb
index 67eaa5e088d..1fe5155c093 100644
--- a/lib/gitlab/git_access_wiki.rb
+++ b/lib/gitlab/git_access_wiki.rb
@@ -1,5 +1,9 @@
module Gitlab
class GitAccessWiki < GitAccess
+ ERROR_MESSAGES = {
+ write_to_wiki: "You are not allowed to write to this project's wiki."
+ }.freeze
+
def guest_can_download_code?
Guest.can?(:download_wiki_code, project)
end
@@ -9,11 +13,11 @@ module Gitlab
end
def check_single_change_access(change)
- if user_access.can_do_action?(:create_wiki)
- build_status_object(true)
- else
- build_status_object(false, "You are not allowed to write to this project's wiki.")
+ unless user_access.can_do_action?(:create_wiki)
+ raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki]
end
+
+ true
end
end
end
diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb
index 5ab3eeb3aff..f7ac48f7dbd 100644
--- a/lib/gitlab/i18n.rb
+++ b/lib/gitlab/i18n.rb
@@ -5,7 +5,10 @@ module Gitlab
AVAILABLE_LANGUAGES = {
'en' => 'English',
'es' => 'Español',
- 'de' => 'Deutsch'
+ 'de' => 'Deutsch',
+ 'zh_CN' => '简体中文',
+ 'zh_HK' => '繁體中文(香港)',
+ 'zh_TW' => '繁體中文(臺灣)'
}.freeze
def available_locales
diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb
new file mode 100644
index 00000000000..0d541935bc6
--- /dev/null
+++ b/lib/gitlab/otp_key_rotator.rb
@@ -0,0 +1,87 @@
+module Gitlab
+ # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute.
+ #
+ # When +otp_key_base+ is changed, it invalidates the current encrypted values
+ # of User#otp_secret. This class can be used to decrypt all the values with
+ # the old key, encrypt them with the new key, and and update the database
+ # with the new values.
+ #
+ # For persistence between runs, a CSV file is used with the following columns:
+ #
+ # user_id, old_value, new_value
+ #
+ # Only the encrypted values are stored in this file.
+ #
+ # As users may have their 2FA settings changed at any time, this is only
+ # guaranteed to be safe if run offline.
+ class OtpKeyRotator
+ HEADERS = %w[user_id old_value new_value].freeze
+
+ attr_reader :filename
+
+ # Create a new rotator. +filename+ is used to store values by +calculate!+,
+ # and to update the database with new and old values in +apply!+ and
+ # +rollback!+, respectively.
+ def initialize(filename)
+ @filename = filename
+ end
+
+ def rotate!(old_key:, new_key:)
+ old_key ||= Gitlab::Application.secrets.otp_key_base
+
+ raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key
+ raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64
+
+ write_csv do |csv|
+ ActiveRecord::Base.transaction do
+ User.with_two_factor.in_batches do |relation|
+ rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
+ rows.each do |row|
+ user = %i[id ciphertext iv salt].zip(row).to_h
+ new_value = reencrypt(user, old_key, new_key)
+
+ User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value)
+ csv << [user[:id], user[:ciphertext], new_value]
+ end
+ end
+ end
+ end
+ end
+
+ def rollback!
+ ActiveRecord::Base.transaction do
+ CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row|
+ User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value'])
+ end
+ end
+ end
+
+ private
+
+ attr_reader :old_key, :new_key
+
+ def otp_secret_settings
+ @otp_secret_settings ||= User.encrypted_attributes[:otp_secret]
+ end
+
+ def reencrypt(user, old_key, new_key)
+ original = user[:ciphertext].unpack("m").join
+ opts = {
+ iv: user[:iv].unpack("m").join,
+ salt: user[:salt].unpack("m").join,
+ algorithm: otp_secret_settings[:algorithm],
+ insecure_mode: otp_secret_settings[:insecure_mode]
+ }
+
+ decrypted = Encryptor.decrypt(original, opts.merge(key: old_key))
+ encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key))
+ [encrypted].pack("m")
+ end
+
+ def write_csv(&blk)
+ File.open(filename, "w") do |file|
+ yield CSV.new(file, headers: HEADERS, write_headers: false)
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake
index fc0ccc726ed..7728c485e8d 100644
--- a/lib/tasks/gitlab/two_factor.rake
+++ b/lib/tasks/gitlab/two_factor.rake
@@ -19,5 +19,21 @@ namespace :gitlab do
puts "There are currently no users with 2FA enabled.".color(:yellow)
end
end
+
+ namespace :rotate_key do
+ def rotator
+ @rotator ||= Gitlab::OtpKeyRotator.new(ENV['filename'])
+ end
+
+ desc "Encrypt user OTP secrets with a new encryption key"
+ task apply: :environment do |t, args|
+ rotator.rotate!(old_key: ENV['old_key'], new_key: ENV['new_key'])
+ end
+
+ desc "Rollback to secrets encrypted with the old encryption key"
+ task rollback: :environment do
+ rotator.rollback!
+ end
+ end
end
end
diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
index bc76d7edc55..50b8e331469 100644
--- a/lib/tasks/import.rake
+++ b/lib/tasks/import.rake
@@ -37,7 +37,7 @@ class GithubImport
end
def import!
- @project.import_start
+ @project.force_import_start
timings = Benchmark.measure do
Github::Import.new(@project, @options).execute
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
new file mode 100644
index 00000000000..c2d69b122e2
--- /dev/null
+++ b/locale/zh_CN/gitlab.po
@@ -0,0 +1,225 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-05-04 19:24-0500\n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_CN\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "提交"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "周期分析概述了项目从想法到产品实现的各阶段所需的时间。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "编码"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "议题"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "计划"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生产"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "评审"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "预发布"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "测试"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "From issue creation until deploy to production"
+msgstr "从创建议题到部署至生产环境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "从合并请求被合并后到部署至生产环境"
+
+msgid "Introducing Cycle Analytics"
+msgstr "周期分析简介"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最后 %d 天"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多显示 %d 个事件"
+
+msgid "Median"
+msgstr "中位数"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新议题"
+
+msgid "Not available"
+msgstr "数据不足"
+
+msgid "Not enough data"
+msgstr "数据不足"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "开始于"
+
+msgid "Pipeline Health"
+msgstr "流水线健康指标"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "项目生命周期"
+
+msgid "Read more"
+msgstr "了解更多"
+
+msgid "Related Commits"
+msgstr "相关的提交"
+
+msgid "Related Deployed Jobs"
+msgstr "相关的部署作业"
+
+msgid "Related Issues"
+msgstr "相关的议题"
+
+msgid "Related Jobs"
+msgstr "相关的作业"
+
+msgid "Related Merge Requests"
+msgstr "相关的合并请求"
+
+msgid "Related Merged Requests"
+msgstr "相关已合并的合并请求"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "显示 %d 个事件"
+
+msgid ""
+"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."
+msgstr "编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "与该阶段相关的事件。"
+
+msgid ""
+"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."
+msgstr "议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"
+
+msgid "The phase of the development lifecycle."
+msgstr "项目生命周期中的各个阶段。"
+
+msgid ""
+"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."
+msgstr "计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"
+
+msgid ""
+"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."
+msgstr "生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"
+
+msgid ""
+"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."
+msgstr "评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"
+
+msgid ""
+"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."
+msgstr "预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"
+
+msgid ""
+"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."
+msgstr "测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "该阶段每条数据所花的时间"
+
+msgid ""
+"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."
+msgstr "中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"
+
+msgid "Time before an issue gets scheduled"
+msgstr "议题被列入日程表的时间"
+
+msgid "Time before an issue starts implementation"
+msgstr "开始进行编码前的时间"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "从创建合并请求到被合并或关闭的时间"
+
+msgid "Time until first merge request"
+msgstr "创建第一个合并请求之前的时间"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小时"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分钟"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "总时间"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有提交和合并的总测试时间"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "权限不足。如需查看相关数据,请向管理员申请权限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "该阶段的数据不足,无法显示。"
+
+msgid "You need permission."
+msgstr "您需要相关的权限。"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
diff --git a/locale/zh_CN/gitlab.po.time_stamp b/locale/zh_CN/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_CN/gitlab.po.time_stamp
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
new file mode 100644
index 00000000000..6d56b2897fa
--- /dev/null
+++ b/locale/zh_HK/gitlab.po
@@ -0,0 +1,225 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-05-04 19:24-0500\n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_HK\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "提交"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "週期分析概述了項目從想法到產品實現的各階段所需的時間。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "編碼"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "生產"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "評審"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "預發布"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "From issue creation until deploy to production"
+msgstr "從創建議題到部署到生產環境"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從合併請求的合併到部署至生產環境"
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分析簡介"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最後 %d 天"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多顯示 %d 個事件"
+
+msgid "Median"
+msgstr "中位數"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新議題"
+
+msgid "Not available"
+msgstr "不可用"
+
+msgid "Not enough data"
+msgstr "數據不足"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Pipeline Health"
+msgstr "流水線健康指標"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "項目生命週期"
+
+msgid "Read more"
+msgstr "了解更多"
+
+msgid "Related Commits"
+msgstr "相關的提交"
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題"
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的合併請求"
+
+msgid "Related Merged Requests"
+msgstr "相關已合併的合並請求"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid ""
+"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."
+msgstr "編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "與該階段相關的事件。"
+
+msgid ""
+"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."
+msgstr "議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"
+
+msgid "The phase of the development lifecycle."
+msgstr "項目生命週期中的各個階段。"
+
+msgid ""
+"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."
+msgstr "計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"
+
+msgid ""
+"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."
+msgstr "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"
+
+msgid ""
+"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."
+msgstr "評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"
+
+msgid ""
+"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."
+msgstr "預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"
+
+msgid ""
+"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."
+msgstr "測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "該階段每條數據所花的時間"
+
+msgid ""
+"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."
+msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "開始進行編碼前的時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "從創建合併請求到被合並或關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "創建第壹個合併請求之前的時間"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小時"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分鐘"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有提交和合併的總測試時間"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權限不足。如需查看相關數據,請向管理員申請權限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "該階段的數據不足,無法顯示。"
+
+msgid "You need permission."
+msgstr "您需要相關的權限。"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
diff --git a/locale/zh_HK/gitlab.po.time_stamp b/locale/zh_HK/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_HK/gitlab.po.time_stamp
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
new file mode 100644
index 00000000000..0caf35a915b
--- /dev/null
+++ b/locale/zh_TW/gitlab.po
@@ -0,0 +1,225 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2017-05-04 19:24-0500\n"
+"PO-Revision-Date: 2017-05-04 19:24-0500\n"
+"Last-Translator: HuangTao <htve@outlook.com>, 2017\n"
+"Language-Team: Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_TW\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgid "ByAuthor|by"
+msgstr "作者:"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "送交"
+
+msgid ""
+"Cycle Analytics gives an overview of how much time it takes to go from idea "
+"to production in your project."
+msgstr "週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "程式開發"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "議題"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "計劃"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "上線"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "複閱"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "預備"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "測試"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "部署"
+
+msgid "FirstPushedBy|First"
+msgstr "首次推送"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "推送者:"
+
+msgid "From issue creation until deploy to production"
+msgstr "從議題建立至線上部署"
+
+msgid "From merge request merge until deploy to production"
+msgstr "從請求被合併後至線上部署"
+
+msgid "Introducing Cycle Analytics"
+msgstr "週期分析簡介"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "最後 %d 天"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "最多顯示 %d 個事件"
+
+msgid "Median"
+msgstr "中位數"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "新議題"
+
+msgid "Not available"
+msgstr "無法使用"
+
+msgid "Not enough data"
+msgstr "資料不足"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "開始於"
+
+msgid "Pipeline Health"
+msgstr "流水線健康指標"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "專案生命週期"
+
+msgid "Read more"
+msgstr "了解更多"
+
+msgid "Related Commits"
+msgstr "相關的送交"
+
+msgid "Related Deployed Jobs"
+msgstr "相關的部署作業"
+
+msgid "Related Issues"
+msgstr "相關的議題"
+
+msgid "Related Jobs"
+msgstr "相關的作業"
+
+msgid "Related Merge Requests"
+msgstr "相關的合併請求"
+
+msgid "Related Merged Requests"
+msgstr "相關已合併的請求"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "顯示 %d 個事件"
+
+msgid ""
+"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."
+msgstr "程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "與該階段相關的事件。"
+
+msgid ""
+"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."
+msgstr "議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"
+
+msgid "The phase of the development lifecycle."
+msgstr "專案開發生命週期的各個階段。"
+
+msgid ""
+"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."
+msgstr "計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"
+
+msgid ""
+"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."
+msgstr "上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"
+
+msgid ""
+"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."
+msgstr "複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"
+
+msgid ""
+"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."
+msgstr "預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"
+
+msgid ""
+"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."
+msgstr "測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "每筆該階段相關資料所花的時間。"
+
+msgid ""
+"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."
+msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"
+
+msgid "Time before an issue gets scheduled"
+msgstr "議題被列入日程表的時間"
+
+msgid "Time before an issue starts implementation"
+msgstr "議題等待開始實作的時間"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "合併請求被合併或是關閉的時間"
+
+msgid "Time until first merge request"
+msgstr "第一個合併請求被建立前的時間"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "小時"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "分鐘"
+
+msgid "Time|s"
+msgstr "秒"
+
+msgid "Total Time"
+msgstr "總時間"
+
+msgid "Total test time for all commits/merges"
+msgstr "所有送交和合併的總測試時間"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "權限不足。如需查看相關資料,請向管理員申請權限。"
+
+msgid "We don't have enough data to show this stage."
+msgstr "因該階段的資料不足而無法顯示相關資訊"
+
+msgid "You need permission."
+msgstr "您需要相關的權限。"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "天"
diff --git a/locale/zh_TW/gitlab.po.time_stamp b/locale/zh_TW/gitlab.po.time_stamp
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/locale/zh_TW/gitlab.po.time_stamp
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 2ab2ca1b667..7d6c317482f 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -10,15 +10,26 @@ describe Admin::UsersController do
describe 'DELETE #user with projects' do
let(:project) { create(:empty_project, namespace: user.namespace) }
+ let!(:issue) { create(:issue, author: user) }
before do
project.team << [user, :developer]
end
- it 'deletes user' do
+ it 'deletes user and ghosts their contributions' do
delete :destroy, id: user.username, format: :json
+
+ expect(response).to have_http_status(200)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(issue.reload.author).to be_ghost
+ end
+
+ it 'deletes the user and their contributions when hard delete is specified' do
+ delete :destroy, id: user.username, hard_delete: true, format: :json
+
expect(response).to have_http_status(200)
- expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect(User.exists?(user.id)).to be_falsy
+ expect(Issue.exists?(issue.id)).to be_falsy
end
end
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 838bdae1445..7211acc53dc 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -101,26 +101,49 @@ describe Projects::JobsController do
end
describe 'GET show' do
- context 'when build exists' do
- let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
- before do
- get_show(id: build.id)
+ context 'when requesting HTML' do
+ context 'when build exists' do
+ before do
+ get_show(id: build.id)
+ end
+
+ it 'has a build' do
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:build).id).to eq(build.id)
+ end
end
- it 'has a build' do
- expect(response).to have_http_status(:ok)
- expect(assigns(:build).id).to eq(build.id)
+ context 'when build does not exist' do
+ before do
+ get_show(id: 1234)
+ end
+
+ it 'renders not_found' do
+ expect(response).to have_http_status(:not_found)
+ end
end
end
- context 'when build does not exist' do
+ context 'when requesting JSON' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
before do
- get_show(id: 1234)
+ project.add_developer(user)
+ sign_in(user)
+
+ allow_any_instance_of(Ci::Build).to receive(:merge_request).and_return(merge_request)
+
+ get_show(id: build.id, format: :json)
end
- it 'renders not_found' do
- expect(response).to have_http_status(:not_found)
+ it 'exposes needed information' do
+ expect(response).to have_http_status(:ok)
+ expect(json_response['raw_path']).to match(/builds\/\d+\/raw\z/)
+ expect(json_response.dig('merge_request', 'path')).to match(/merge_requests\/\d+\z/)
+ expect(json_response['new_issue_path'])
+ .to include('/issues/new')
end
end
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 71dd9ef3eb4..634563fc290 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -77,7 +77,7 @@ describe RegistrationsController do
end
it 'schedules the user for destruction' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {})
post(:destroy)
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index f5e99fdf00b..0bb5a86d9b9 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -64,7 +64,8 @@ FactoryGirl.define do
trait :teardown_environment do
environment 'staging'
options environment: { name: 'staging',
- action: 'stop' }
+ action: 'stop',
+ url: 'http://staging.example.com/$CI_JOB_NAME' }
end
trait :allowed_to_fail do
diff --git a/spec/factories/conversational_development_index_metrics.rb b/spec/factories/conversational_development_index_metrics.rb
new file mode 100644
index 00000000000..a5412629195
--- /dev/null
+++ b/spec/factories/conversational_development_index_metrics.rb
@@ -0,0 +1,33 @@
+FactoryGirl.define do
+ factory :conversational_development_index_metric, class: ConversationalDevelopmentIndex::Metric do
+ leader_issues 9.256
+ instance_issues 1.234
+
+ leader_notes 30.33333
+ instance_notes 28.123
+
+ leader_milestones 16.2456
+ instance_milestones 1.234
+
+ leader_boards 5.2123
+ instance_boards 3.254
+
+ leader_merge_requests 1.2
+ instance_merge_requests 0.6
+
+ leader_ci_pipelines 12.1234
+ instance_ci_pipelines 2.344
+
+ leader_environments 3.3333
+ instance_environments 2.2222
+
+ leader_deployments 1.200
+ instance_deployments 0.771
+
+ leader_projects_prometheus_active 0.111
+ instance_projects_prometheus_active 0.109
+
+ leader_service_desk_issues 15.891
+ instance_service_desk_issues 13.345
+ end
+end
diff --git a/spec/factories/forked_project_links.rb b/spec/factories/forked_project_links.rb
index b16c1272e68..66b0f248959 100644
--- a/spec/factories/forked_project_links.rb
+++ b/spec/factories/forked_project_links.rb
@@ -7,5 +7,9 @@ FactoryGirl.define do
link.forked_from_project.reload
link.forked_to_project.reload
end
+
+ trait :forked_to_empty_project do
+ association :forked_to_project, factory: :empty_project
+ end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 19a85e5a38f..e17e50db143 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -26,6 +26,22 @@ FactoryGirl.define do
visibility_level Gitlab::VisibilityLevel::PRIVATE
end
+ trait :import_scheduled do
+ import_status :scheduled
+ end
+
+ trait :import_started do
+ import_status :started
+ end
+
+ trait :import_finished do
+ import_status :finished
+ end
+
+ trait :import_failed do
+ import_status :failed
+ end
+
trait :archived do
archived true
end
diff --git a/spec/features/admin/admin_conversational_development_index_spec.rb b/spec/features/admin/admin_conversational_development_index_spec.rb
new file mode 100644
index 00000000000..739ab907a29
--- /dev/null
+++ b/spec/features/admin/admin_conversational_development_index_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe 'Admin Conversational Development Index' do
+ before do
+ login_as :admin
+ end
+
+ context 'when usage ping is disabled' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: false)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Usage ping is not enabled')
+ end
+ end
+
+ context 'when there is no data to display' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: true)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content('Data is still calculating')
+ end
+ end
+
+ context 'when there is data to display' do
+ it 'shows numbers for each metric' do
+ stub_application_setting(usage_ping_enabled: true)
+ create(:conversational_development_index_metric)
+
+ visit admin_conversational_development_index_path
+
+ expect(page).to have_content(
+ 'Issues created per active user 1.2 You 9.3 Lead 13.3%'
+ )
+ end
+ end
+end
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 376e80571d0..301a47169a4 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -22,7 +22,8 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
expect(page).to have_link('Block', href: block_admin_user_path(user))
- expect(page).to have_link('Delete', href: admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Two-factor Authentication filters' do
@@ -116,6 +117,9 @@ describe "Admin::Users", feature: true do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
+ expect(page).to have_link('Block user', href: block_admin_user_path(user))
+ expect(page).to have_link('Remove user', href: admin_user_path(user))
+ expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
end
describe 'Impersonation' do
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index fa3435ab719..3568954a548 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -15,6 +15,15 @@ RSpec.describe 'Dashboard Projects', feature: true do
expect(page).to have_content('awesome stuff')
end
+ it 'shows the last_activity_at attribute as the update date' do
+ now = Time.now
+ project.update_column(:last_activity_at, now)
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{now.getutc.iso8601}']")
+ end
+
context 'when on Starred projects tab' do
it 'shows only starred projects' do
user.toggle_star(project2)
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 0cb75538311..c4d5077e5e1 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -5,6 +5,11 @@ feature 'Expand and collapse diffs', js: true, feature: true do
let(:project) { create(:project, :repository) }
before do
+ # Set the limits to those when these specs were written, to avoid having to
+ # update the test repo every time we change them.
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes)
+
login_as :admin
# Ensure that undiffable.md is in .gitattributes
@@ -62,18 +67,6 @@ feature 'Expand and collapse diffs', js: true, feature: true do
expect(small_diff).not_to have_selector('.nothing-here-block')
end
- it 'collapses large diffs by default' do
- expect(large_diff).not_to have_selector('.code')
- expect(large_diff).to have_selector('.nothing-here-block')
- end
-
- it 'collapses large diffs for renamed files by default' do
- expect(large_diff_renamed).not_to have_selector('.code')
- expect(large_diff_renamed).to have_selector('.nothing-here-block')
- expect(large_diff_renamed).to have_selector('.js-file-title .deletion')
- expect(large_diff_renamed).to have_selector('.js-file-title .addition')
- end
-
it 'shows non-renderable diffs as such immediately, regardless of their size' do
expect(undiffable).not_to have_selector('.code')
expect(undiffable).to have_selector('.nothing-here-block')
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
index 7f669565085..27e2d5d16f3 100644
--- a/spec/features/merge_requests/conflicts_spec.rb
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -100,7 +100,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do
context 'in Parallel view mode' do
before do
- click_link('conflicts', href: /\/conflicts\Z/)
+ click_link('conflicts', href: /\/conflicts\Z/)
click_button 'Side-by-side'
end
diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb
index 4860a2a7498..44013df3ea0 100644
--- a/spec/features/merge_requests/diffs_spec.rb
+++ b/spec/features/merge_requests/diffs_spec.rb
@@ -68,9 +68,14 @@ feature 'Diffs URL', js: true, feature: true do
let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) }
let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") }
+ before do
+ forked_project.repository.after_import
+ end
+
context 'as author' do
it 'shows direct edit link' do
login_as(author_user)
+
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
@@ -81,6 +86,7 @@ feature 'Diffs URL', js: true, feature: true do
context 'as user who needs to fork' do
it 'shows fork/cancel confirmation' do
login_as(user)
+
visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb
index 4162f2579d1..ee6985ad993 100644
--- a/spec/features/projects/compare_spec.rb
+++ b/spec/features/projects/compare_spec.rb
@@ -24,6 +24,7 @@ describe "Compare", js: true do
expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("binary-encoding")
click_button "Compare"
+
expect(page).to have_content "Commits"
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index cf393afccbb..31345403702 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -239,7 +239,9 @@ feature 'Environments page', :feature, :js do
context 'when logged as developer' do
before do
- click_link 'New environment'
+ within(".top-area") do
+ click_link 'New environment'
+ end
end
context 'for valid name' do
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index c1ecb46aece..8fcf7f5fa15 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -192,4 +192,22 @@ describe IssuablesHelper do
expect(helper.issuable_filter_present?).to be_falsey
end
end
+
+ describe '#updated_at_by' do
+ let(:user) { create(:user) }
+ let(:unedited_issuable) { create(:issue) }
+ let(:edited_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) }
+ let(:edited_updated_at_by) do
+ {
+ updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: user.name,
+ path: user_path(user)
+ }
+ }
+ end
+
+ it { expect(helper.updated_at_by(unedited_issuable)).to eq({}) }
+ it { expect(helper.updated_at_by(edited_issuable)).to eq(edited_updated_at_by) }
+ end
end
diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb
index b05ae5c2232..cb727430117 100644
--- a/spec/helpers/submodule_helper_spec.rb
+++ b/spec/helpers/submodule_helper_spec.rb
@@ -52,6 +52,14 @@ describe SubmoduleHelper do
stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git'].join(''))
expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')])
end
+
+ it 'works with subgroups' do
+ allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure
+ allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root')
+ allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
+ stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-ce.git'].join(''))
+ expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-ce'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-ce', 'hash')])
+ end
end
context 'submodule on github.com' do
diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js
index 1c54cc3054c..c31642ac788 100644
--- a/spec/javascripts/environments/environment_spec.js
+++ b/spec/javascripts/environments/environment_spec.js
@@ -41,7 +41,7 @@ describe('Environment', () => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-new-environment-button').textContent,
- ).toContain('New Environment');
+ ).toContain('New environment');
expect(
component.$el.querySelector('.js-blank-state-title').textContent,
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 0030a953119..59c006aa0af 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -14,6 +14,10 @@ const issueShowInterceptor = data => (request, next) => {
}));
};
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
describe('Issuable output', () => {
document.body.innerHTML = '<span id="task_status"></span>';
@@ -50,12 +54,17 @@ describe('Issuable output', () => {
Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor);
});
- it('should render a title/description and update title/description on update', (done) => {
+ it('should render a title/description/edited and update title/description/edited on update', (done) => {
setTimeout(() => {
+ const editedText = vm.$el.querySelector('.edited-text');
+
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description');
+ expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
@@ -64,6 +73,10 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
done();
});
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js
new file mode 100644
index 00000000000..a0d0750ae34
--- /dev/null
+++ b/spec/javascripts/issue_show/components/edited_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import edited from '~/issue_show/components/edited.vue';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('edited', () => {
+ const EditedComponent = Vue.extend(edited);
+
+ it('should render an edited at+by string', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+
+ it('if no updatedAt is provided, no time element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ });
+
+ it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy();
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index 6683d581bc5..eb3111412a7 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -5,7 +5,9 @@ export default {
description: '<p>this is a description!</p>',
description_text: 'this is a description',
task_status: '2 of 4 completed',
- updated_at: new Date().toString(),
+ updated_at: '2015-05-15T12:31:04.428Z',
+ updated_by_name: 'Some User',
+ updated_by_path: '/some_user',
},
secondRequest: {
title: '<p>2</p>',
@@ -13,7 +15,9 @@ export default {
description: '<p>42</p>',
description_text: '42',
task_status: '0 of 0 completed',
- updated_at: new Date().toString(),
+ updated_at: '2016-05-15T12:31:04.428Z',
+ updated_by_name: 'Other User',
+ updated_by_path: '/other_user',
},
issueSpecRequest: {
title: '<p>this is a title</p>',
@@ -21,6 +25,8 @@ export default {
description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>',
description_text: '- [ ] Task List Item',
task_status: '0 of 1 completed',
- updated_at: new Date().toString(),
+ updated_at: '2017-05-15T12:31:04.428Z',
+ updated_by_name: 'Last User',
+ updated_by_path: '/last_user',
},
};
diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js
index e1747a82329..2c946802dcd 100644
--- a/spec/javascripts/lib/utils/ajax_cache_spec.js
+++ b/spec/javascripts/lib/utils/ajax_cache_spec.js
@@ -154,5 +154,36 @@ describe('AjaxCache', () => {
.then(done)
.catch(fail);
});
+
+ it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => {
+ const oldDummyResponse = {
+ important: 'old dummy data',
+ };
+
+ AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse;
+
+ ajaxSpy = (url) => {
+ expect(url).toBe(dummyEndpoint);
+ const deferred = $.Deferred();
+ deferred.resolve(dummyResponse);
+ return deferred.promise();
+ };
+
+ // Call without forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint)
+ .then((data) => {
+ expect(data).toBe(oldDummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+
+ // Call with forceRetrieve param
+ AjaxCache.retrieve(dummyEndpoint, true)
+ .then((data) => {
+ expect(data).toBe(dummyResponse);
+ })
+ .then(done)
+ .catch(fail);
+ });
});
});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 17aa70ff3f1..24335614e09 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -13,6 +13,23 @@ import '~/notes';
window.gl = window.gl || {};
gl.utils = gl.utils || {};
+ const htmlEscape = (comment) => {
+ const escapedString = comment.replace(/["&'<>]/g, (a) => {
+ const escapedToken = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ "'": '&#x27;',
+ '`': '&#x60;'
+ }[a];
+
+ return escapedToken;
+ });
+
+ return escapedString;
+ };
+
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
var commentsTemplate = 'issues/issue_with_comment.html.raw';
@@ -445,11 +462,17 @@ import '~/notes';
});
describe('getFormData', () => {
- it('should return form metadata object from form reference', () => {
+ let $form;
+ let sampleComment;
+
+ beforeEach(() => {
this.notes = new Notes('', []);
- const $form = $('form');
- const sampleComment = 'foobar';
+ $form = $('form');
+ sampleComment = 'foobar';
+ });
+
+ it('should return form metadata object from form reference', () => {
$form.find('textarea.js-note-text').val(sampleComment);
const { formData, formContent, formAction } = this.notes.getFormData($form);
@@ -457,6 +480,18 @@ import '~/notes';
expect(formContent).toEqual(sampleComment);
expect(formAction).toEqual($form.attr('action'));
});
+
+ it('should return form metadata with sanitized formContent from form reference', () => {
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+
+ sampleComment = '<script>alert("Boom!");</script>';
+ $form.find('textarea.js-note-text').val(sampleComment);
+
+ const { formContent } = this.notes.getFormData($form);
+
+ expect(_.escape).toHaveBeenCalledWith(sampleComment);
+ expect(formContent).toEqual('&lt;script&gt;alert(&quot;Boom!&quot;);&lt;/script&gt;');
+ });
});
describe('hasSlashCommands', () => {
@@ -512,30 +547,42 @@ import '~/notes';
});
});
+ describe('getSlashCommandDescription', () => {
+ const availableSlashCommands = [
+ { name: 'close', description: 'Close this issue', params: [] },
+ { name: 'title', description: 'Change title', params: [{}] },
+ { name: 'estimate', description: 'Set time estimate', params: [{}] }
+ ];
+
+ beforeEach(() => {
+ this.notes = new Notes();
+ });
+
+ it('should return executing slash command description when note has single slash command', () => {
+ const sampleComment = '/close';
+ expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying command to close this issue');
+ });
+
+ it('should return generic multiple slash command description when note has multiple slash commands', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying multiple commands');
+ });
+
+ it('should return generic slash command description when available slash commands list is not populated', () => {
+ const sampleComment = '/close\n/title [Duplicate] Issue foobar';
+ expect(this.notes.getSlashCommandDescription(sampleComment)).toBe('Applying command');
+ });
+ });
+
describe('createPlaceholderNote', () => {
const sampleComment = 'foobar';
const uniqueId = 'b1234-a4567';
const currentUsername = 'root';
const currentUserFullname = 'Administrator';
+ const currentUserAvatar = 'avatar_url';
beforeEach(() => {
this.notes = new Notes('', []);
- spyOn(_, 'escape').and.callFake((comment) => {
- const escapedString = comment.replace(/["&'<>]/g, (a) => {
- const escapedToken = {
- '&': '&amp;',
- '<': '&lt;',
- '>': '&gt;',
- '"': '&quot;',
- "'": '&#x27;',
- '`': '&#x60;'
- }[a];
-
- return escapedToken;
- });
-
- return escapedString;
- });
});
it('should return constructed placeholder element for regular note based on form contents', () => {
@@ -544,46 +591,59 @@ import '~/notes';
uniqueId,
isDiscussionNote: false,
currentUsername,
- currentUserFullname
+ currentUserFullname,
+ currentUserAvatar,
});
const $tempNoteHeader = $tempNote.find('.note-header');
expect($tempNote.prop('nodeName')).toEqual('LI');
expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
$tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() {
expect($(this).attr('href')).toEqual(`/${currentUsername}`);
});
+ expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar);
expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy();
expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname);
expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`);
expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment);
});
- it('should escape HTML characters from note based on form contents', () => {
- const commentWithHtml = '<script>alert("Boom!");</script>';
+ it('should return constructed placeholder element for discussion note based on form contents', () => {
const $tempNote = this.notes.createPlaceholderNote({
- formContent: commentWithHtml,
+ formContent: sampleComment,
uniqueId,
- isDiscussionNote: false,
+ isDiscussionNote: true,
currentUsername,
currentUserFullname
});
- expect(_.escape).toHaveBeenCalledWith(commentWithHtml);
- expect($tempNote.find('.note-body .note-text p').html()).toEqual('&lt;script&gt;alert("Boom!");&lt;/script&gt;');
+ expect($tempNote.prop('nodeName')).toEqual('LI');
+ expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
});
+ });
- it('should return constructed placeholder element for discussion note based on form contents', () => {
- const $tempNote = this.notes.createPlaceholderNote({
- formContent: sampleComment,
+ describe('createPlaceholderSystemNote', () => {
+ const sampleCommandDescription = 'Applying command to close this issue';
+ const uniqueId = 'b1234-a4567';
+
+ beforeEach(() => {
+ this.notes = new Notes('', []);
+ spyOn(_, 'escape').and.callFake(htmlEscape);
+ });
+
+ it('should return constructed placeholder element for system note based on form contents', () => {
+ const $tempNote = this.notes.createPlaceholderSystemNote({
+ formContent: sampleCommandDescription,
uniqueId,
- isDiscussionNote: true,
- currentUsername,
- currentUserFullname
});
expect($tempNote.prop('nodeName')).toEqual('LI');
- expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
+ expect($tempNote.attr('id')).toEqual(uniqueId);
+ expect($tempNote.hasClass('being-posted')).toBeTruthy();
+ expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
+ expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription);
});
});
@@ -595,7 +655,7 @@ import '~/notes';
it('shows a flash message', () => {
this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline);
- expect(document.querySelectorAll('.flash-alert').length).toBe(1);
+ expect($('.flash-alert').is(':visible')).toBeTruthy();
});
});
@@ -605,13 +665,12 @@ import '~/notes';
this.notes = new Notes();
});
- it('removes all the associated flash messages', () => {
+ it('hides visible flash message', () => {
this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline);
- this.notes.addFlash('Error message 2', FLASH_TYPE_ALERT, this.notes.parentTimeline);
this.notes.clearFlash();
- expect(document.querySelectorAll('.flash-alert').length).toBe(0);
+ expect($('.flash-alert').is(':visible')).toBeFalsy();
});
});
});
diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js
index b8531350e43..cecc7ceb53d 100644
--- a/spec/javascripts/pipelines/header_component_spec.js
+++ b/spec/javascripts/pipelines/header_component_spec.js
@@ -10,6 +10,9 @@ describe('Pipeline details header', () => {
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
props = {
pipeline: {
details: {
@@ -22,7 +25,7 @@ describe('Pipeline details header', () => {
},
},
id: 123,
- created_at: '2017-05-08T14:57:39.781Z',
+ created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb
index 8d81ed5856e..c0c309d8179 100644
--- a/spec/lib/gitlab/checks/change_access_spec.rb
+++ b/spec/lib/gitlab/checks/change_access_spec.rb
@@ -23,29 +23,27 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
before { project.add_developer(user) }
context 'without failed checks' do
- it "doesn't return any error" do
- expect(subject.status).to be(true)
+ it "doesn't raise an error" do
+ expect { subject }.not_to raise_error
end
end
context 'when the user is not allowed to push code' do
- it 'returns an error' do
+ it 'raises an error' do
expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.')
end
end
context 'tags check' do
let(:ref) { 'refs/tags/v1.0.0' }
- it 'returns an error if the user is not allowed to update tags' do
+ it 'raises an error if the user is not allowed to update tags' do
allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true)
expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to change existing tags on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.')
end
context 'with protected tag' do
@@ -59,8 +57,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '0000000000000000000000000000000000000000' }
it 'is prevented' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('cannot be deleted')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/)
end
end
@@ -69,8 +66,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
it 'is prevented' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('cannot be updated')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/)
end
end
end
@@ -81,15 +77,14 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:ref) { 'refs/tags/v9.1.0' }
it 'prevents creation below access level' do
- expect(subject.status).to be(false)
- expect(subject.message).to include('allowed to create this tag as it is protected')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/)
end
context 'when user has access' do
let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') }
it 'allows tag creation' do
- expect(subject.status).to be(true)
+ expect { subject }.not_to raise_error
end
end
end
@@ -101,9 +96,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:newrev) { '0000000000000000000000000000000000000000' }
let(:ref) { 'refs/heads/master' }
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('The default branch of a project cannot be deleted.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.')
end
end
@@ -113,27 +107,24 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true)
end
- it 'returns an error if the user is not allowed to do forced pushes to protected branches' do
+ it 'raises an error if the user is not allowed to do forced pushes to protected branches' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.')
end
- it 'returns an error if the user is not allowed to merge to protected branches' do
+ it 'raises an error if the user is not allowed to merge to protected branches' do
expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true)
expect(user_access).to receive(:can_merge_to_branch?).and_return(false)
expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.')
end
- it 'returns an error if the user is not allowed to push to protected branches' do
+ it 'raises an error if the user is not allowed to push to protected branches' do
expect(user_access).to receive(:can_push_to_branch?).and_return(false)
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.')
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.')
end
context 'branch deletion' do
@@ -141,9 +132,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:ref) { 'refs/heads/feature' }
context 'if the user is not allowed to delete protected branches' do
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.')
end
end
@@ -156,14 +146,13 @@ describe Gitlab::Checks::ChangeAccess, lib: true do
let(:protocol) { 'web' }
it 'allows branch deletion' do
- expect(subject.status).to be(true)
+ expect { subject }.not_to raise_error
end
end
context 'over SSH or HTTP' do
- it 'returns an error' do
- expect(subject.status).to be(false)
- expect(subject.message).to eq('You can only delete protected branches using the web interface.')
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.')
end
end
end
diff --git a/spec/lib/gitlab/ci_access_spec.rb b/spec/lib/gitlab/ci_access_spec.rb
new file mode 100644
index 00000000000..eaf8f1d0f1c
--- /dev/null
+++ b/spec/lib/gitlab/ci_access_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Gitlab::CiAccess, lib: true do
+ let(:access) { Gitlab::CiAccess.new }
+
+ describe '#can_do_action?' do
+ context 'when action is :build_download_code' do
+ it { expect(access.can_do_action?(:build_download_code)).to be_truthy }
+ end
+
+ context 'when action is not :build_download_code' do
+ it { expect(access.can_do_action?(:download_code)).to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index 0418fc0a1e2..269798c7c9e 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -67,6 +67,17 @@ describe Gitlab::EtagCaching::Router do
expect(result.name).to eq 'merge_request_pipelines'
end
+ it 'matches build endpoint' do
+ env = build_env(
+ '/my-group/my-project/builds/234.json'
+ )
+
+ result = described_class.match(env)
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_build'
+ end
+
it 'does not match blob with confusing name' do
env = build_env(
'/my-group/my-project/blob/master/pipelines.json'
diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb
index 3565e719ad3..a9a7bba2c05 100644
--- a/spec/lib/gitlab/git/diff_collection_spec.rb
+++ b/spec/lib/gitlab/git/diff_collection_spec.rb
@@ -341,7 +341,8 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do
end
context 'when diff is quite large will collapse by default' do
- let(:iterator) { [{ diff: 'a' * 20480 }] }
+ let(:iterator) { [{ diff: 'a' * (Gitlab::Git::Diff.collapse_limit + 1) }] }
+ let(:max_files) { 100 }
context 'when no collapse is set' do
let(:expanded) { true }
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 9c2e8a298c6..da213f617cc 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -31,6 +31,36 @@ EOT
[".gitmodules"]).patches.first
end
+ describe 'size limit feature toggles' do
+ context 'when the feature gitlab_git_diff_size_limit_increase is enabled' do
+ before do
+ Feature.enable('gitlab_git_diff_size_limit_increase')
+ end
+
+ it 'returns 200 KB for size_limit' do
+ expect(described_class.size_limit).to eq(200.kilobytes)
+ end
+
+ it 'returns 100 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(100.kilobytes)
+ end
+ end
+
+ context 'when the feature gitlab_git_diff_size_limit_increase is disabled' do
+ before do
+ Feature.disable('gitlab_git_diff_size_limit_increase')
+ end
+
+ it 'returns 100 KB for size_limit' do
+ expect(described_class.size_limit).to eq(100.kilobytes)
+ end
+
+ it 'returns 10 KB for collapse_limit' do
+ expect(described_class.collapse_limit).to eq(10.kilobytes)
+ end
+ end
+ end
+
describe '.new' do
context 'using a Hash' do
context 'with a small diff' do
@@ -47,7 +77,7 @@ EOT
context 'using a diff that is too large' do
it 'prunes the diff' do
- diff = described_class.new(diff: 'a' * 204800)
+ diff = described_class.new(diff: 'a' * (described_class.size_limit + 1))
expect(diff.diff).to be_empty
expect(diff).to be_too_large
@@ -85,8 +115,8 @@ EOT
# The patch total size is 200, with lines between 21 and 54.
# This is a quick-and-dirty way to test this. Ideally, a new patch is
# added to the test repo with a size that falls between the real limits.
- stub_const("#{described_class}::SIZE_LIMIT", 150)
- stub_const("#{described_class}::COLLAPSE_LIMIT", 100)
+ allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(150)
+ allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(100)
end
it 'prunes the diff as a large diff instead of as a collapsed diff' do
@@ -299,7 +329,7 @@ EOT
describe '#collapsed?' do
it 'returns true for a diff that is quite large' do
- diff = described_class.new({ diff: 'a' * 20480 }, expanded: false)
+ diff = described_class.new({ diff: 'a' * (described_class.collapse_limit + 1) }, expanded: false)
expect(diff).to be_collapsed
end
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 25769977f24..36d1d777583 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -1,10 +1,13 @@
require 'spec_helper'
describe Gitlab::GitAccess, lib: true do
- let(:access) { Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities) }
+ let(:pull_access_check) { access.check('git-upload-pack', '_any') }
+ let(:push_access_check) { access.check('git-receive-pack', '_any') }
+ let(:access) { Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: authentication_abilities) }
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:actor) { user }
+ let(:protocol) { 'ssh' }
let(:authentication_abilities) do
[
:read_project,
@@ -15,49 +18,188 @@ describe Gitlab::GitAccess, lib: true do
describe '#check with single protocols allowed' do
def disable_protocol(protocol)
- settings = ::ApplicationSetting.create_from_defaults
- settings.update_attribute(:enabled_git_access_protocol, protocol)
+ allow(Gitlab::ProtocolAccess).to receive(:allowed?).with(protocol).and_return(false)
end
context 'ssh disabled' do
before do
disable_protocol('ssh')
- @acc = Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities)
end
it 'blocks ssh git push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
it 'blocks ssh git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over SSH is not allowed')
end
end
context 'http disabled' do
+ let(:protocol) { 'http' }
+
before do
disable_protocol('http')
- @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities)
end
it 'blocks http push' do
- expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey
+ expect { push_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
it 'blocks http git pull' do
- expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey
+ expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
end
end
- describe '#check_download_access!' do
- subject { access.check('git-upload-pack', '_any') }
+ describe '#check_project_accessibility!' do
+ context 'when the project exists' do
+ context 'when actor exists' do
+ context 'when actor is a DeployKey' do
+ let(:deploy_key) { create(:deploy_key, user: user, can_push: true) }
+ let(:actor) { deploy_key }
+
+ context 'when the DeployKey has access to the project' do
+ before { deploy_key.projects << project }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the Deploykey does not have access to the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+ context 'when actor is a User' do
+ context 'when the User can read the project' do
+ before { project.team << [user, :master] }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when the User cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ # For backwards compatibility
+ context 'when actor is :ci' do
+ let(:actor) { :ci }
+ let(:authentication_abilities) { build_authentication_abilities }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+ end
+
+ context 'when actor is nil' do
+ let(:actor) { nil }
+
+ context 'when guests can read the project' do
+ let(:project) { create(:project, :repository, :public) }
+
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'does not block pushes with "not found"' do
+ expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.')
+ end
+ end
+
+ context 'when guests cannot read the project' do
+ it 'blocks pulls with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+
+ it 'blocks pushes with "not found"' do
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+ end
+
+ context 'when the project is nil' do
+ let(:project) { nil }
+
+ it 'blocks any command with "not found"' do
+ expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.')
+ end
+ end
+ end
+
+ describe '#check_command_disabled!' do
+ before { project.team << [user, :master] }
+
+ context 'over http' do
+ let(:protocol) { 'http' }
+
+ context 'when the git-upload-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.to raise_unauthorized('Pulling over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.not_to raise_error }
+ end
+ end
+
+ context 'when the git-receive-pack command is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ context 'when calling git-receive-pack' do
+ it { expect { push_access_check }.to raise_unauthorized('Pushing over HTTP is not allowed.') }
+ end
+
+ context 'when calling git-upload-pack' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
+ end
+ end
+
+ describe '#check_download_access!' do
describe 'master permissions' do
before { project.team << [user, :master] }
context 'pull code' do
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -65,8 +207,7 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :guest] }
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/You are not allowed to download code/) }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
@@ -77,24 +218,22 @@ describe Gitlab::GitAccess, lib: true do
end
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
- it { expect(subject.message).to match(/Your account has been blocked/) }
+ it { expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') }
end
end
describe 'without access to project' do
context 'pull code' do
- it { expect(subject.allowed?).to be_falsey }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'when project is public' do
let(:public_project) { create(:project, :public, :repository) }
- let(:guest_access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) }
- subject { guest_access.check('git-upload-pack', '_any') }
+ let(:access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) }
context 'when repository is enabled' do
it 'give access to download code' do
- expect(subject.allowed?).to be_truthy
+ expect { pull_access_check }.not_to raise_error
end
end
@@ -102,8 +241,7 @@ describe Gitlab::GitAccess, lib: true do
it 'does not give access to download code' do
public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.')
end
end
end
@@ -117,26 +255,26 @@ describe Gitlab::GitAccess, lib: true do
context 'when project is authorized' do
before { key.projects << project }
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'when unauthorized' do
context 'from public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
context 'from internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
context 'from private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -149,7 +287,7 @@ describe Gitlab::GitAccess, lib: true do
let(:project) { create(:project, :repository, namespace: user.namespace) }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -157,7 +295,7 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :reporter] }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
@@ -168,16 +306,24 @@ describe Gitlab::GitAccess, lib: true do
before { project.team << [user, :reporter] }
context 'pull code' do
- it { expect(subject).to be_allowed }
+ it { expect { pull_access_check }.not_to raise_error }
end
end
context 'when is not member of the project' do
context 'pull code' do
- it { expect(subject).not_to be_allowed }
+ it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') }
end
end
end
+
+ describe 'generic CI (build without a user)' do
+ let(:actor) { :ci }
+
+ context 'pull code' do
+ it { expect { pull_access_check }.not_to raise_error }
+ end
+ end
end
end
@@ -365,42 +511,32 @@ describe Gitlab::GitAccess, lib: true do
end
end
- shared_examples 'pushing code' do |can|
- subject { access.check('git-receive-pack', '_any') }
+ describe 'build authentication abilities' do
+ let(:authentication_abilities) { build_authentication_abilities }
context 'when project is authorized' do
- before { authorize }
+ before { project.team << [user, :reporter] }
- it { expect(subject).public_send(can, be_allowed) }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'when unauthorized' do
context 'to public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect(subject).not_to be_allowed }
+ it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') }
end
context 'to private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect(subject).not_to be_allowed }
- end
- end
- end
-
- describe 'build authentication abilities' do
- let(:authentication_abilities) { build_authentication_abilities }
-
- it_behaves_like 'pushing code', :not_to do
- def authorize
- project.team << [user, :reporter]
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -412,9 +548,29 @@ describe Gitlab::GitAccess, lib: true do
context 'when deploy_key can push' do
let(:can_push) { true }
- it_behaves_like 'pushing code', :to do
- def authorize
- key.projects << project
+ context 'when project is authorized' do
+ before { key.projects << project }
+
+ it { expect { push_access_check }.not_to raise_error }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -422,9 +578,29 @@ describe Gitlab::GitAccess, lib: true do
context 'when deploy_key cannot push' do
let(:can_push) { false }
- it_behaves_like 'pushing code', :not_to do
- def authorize
- key.projects << project
+ context 'when project is authorized' do
+ before { key.projects << project }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'when unauthorized' do
+ context 'to public project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') }
+ end
+
+ context 'to internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
+ end
+
+ context 'to private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') }
end
end
end
@@ -432,6 +608,14 @@ describe Gitlab::GitAccess, lib: true do
private
+ def raise_unauthorized(message)
+ raise_error(Gitlab::GitAccess::UnauthorizedError, message)
+ end
+
+ def raise_not_found(message)
+ raise_error(Gitlab::GitAccess::NotFoundError, message)
+ end
+
def build_authentication_abilities
[
:read_project,
diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb
index 1ae293416e4..a1eb95750ba 100644
--- a/spec/lib/gitlab/git_access_wiki_spec.rb
+++ b/spec/lib/gitlab/git_access_wiki_spec.rb
@@ -20,7 +20,7 @@ describe Gitlab::GitAccessWiki, lib: true do
subject { access.check('git-receive-pack', changes) }
- it { expect(subject.allowed?).to be_truthy }
+ it { expect { subject }.not_to raise_error }
end
def changes
@@ -36,7 +36,7 @@ describe Gitlab::GitAccessWiki, lib: true do
context 'when wiki feature is enabled' do
it 'give access to download wiki code' do
- expect(subject.allowed?).to be_truthy
+ expect { subject }.not_to raise_error
end
end
@@ -44,8 +44,7 @@ describe Gitlab::GitAccessWiki, lib: true do
it 'does not give access to download wiki code' do
project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
- expect(subject.allowed?).to be_falsey
- expect(subject.message).to match(/You are not allowed to download code/)
+ expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to download code from this project.')
end
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 2e9646286df..21296a36729 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -144,7 +144,9 @@ merge_access_levels:
push_access_levels:
- protected_branch
create_access_levels:
+- user
- protected_tag
+- group
container_repositories:
- project
- name
diff --git a/spec/lib/gitlab/otp_key_rotator_spec.rb b/spec/lib/gitlab/otp_key_rotator_spec.rb
new file mode 100644
index 00000000000..6e6e9ce29ac
--- /dev/null
+++ b/spec/lib/gitlab/otp_key_rotator_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe Gitlab::OtpKeyRotator do
+ let(:file) { Tempfile.new("otp-key-rotator-test") }
+ let(:filename) { file.path }
+ let(:old_key) { Gitlab::Application.secrets.otp_key_base }
+ let(:new_key) { "00" * 32 }
+ let!(:users) { create_list(:user, 5, :two_factor) }
+
+ after do
+ file.close
+ file.unlink
+ end
+
+ def data
+ CSV.read(filename)
+ end
+
+ def build_row(user, applied = false)
+ [user.id.to_s, encrypt_otp(user, old_key), encrypt_otp(user, new_key)]
+ end
+
+ def encrypt_otp(user, key)
+ opts = {
+ value: user.otp_secret,
+ iv: user.encrypted_otp_secret_iv.unpack("m").join,
+ salt: user.encrypted_otp_secret_salt.unpack("m").join,
+ algorithm: 'aes-256-cbc',
+ insecure_mode: true,
+ key: key
+ }
+ [Encryptor.encrypt(opts)].pack("m")
+ end
+
+ subject(:rotator) { described_class.new(filename) }
+
+ describe '#rotate!' do
+ subject(:rotation) { rotator.rotate!(old_key: old_key, new_key: new_key) }
+
+ it 'stores the calculated values in a spreadsheet' do
+ rotation
+
+ expect(data).to match_array(users.map {|u| build_row(u) })
+ end
+
+ context 'new key is too short' do
+ let(:new_key) { "00" * 31 }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+
+ context 'new key is the same as the old key' do
+ let(:new_key) { old_key }
+
+ it { expect { rotation }.to raise_error(ArgumentError) }
+ end
+ end
+
+ describe '#rollback!' do
+ it 'updates rows to the old value' do
+ file.puts("#{users[0].id},old,new")
+ file.close
+
+ rotator.rollback!
+
+ expect(users[0].reload.encrypted_otp_secret).to eq('old')
+ expect(users[1].reload.encrypted_otp_secret).not_to eq('old')
+ end
+ end
+end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index ced93c8f762..90aec2b45e6 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -28,9 +28,7 @@ RSpec.describe AbuseReport, type: :model do
end
it 'lets a worker delete the user' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id,
- delete_solo_owned_groups: true,
- hard_delete: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true)
subject.remove_user(deleted_by: user)
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index e2406290c6c..b0716e04d3d 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -427,6 +427,42 @@ describe Ci::Build, :models do
end
end
+ describe '#environment_url' do
+ subject { job.environment_url }
+
+ context 'when yaml environment uses $CI_COMMIT_REF_NAME' do
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ options: { environment: { url: 'http://review/$CI_COMMIT_REF_NAME' } })
+ end
+
+ it { is_expected.to eq('http://review/master') }
+ end
+
+ context 'when yaml environment uses yaml_variables containing symbol keys' do
+ let(:job) do
+ create(:ci_build,
+ yaml_variables: [{ key: :APP_HOST, value: 'host' }],
+ options: { environment: { url: 'http://review/$APP_HOST' } })
+ end
+
+ it { is_expected.to eq('http://review/host') }
+ end
+
+ context 'when yaml environment does not have url' do
+ let(:job) { create(:ci_build, environment: 'staging') }
+
+ let!(:environment) do
+ create(:environment, project: job.project, name: job.environment)
+ end
+
+ it 'returns the external_url from persisted environment' do
+ is_expected.to eq(environment.external_url)
+ end
+ end
+ end
+
describe '#starts_environment?' do
subject { build.starts_environment? }
@@ -918,6 +954,10 @@ describe Ci::Build, :models do
it { is_expected.to eq(environment) }
end
+
+ context 'when there is no environment' do
+ it { is_expected.to be_nil }
+ end
end
describe '#play' do
@@ -1139,6 +1179,7 @@ describe Ci::Build, :models do
{ key: 'CI_PROJECT_ID', value: project.id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: project.path, public: true },
{ key: 'CI_PROJECT_PATH', value: project.full_path, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path.parameterize, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: project.web_url, public: true },
{ key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true },
@@ -1176,11 +1217,6 @@ describe Ci::Build, :models do
end
context 'when build has an environment' do
- before do
- build.update(environment: 'production')
- create(:environment, project: build.project, name: 'production', slug: 'prod-slug')
- end
-
let(:environment_variables) do
[
{ key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true },
@@ -1188,7 +1224,56 @@ describe Ci::Build, :models do
]
end
- it { environment_variables.each { |v| is_expected.to include(v) } }
+ let!(:environment) do
+ create(:environment,
+ project: build.project,
+ name: 'production',
+ slug: 'prod-slug',
+ external_url: '')
+ end
+
+ before do
+ build.update(environment: 'production')
+ end
+
+ shared_examples 'containing environment variables' do
+ it { environment_variables.each { |v| is_expected.to include(v) } }
+ end
+
+ context 'when no URL was set' do
+ it_behaves_like 'containing environment variables'
+
+ it 'does not have CI_ENVIRONMENT_URL' do
+ keys = subject.map { |var| var[:key] }
+
+ expect(keys).not_to include('CI_ENVIRONMENT_URL')
+ end
+ end
+
+ context 'when an URL was set' do
+ let(:url) { 'http://host/test' }
+
+ before do
+ environment_variables <<
+ { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ context 'when the URL was set from the job' do
+ before do
+ build.update(options: { environment: { url: 'http://host/$CI_JOB_NAME' } })
+ end
+
+ it_behaves_like 'containing environment variables'
+ end
+
+ context 'when the URL was not set from the job, but environment' do
+ before do
+ environment.update(external_url: url)
+ end
+
+ it_behaves_like 'containing environment variables'
+ end
+ end
end
context 'when build started manually' do
diff --git a/spec/models/concerns/editable_spec.rb b/spec/models/concerns/editable_spec.rb
new file mode 100644
index 00000000000..cd73af3b480
--- /dev/null
+++ b/spec/models/concerns/editable_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Editable do
+ describe '#is_edited?' do
+ let(:issue) { create(:issue, last_edited_at: nil) }
+ let(:edited_issue) { create(:issue, created_at: 3.days.ago, last_edited_at: 2.days.ago) }
+
+ it { expect(issue.is_edited?).to eq(false) }
+ it { expect(edited_issue.is_edited?).to eq(true) }
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 86ab2550bfb..3ed52d42f86 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -50,7 +50,7 @@ describe Project, models: true do
it { is_expected.to have_one(:external_wiki_service).dependent(:destroy) }
it { is_expected.to have_one(:project_feature).dependent(:destroy) }
it { is_expected.to have_one(:statistics).class_name('ProjectStatistics').dependent(:delete) }
- it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) }
+ it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:delete) }
it { is_expected.to have_one(:last_event).class_name('Event') }
it { is_expected.to have_one(:forked_from_project).through(:forked_project_link) }
it { is_expected.to have_many(:commit_statuses) }
@@ -1446,16 +1446,13 @@ describe Project, models: true do
end
describe 'Project import job' do
- let(:project) { create(:empty_project) }
- let(:mirror) { false }
+ let(:project) { create(:empty_project, import_url: generate(:url)) }
before do
allow_any_instance_of(Gitlab::Shell).to receive(:import_repository)
.with(project.repository_storage_path, project.path_with_namespace, project.import_url)
.and_return(true)
- allow(project).to receive(:repository_exists?).and_return(true)
-
expect_any_instance_of(Repository).to receive(:after_import)
.and_call_original
end
@@ -1463,8 +1460,7 @@ describe Project, models: true do
it 'imports a project' do
expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original
- project.import_start
- project.add_import_job
+ project.import_schedule
expect(project.reload.import_status).to eq('finished')
end
@@ -1551,7 +1547,7 @@ describe Project, models: true do
describe '#add_import_job' do
context 'forked' do
- let(:forked_project_link) { create(:forked_project_link) }
+ let(:forked_project_link) { create(:forked_project_link, :forked_to_empty_project) }
let(:forked_from_project) { forked_project_link.forked_from_project }
let(:project) { forked_project_link.forked_to_project }
@@ -1565,9 +1561,9 @@ describe Project, models: true do
end
context 'not forked' do
- let(:project) { create(:empty_project) }
-
it 'schedules a RepositoryImportWorker job' do
+ project = create(:empty_project, import_url: generate(:url))
+
expect(RepositoryImportWorker).to receive(:perform_async).with(project.id)
project.add_import_job
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 4c37a553227..a8331ceb5ff 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -9,11 +9,12 @@ describe GroupPolicy, models: true do
let(:admin) { create(:admin) }
let(:group) { create(:group) }
+ let(:reporter_permissions) { [:admin_label] }
+
let(:master_permissions) do
[
:create_projects,
- :admin_milestones,
- :admin_label
+ :admin_milestones
]
end
@@ -42,6 +43,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -52,6 +54,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -62,6 +65,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -72,6 +76,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -82,6 +87,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -92,6 +98,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
@@ -102,14 +109,27 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
end
- describe 'private nested group inherit permissions', :nested_groups do
+ describe 'private nested group use the highest access level from the group and inherited permissions', :nested_groups do
let(:nested_group) { create(:group, :private, parent: group) }
+ before do
+ nested_group.add_guest(guest)
+ nested_group.add_guest(reporter)
+ nested_group.add_guest(developer)
+ nested_group.add_guest(master)
+
+ group.owners.destroy_all
+
+ group.add_guest(owner)
+ nested_group.add_owner(owner)
+ end
+
subject { described_class.abilities(current_user, nested_group).to_set }
context 'with no user' do
@@ -117,6 +137,7 @@ describe GroupPolicy, models: true do
it do
is_expected.not_to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -127,6 +148,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.not_to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -137,6 +159,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -147,6 +170,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.not_to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -157,6 +181,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.not_to include(*owner_permissions)
end
@@ -167,6 +192,7 @@ describe GroupPolicy, models: true do
it do
is_expected.to include(:read_group)
+ is_expected.to include(*reporter_permissions)
is_expected.to include(*master_permissions)
is_expected.to include(*owner_permissions)
end
diff --git a/spec/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
new file mode 100644
index 00000000000..1e015c71f5b
--- /dev/null
+++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe ConversationalDevelopmentIndex::MetricPresenter do
+ subject { described_class.new(metric) }
+ let(:metric) { build(:conversational_development_index_metric) }
+
+ describe '#cards' do
+ it 'includes instance score, leader score and percentage score' do
+ issues_card = subject.cards.first
+
+ expect(issues_card.instance_score).to eq 1.234
+ expect(issues_card.leader_score).to eq 9.256
+ expect(issues_card.percentage_score).to be_within(0.1).of(13.3)
+ end
+ end
+
+ describe '#idea_to_production_steps' do
+ it 'returns percentage score when it depends on a single feature' do
+ code_step = subject.idea_to_production_steps.fourth
+
+ expect(code_step.percentage_score).to be_within(0.1).of(50.0)
+ end
+
+ it 'returns percentage score when it depends on two features' do
+ issue_step = subject.idea_to_production_steps.second
+
+ expect(issue_step.percentage_score).to be_within(0.1).of(53.0)
+ end
+ end
+
+ describe '#average_percentage_score' do
+ it 'calculates an average value across all the features' do
+ expect(subject.average_percentage_score).to be_within(0.1).of(55.8)
+ end
+ end
+end
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 6ca3ef18fe6..f018b48ceb2 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -5,76 +5,217 @@ describe 'Git HTTP requests', lib: true do
include WorkhorseHelpers
include UserActivitiesHelpers
- it "gives WWW-Authenticate hints" do
- clone_get('doesnt/exist.git')
+ shared_examples 'pulls require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to downloads with status 401 Unauthorized (no project existence information leak)" do
+ download(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- expect(response.header['WWW-Authenticate']).to start_with('Basic ')
- end
+ context "when only username is provided" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- describe "User with no identities" do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository, path: 'project.git-project') }
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
- context "when the project doesn't exist" do
- context "when no authentication is provided" do
- it "responds with status 401 (no project existence information leak)" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
+ context "when authentication succeeds" do
+ it "does not respond to downloads with status 401 Unauthorized" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
+ end
+ end
- context "when username and password are provided" do
- context "when authentication fails" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
+ shared_examples 'pushes require Basic HTTP Authentication' do
+ context "when no credentials are provided" do
+ it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do
+ upload(path) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
+
+ context "when only username is provided" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username) do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
+ end
+ end
+ end
+
+ context "when username and password are provided" do
+ context "when authentication fails" do
+ it "responds to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: "wrong-password") do |response|
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to start_with('Basic ')
end
end
+ end
- context "when authentication succeeds" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
- end
+ context "when authentication succeeds" do
+ it "does not respond to uploads with status 401 Unauthorized" do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).not_to have_http_status(:unauthorized)
+ expect(response.header['WWW-Authenticate']).to be_nil
end
end
end
end
+ end
- context "when the Wiki for a project exists" do
- it "responds with the right project" do
- wiki = ProjectWiki.new(project)
- project.update_attribute(:visibility_level, Project::PUBLIC)
+ shared_examples_for 'pulls are allowed' do
+ it do
+ download(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
- download("/#{wiki.repository.path_with_namespace}.git") do |response|
- json_body = ActiveSupport::JSON.decode(response.body)
+ shared_examples_for 'pushes are allowed' do
+ it do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+ end
+ end
- expect(response).to have_http_status(200)
- expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ describe "User with no identities" do
+ let(:user) { create(:user) }
+
+ context "when the project doesn't exist" do
+ let(:path) { 'doesnt/exist.git' }
+
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when authenticated' do
+ it 'rejects downloads and uploads with 404 Not Found' do
+ download_or_upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ end
end
end
+ end
+
+ context "when requesting the Wiki" do
+ let(:wiki) { ProjectWiki.new(project) }
+ let(:path) { "/#{wiki.repository.path_with_namespace}.git" }
+
+ context "when the project is public" do
+ let(:project) { create(:project, :repository, :public, :wiki_enabled) }
+
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when unauthenticated' do
+ let(:env) { {} }
- context 'but the repo is disabled' do
- let(:project) { create(:project, :repository_disabled, :wiki_enabled) }
- let(:wiki) { ProjectWiki.new(project) }
- let(:path) { "/#{wiki.repository.path_with_namespace}.git" }
+ it_behaves_like 'pulls are allowed'
- before do
- project.team << [user, :developer]
+ it "responds to pulls with the wiki's repo" do
+ download(path) do |response|
+ json_body = ActiveSupport::JSON.decode(response.body)
+
+ expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace)
+ end
+ end
end
- it 'allows clones' do
- download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context 'when authenticated' do
+ let(:env) { { user: user.username, password: user.password } }
+
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) }
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
+ end
+ end
+
+ context 'and not on the team' do
+ it_behaves_like 'pulls are allowed'
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_wiki_error(:write_to_wiki))
+ end
+ end
end
end
+ end
- it 'allows pushes' do
- upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ context "when the project is private" do
+ let(:project) { create(:project, :repository, :private, :wiki_enabled) }
+
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
+
+ context 'when authenticated' do
+ context 'and as a developer on the team' do
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'but the repo is disabled' do
+ let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) }
+
+ it 'allows clones' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ it 'pushes are allowed' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context 'and not on the team' do
+ it 'rejects clones with 404 Not Found' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
+
+ it 'rejects pushes with 404 Not Found' do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
+ end
+ end
end
end
end
@@ -84,49 +225,60 @@ describe 'Git HTTP requests', lib: true do
let(:path) { "#{project.path_with_namespace}.git" }
context "when the project is public" do
- before do
- project.update_attribute(:visibility_level, Project::PUBLIC)
- end
+ let(:project) { create(:project, :repository, :public) }
- it "downloads get status 200" do
- download(path, {}) do |response|
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
- end
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- it "uploads get status 401" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
+ context 'when not authenticated' do
+ let(:env) { {} }
+
+ it_behaves_like 'pulls are allowed'
end
- context "with correct credentials" do
+ context "when authenticated" do
let(:env) { { user: user.username, password: user.password } }
- it "uploads get status 403" do
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'as a developer on the team' do
+ before do
+ project.team << [user, :developer]
end
- end
- context 'but git-receive-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
- upload(path, env) do |response|
- expect(response).to have_http_status(403)
+ context 'but git-receive-pack over HTTP is disabled in config' do
+ before do
+ allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false)
+ end
+
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http))
+ end
+ end
+ end
+
+ context 'but git-upload-pack over HTTP is disabled in config' do
+ it "rejects pushes with 403 Forbidden" do
+ allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+
+ download(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http))
+ end
end
end
end
- end
- context 'but git-upload-pack is disabled' do
- it "responds with status 404" do
- allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
+ context 'and not a member of the team' do
+ it_behaves_like 'pulls are allowed'
- download(path, {}) do |response|
- expect(response).to have_http_status(404)
+ it 'rejects pushes with 403 Forbidden' do
+ upload(path, env) do |response|
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(change_access_error(:push_code))
+ end
end
end
end
@@ -141,66 +293,41 @@ describe 'Git HTTP requests', lib: true do
context 'when the repo is public' do
context 'but the repo is disabled' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_disabled)
+ let(:project) { create(:project, :public, :repository, :repository_disabled) }
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
context 'but the repo is enabled' do
- it 'allows to clone the repo' do
- project = create(:project, :public, :repository_enabled)
+ let(:project) { create(:project, :public, :repository, :repository_enabled) }
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { {} }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:ok)
- end
- end
+ it_behaves_like 'pulls are allowed'
end
context 'but only project members are allowed' do
- it 'does not allow to clone the repo' do
- project = create(:project, :public, :repository_private)
+ let(:project) { create(:project, :public, :repository, :repository_private) }
- download("#{project.path_with_namespace}.git", {}) do |response|
- expect(response).to have_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
end
end
end
context "when the project is private" do
- before do
- project.update_attribute(:visibility_level, Project::PRIVATE)
- end
-
- context "when no authentication is provided" do
- it "responds with status 401 to downloads" do
- download(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
- end
+ let(:project) { create(:project, :repository, :private) }
- it "responds with status 401 to uploads" do
- upload(path, {}) do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
context "when authentication fails" do
- it "responds with status 401" do
- download(path, env) do |response|
- expect(response).to have_http_status(401)
- end
- end
-
context "when the user is IP banned" do
it "responds with status 401" do
expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true)
@@ -208,7 +335,7 @@ describe 'Git HTTP requests', lib: true do
clone_get(path, env)
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
@@ -222,37 +349,39 @@ describe 'Git HTTP requests', lib: true do
end
context "when the user is blocked" do
- it "responds with status 401" do
+ it "rejects pulls with 401 Unauthorized" do
user.block
project.team << [user, :master]
download(path, env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
- it "responds with status 401 for unknown projects (no project existence information leak)" do
+ it "rejects pulls with 401 Unauthorized for unknown projects (no project existence information leak)" do
user.block
download('doesnt/exist.git', env) do |response|
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:unauthorized)
end
end
end
context "when the user isn't blocked" do
- it "downloads get status 200" do
- expect(Rack::Attack::Allow2Ban).to receive(:reset)
-
- clone_get(path, env)
+ it "resets the IP in Rack Attack on download" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ download(path, env) do
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
end
- it "uploads get status 200" do
- upload(path, env) do |response|
- expect(response).to have_http_status(200)
+ it "resets the IP in Rack Attack on upload" do
+ expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
+
+ upload(path, env) do
+ expect(response).to have_http_status(:ok)
expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
end
@@ -272,56 +401,43 @@ describe 'Git HTTP requests', lib: true do
@token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api")
end
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
-
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 200" do
- push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'oauth2', password: @token.token } }
- expect(response).to have_http_status(200)
- end
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
context 'when user has 2FA enabled' do
let(:user) { create(:user, :two_factor) }
let(:access_token) { create(:personal_access_token, user: user) }
+ let(:path) { "#{project.path_with_namespace}.git" }
before do
project.team << [user, :master]
end
context 'when username and password are provided' do
- it 'rejects the clone attempt' do
- download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
+ it 'rejects pulls with 2FA error message' do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
end
end
it 'rejects the push attempt' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(401)
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:unauthorized)
expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP')
end
end
end
context 'when username and personal access token are provided' do
- it 'allows clones' do
- download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
- end
- end
+ let(:env) { { user: user.username, password: access_token.token } }
- it 'allows pushes' do
- upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response|
- expect(response).to have_http_status(200)
- end
- end
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
end
@@ -357,15 +473,15 @@ describe 'Git HTTP requests', lib: true do
end
context "when the user doesn't have access to the project" do
- it "downloads get status 404" do
+ it "pulls get status 404" do
download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
it "uploads get status 404" do
upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
@@ -373,28 +489,41 @@ describe 'Git HTTP requests', lib: true do
end
context "when a gitlab ci token is provided" do
+ let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, :running) }
- let(:project) { build.project }
let(:other_project) { create(:empty_project) }
- context 'when build created by system is authenticated' do
- it "downloads get status 200" do
- clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
-
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
-
- it "uploads get status 401 (no project existence information leak)" do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ before do
+ build.update!(project: project) # can't associate it on factory create
+ end
- expect(response).to have_http_status(401)
+ context 'when build created by system is authenticated' do
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
+
+ it_behaves_like 'pulls are allowed'
+
+ # A non-401 here is not an information leak since the system is
+ # "authenticated" as CI using the correct token. It does not have
+ # push access, so pushes should be rejected as forbidden, and giving
+ # a reason is fine.
+ #
+ # We know for sure it is not an information leak since pulls using
+ # the build token must be allowed.
+ it "rejects pushes with 403 Forbidden" do
+ push_get(path, env)
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
- it "downloads from other project get status 404" do
- clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ # We are "authenticated" as CI using a valid token here. But we are
+ # not authorized to see any other project, so return "not found".
+ it "rejects pulls for other project with 404 Not Found" do
+ clone_get("#{other_project.path_with_namespace}.git", env)
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to eq(git_access_error(:project_not_found))
end
end
@@ -405,31 +534,27 @@ describe 'Git HTTP requests', lib: true do
end
shared_examples 'can download code only' do
- it 'downloads get status 200' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(true)
-
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ let(:path) { "#{project.path_with_namespace}.git" }
+ let(:env) { { user: 'gitlab-ci-token', password: build.token } }
- expect(response).to have_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
+ it_behaves_like 'pulls are allowed'
- it 'downloads from non-existing repository and gets 403' do
- allow_any_instance_of(Repository).
- to receive(:exists?).and_return(false)
+ context 'when the repo does not exist' do
+ let(:project) { create(:empty_project) }
- clone_get "#{project.path_with_namespace}.git",
- user: 'gitlab-ci-token', password: build.token
+ it 'rejects pulls with 403 Forbidden' do
+ clone_get path, env
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:no_repo))
+ end
end
- it 'uploads get status 403' do
- push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
+ it 'rejects pushes with 403 Forbidden' do
+ push_get path, env
- expect(response).to have_http_status(401)
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to eq(git_access_error(:upload))
end
end
@@ -441,7 +566,7 @@ describe 'Git HTTP requests', lib: true do
it 'downloads from other project get status 403' do
clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(403)
+ expect(response).to have_http_status(:forbidden)
end
end
@@ -453,91 +578,93 @@ describe 'Git HTTP requests', lib: true do
it 'downloads from other project get status 404' do
clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(:not_found)
end
end
end
end
end
- end
- context "when the project path doesn't end in .git" do
- context "GET info/refs" do
- let(:path) { "/#{project.path_with_namespace}/info/refs" }
+ context "when the project path doesn't end in .git" do
+ let(:project) { create(:project, :repository, :public, path: 'project.git-project') }
+
+ context "GET info/refs" do
+ let(:path) { "/#{project.path_with_namespace}/info/refs" }
- context "when no params are added" do
- before { get path }
+ context "when no params are added" do
+ before { get path }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs")
+ end
end
- end
- context "when the upload-pack service is requested" do
- let(:params) { { service: 'git-upload-pack' } }
- before { get path, params }
+ context "when the upload-pack service is requested" do
+ let(:params) { { service: 'git-upload-pack' } }
+ before { get path, params }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the receive-pack service is requested" do
- let(:params) { { service: 'git-receive-pack' } }
- before { get path, params }
+ context "when the receive-pack service is requested" do
+ let(:params) { { service: 'git-receive-pack' } }
+ before { get path, params }
- it "redirects to the .git suffix version" do
- expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ it "redirects to the .git suffix version" do
+ expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}")
+ end
end
- end
- context "when the params are anything else" do
- let(:params) { { service: 'git-implode-pack' } }
- before { get path, params }
+ context "when the params are anything else" do
+ let(:params) { { service: 'git-implode-pack' } }
+ before { get path, params }
- it "redirects to the sign-in page" do
- expect(response).to redirect_to(new_user_session_path)
+ it "redirects to the sign-in page" do
+ expect(response).to redirect_to(new_user_session_path)
+ end
end
end
- end
- context "POST git-upload-pack" do
- it "fails to find a route" do
- expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-upload-pack" do
+ it "fails to find a route" do
+ expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ end
end
- end
- context "POST git-receive-pack" do
- it "failes to find a route" do
- expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ context "POST git-receive-pack" do
+ it "failes to find a route" do
+ expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError)
+ end
end
end
- end
- context "retrieving an info/refs file" do
- before { project.update_attribute(:visibility_level, Project::PUBLIC) }
+ context "retrieving an info/refs file" do
+ let(:project) { create(:project, :repository, :public) }
+
+ context "when the file exists" do
+ before do
+ # Provide a dummy file in its place
+ allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
+ allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
+ Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt')
+ end
- context "when the file exists" do
- before do
- # Provide a dummy file in its place
- allow_any_instance_of(Repository).to receive(:blob_at).and_call_original
- allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do
- Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt')
+ get "/#{project.path_with_namespace}/blob/master/info/refs"
end
- get "/#{project.path_with_namespace}/blob/master/info/refs"
+ it "returns the file" do
+ expect(response).to have_http_status(:ok)
+ end
end
- it "returns the file" do
- expect(response).to have_http_status(200)
- end
- end
+ context "when the file does not exist" do
+ before { get "/#{project.path_with_namespace}/blob/master/info/refs" }
- context "when the file does not exist" do
- before { get "/#{project.path_with_namespace}/blob/master/info/refs" }
-
- it "returns not found" do
- expect(response).to have_http_status(404)
+ it "returns not found" do
+ expect(response).to have_http_status(:not_found)
+ end
end
end
end
@@ -546,6 +673,7 @@ describe 'Git HTTP requests', lib: true do
describe "User with LDAP identity" do
let(:user) { create(:omniauth_user, extern_uid: dn) }
let(:dn) { 'uid=john,ou=people,dc=example,dc=com' }
+ let(:path) { 'doesnt/exist.git' }
before do
allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
@@ -553,44 +681,36 @@ describe 'Git HTTP requests', lib: true do
allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user)
end
- context "when authentication fails" do
- context "when no authentication is provided" do
- it "responds with status 401" do
- download('doesnt/exist.git') do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
-
- context "when username and invalid password are provided" do
- it "responds with status 401" do
- download('doesnt/exist.git', user: user.username, password: "nope") do |response|
- expect(response).to have_http_status(401)
- end
- end
- end
- end
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
context "when authentication succeeds" do
context "when the project doesn't exist" do
- it "responds with status 404" do
- download('/doesnt/exist.git', user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(404)
+ it "responds with status 404 Not Found" do
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_http_status(:not_found)
end
end
end
context "when the project exists" do
- let(:project) { create(:project, path: 'project.git-project') }
+ let(:project) { create(:project, :repository) }
+ let(:path) { "#{project.full_path}.git" }
+ let(:env) { { user: user.username, password: user.password } }
- before do
- project.team << [user, :master]
- end
+ context 'and the user is on the team' do
+ before do
+ project.team << [user, :master]
+ end
- it "responds with status 200" do
- clone_get(path, user: user.username, password: user.password) do |response|
- expect(response).to have_http_status(200)
+ it "responds with status 200" do
+ clone_get(path, env) do |response|
+ expect(response).to have_http_status(200)
+ end
end
+
+ it_behaves_like 'pulls are allowed'
+ it_behaves_like 'pushes are allowed'
end
end
end
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index 0c9b4121adf..697b150ab34 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -759,8 +759,8 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
@@ -769,8 +769,9 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -778,8 +779,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -979,8 +980,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because the build user can read the project)' do
+ expect(response).to have_http_status(403)
end
end
@@ -993,8 +994,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1006,8 +1007,8 @@ describe 'Git LFS API and storage' do
put_authorize
end
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 404 (do not leak non-public project existence)' do
+ expect(response).to have_http_status(404)
end
end
end
@@ -1079,8 +1080,8 @@ describe 'Git LFS API and storage' do
context 'tries to push to own project' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
@@ -1089,8 +1090,9 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ # I'm not sure what this tests that is different from the previous test
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
@@ -1098,8 +1100,8 @@ describe 'Git LFS API and storage' do
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it 'responds with 401' do
- expect(response).to have_http_status(401)
+ it 'responds with 403 (not 404 because project is public)' do
+ expect(response).to have_http_status(403)
end
end
end
diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb
index b4eef20d6a6..ad0d3d3839e 100644
--- a/spec/serializers/build_artifact_entity_spec.rb
+++ b/spec/serializers/build_artifact_entity_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe BuildArtifactEntity do
- let(:job) { create(:ci_build, name: 'test:job') }
+ let(:job) { create(:ci_build, name: 'test:job', artifacts_expire_at: 1.hour.from_now) }
let(:entity) do
described_class.new(job, request: double)
@@ -14,9 +14,19 @@ describe BuildArtifactEntity do
expect(subject[:name]).to eq 'test:job'
end
- it 'contains path to the artifacts' do
+ it 'exposes information about expiration of artifacts' do
+ expect(subject).to include(:expired, :expire_at)
+ end
+
+ it 'contains paths to the artifacts' do
expect(subject[:path])
.to include "jobs/#{job.id}/artifacts/download"
+
+ expect(subject[:keep_path])
+ .to include "jobs/#{job.id}/artifacts/keep"
+
+ expect(subject[:browse_path])
+ .to include "jobs/#{job.id}/artifacts/browse"
end
end
end
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
new file mode 100644
index 00000000000..e2511e8968c
--- /dev/null
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe BuildDetailsEntity do
+ set(:user) { create(:admin) }
+
+ it 'inherits from BuildEntity' do
+ expect(described_class).to be < BuildEntity
+ end
+
+ describe '#as_json' do
+ let(:project) { create(:project, :repository) }
+ let!(:build) { create(:ci_build, :failed, project: project) }
+ let(:request) { double('request') }
+ let(:entity) { described_class.new(build, request: request, current_user: user, project: project) }
+ subject { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the user has access to issues and merge requests' do
+ let!(:merge_request) do
+ create(:merge_request, source_project: project, source_branch: build.ref)
+ end
+
+ before do
+ allow(build).to receive(:merge_request).and_return(merge_request)
+ end
+
+ it 'contains the needed key value pairs' do
+ expect(subject).to include(:coverage, :erased_at, :duration)
+ expect(subject).to include(:artifacts, :runner, :pipeline)
+ expect(subject).to include(:raw_path, :merge_request)
+ expect(subject).to include(:new_issue_path)
+ end
+
+ it 'exposes details of the merge request' do
+ expect(subject[:merge_request]).to include(:iid, :path)
+ end
+
+ context 'when the build has been erased' do
+ let!(:build) { create(:ci_build, :erasable, project: project) }
+
+ it 'exposes the user whom erased the build' do
+ expect(subject).to include(:erase_path)
+ end
+ end
+
+ context 'when the build has been erased' do
+ let!(:build) { create(:ci_build, erased_at: Time.now, project: project, erased_by: user) }
+
+ it 'exposes the user whom erased the build' do
+ expect(subject).to include(:erased_by)
+ end
+ end
+ end
+
+ context 'when the user can only read the build' do
+ let(:user) { create(:user) }
+
+ it "won't display the paths to issues and merge requests" do
+ expect(subject['new_issue_path']).to be_nil
+ expect(subject['merge_request_path']).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb
index 6d5e1046e86..46d43a80ef7 100644
--- a/spec/serializers/build_entity_spec.rb
+++ b/spec/serializers/build_entity_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe BuildEntity do
let(:user) { create(:user) }
- let(:build) { create(:ci_build) }
+ let(:build) { create(:ci_build, :failed) }
let(:project) { build.project }
let(:request) { double('request') }
@@ -18,6 +18,7 @@ describe BuildEntity do
it 'contains paths to build page and retry action' do
expect(subject).to include(:build_path, :retry_path)
+ expect(subject[:retry_path]).not_to be_nil
end
it 'does not contain sensitive information' do
diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb
index b75c73e78c2..d38433c2365 100644
--- a/spec/serializers/merge_request_entity_spec.rb
+++ b/spec/serializers/merge_request_entity_spec.rb
@@ -26,7 +26,7 @@ describe MergeRequestEntity do
pipeline = build_stubbed(:ci_pipeline)
allow(resource).to receive(:head_pipeline).and_return(pipeline)
- pipeline_payload = PipelineEntity
+ pipeline_payload = PipelineDetailsEntity
.represent(pipeline, request: req)
.as_json
diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb
new file mode 100644
index 00000000000..03cc5ae9b63
--- /dev/null
+++ b/spec/serializers/pipeline_details_entity_spec.rb
@@ -0,0 +1,120 @@
+require 'spec_helper'
+
+describe PipelineDetailsEntity do
+ set(:user) { create(:user) }
+ let(:request) { double('request') }
+
+ it 'inherrits from PipelineEntity' do
+ expect(described_class).to be < PipelineEntity
+ end
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ let(:entity) do
+ described_class.represent(pipeline, request: request)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when pipeline is empty' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'contains details' do
+ expect(subject).to include :details
+ expect(subject[:details])
+ .to include :duration, :finished_at
+ expect(subject[:details])
+ .to include :stages, :artifacts, :manual_actions
+ expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
+ end
+
+ it 'contains flags' do
+ expect(subject).to include :flags
+ expect(subject[:flags])
+ .to include :latest, :stuck,
+ :yaml_errors, :retryable, :cancelable
+ end
+ end
+
+ context 'when pipeline is retryable' do
+ let(:project) { create(:empty_project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :success, project: project)
+ end
+
+ before do
+ create(:ci_build, :failed, pipeline: pipeline)
+ end
+
+ context 'user has ability to retry pipeline' do
+ before { project.team << [user, :developer] }
+
+ it 'retryable flag is true' do
+ expect(subject[:flags][:retryable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to retry pipeline' do
+ it 'retryable flag is false' do
+ expect(subject[:flags][:retryable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline is cancelable' do
+ let(:project) { create(:empty_project) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, status: :running, project: project)
+ end
+
+ before do
+ create(:ci_build, :pending, pipeline: pipeline)
+ end
+
+ context 'user has ability to cancel pipeline' do
+ before { project.add_developer(user) }
+
+ it 'cancelable flag is true' do
+ expect(subject[:flags][:cancelable]).to eq true
+ end
+ end
+
+ context 'user does not have ability to cancel pipeline' do
+ it 'cancelable flag is false' do
+ expect(subject[:flags][:cancelable]).to eq false
+ end
+ end
+ end
+
+ context 'when pipeline has YAML errors' do
+ let(:pipeline) do
+ create(:ci_pipeline, config: { rspec: { invalid: :value } })
+ end
+
+ it 'contains information about error' do
+ expect(subject[:yaml_errors]).to be_present
+ end
+
+ it 'contains flag that indicates there are errors' do
+ expect(subject[:flags][:yaml_errors]).to be true
+ end
+ end
+
+ context 'when pipeline does not have YAML errors' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ it 'does not contain field that normally holds an error' do
+ expect(subject).not_to have_key(:yaml_errors)
+ end
+
+ it 'contains flag that indicates there are no errors' do
+ expect(subject[:flags][:yaml_errors]).to be false
+ end
+ end
+ end
+end
diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb
index 88ec4ed2952..a059c2cc736 100644
--- a/spec/serializers/pipeline_entity_spec.rb
+++ b/spec/serializers/pipeline_entity_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe PipelineEntity do
- let(:user) { create(:user) }
+ set(:user) { create(:user) }
let(:request) { double('request') }
before do
@@ -28,8 +28,6 @@ describe PipelineEntity do
expect(subject).to include :details
expect(subject[:details])
.to include :duration, :finished_at
- expect(subject[:details])
- .to include :stages, :artifacts, :manual_actions
expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
end
@@ -55,20 +53,12 @@ describe PipelineEntity do
context 'user has ability to retry pipeline' do
before { project.team << [user, :developer] }
- it 'retryable flag is true' do
- expect(subject[:flags][:retryable]).to eq true
- end
-
it 'contains retry path' do
expect(subject[:retry_path]).to be_present
end
end
context 'user does not have ability to retry pipeline' do
- it 'retryable flag is false' do
- expect(subject[:flags][:retryable]).to eq false
- end
-
it 'does not contain retry path' do
expect(subject).not_to have_key(:retry_path)
end
@@ -87,11 +77,7 @@ describe PipelineEntity do
end
context 'user has ability to cancel pipeline' do
- before { project.team << [user, :developer] }
-
- it 'cancelable flag is true' do
- expect(subject[:flags][:cancelable]).to eq true
- end
+ before { project.add_developer(user) }
it 'contains cancel path' do
expect(subject[:cancel_path]).to be_present
@@ -99,42 +85,12 @@ describe PipelineEntity do
end
context 'user does not have ability to cancel pipeline' do
- it 'cancelable flag is false' do
- expect(subject[:flags][:cancelable]).to eq false
- end
-
it 'does not contain cancel path' do
expect(subject).not_to have_key(:cancel_path)
end
end
end
- context 'when pipeline has YAML errors' do
- let(:pipeline) do
- create(:ci_pipeline, config: { rspec: { invalid: :value } })
- end
-
- it 'contains flag that indicates there are errors' do
- expect(subject[:flags][:yaml_errors]).to be true
- end
-
- it 'contains information about error' do
- expect(subject[:yaml_errors]).to be_present
- end
- end
-
- context 'when pipeline does not have YAML errors' do
- let(:pipeline) { create(:ci_empty_pipeline) }
-
- it 'contains flag that indicates there are no errors' do
- expect(subject[:flags][:yaml_errors]).to be false
- end
-
- it 'does not contain field that normally holds an error' do
- expect(subject).not_to have_key(:yaml_errors)
- end
- end
-
context 'when pipeline ref is empty' do
let(:pipeline) { create(:ci_empty_pipeline) }
diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb
new file mode 100644
index 00000000000..4f25a8dcfa0
--- /dev/null
+++ b/spec/serializers/runner_entity_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe RunnerEntity do
+ let(:runner) { create(:ci_runner, :specific) }
+ let(:entity) { described_class.new(runner, request: request, current_user: user) }
+ let(:request) { double('request') }
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:admin) }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains required fields' do
+ expect(subject).to include(:id, :description)
+ expect(subject).to include(:edit_path)
+ end
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 06fbd7bad90..597c3947e71 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -296,5 +296,20 @@ describe Ci::CreatePipelineService, services: true do
expect(Environment.find_by(name: "review/master")).not_to be_nil
end
end
+
+ context 'when environment with invalid name' do
+ before do
+ config = YAML.dump(deploy: { environment: { name: 'name,with,commas' }, script: 'ls' })
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'does not create an environment' do
+ expect do
+ result = execute_service
+
+ expect(result).to be_persisted
+ end.not_to change { Environment.count }
+ end
+ end
end
end
diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb
index f35d7a33548..5398b5c3f7e 100644
--- a/spec/services/create_deployment_service_spec.rb
+++ b/spec/services/create_deployment_service_spec.rb
@@ -1,156 +1,117 @@
require 'spec_helper'
describe CreateDeploymentService, services: true do
- let(:project) { create(:empty_project) }
let(:user) { create(:user) }
+ let(:options) { nil }
+
+ let(:job) do
+ create(:ci_build,
+ ref: 'master',
+ tag: false,
+ environment: 'production',
+ options: { environment: options })
+ end
- let(:service) { described_class.new(project, user, params) }
+ let(:project) { job.project }
- describe '#execute' do
- let(:options) { nil }
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: options
- }
- end
+ let!(:environment) do
+ create(:environment, project: project, name: 'production')
+ end
- subject { service.execute }
+ let(:service) { described_class.new(job) }
- context 'when no environments exist' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
+ describe '#execute' do
+ subject { service.execute }
- it 'does create a deployment' do
+ context 'when environment exists' do
+ it 'creates a deployment' do
expect(subject).to be_persisted
end
end
- context 'when environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'production') }
-
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ context 'when environment does not exist' do
+ let(:environment) {}
- it 'does create a deployment' do
- expect(subject).to be_persisted
+ it 'does not create a deployment' do
+ expect do
+ expect(subject).to be_nil
+ end.not_to change { Deployment.count }
end
+ end
- context 'and start action is defined' do
- let(:options) { { action: 'start' } }
+ context 'when start action is defined' do
+ let(:options) { { action: 'start' } }
- context 'and environment is stopped' do
- before do
- environment.stop
- end
+ context 'and environment is stopped' do
+ before do
+ environment.stop
+ end
- it 'makes environment available' do
- subject
+ it 'makes environment available' do
+ subject
- expect(environment.reload).to be_available
- end
+ expect(environment.reload).to be_available
+ end
- it 'does create a deployment' do
- expect(subject).to be_persisted
- end
+ it 'creates a deployment' do
+ expect(subject).to be_persisted
end
end
+ end
- context 'and stop action is defined' do
- let(:options) { { action: 'stop' } }
-
- context 'and environment is available' do
- before do
- environment.start
- end
-
- it 'makes environment stopped' do
- subject
-
- expect(environment.reload).to be_stopped
- end
+ context 'when stop action is defined' do
+ let(:options) { { action: 'stop' } }
- it 'does not create a deployment' do
- expect(subject).to be_nil
- end
+ context 'and environment is available' do
+ before do
+ environment.start
end
- end
- end
- context 'for environment with invalid name' do
- let(:params) do
- {
- environment: 'name,with,commas',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142'
- }
- end
+ it 'makes environment stopped' do
+ subject
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
+ expect(environment.reload).to be_stopped
+ end
- it 'does not create a deployment' do
- expect(subject).to be_nil
+ it 'does not create a deployment' do
+ expect(subject).to be_nil
+ end
end
end
context 'when variables are used' do
- let(:params) do
- {
- environment: 'review-apps/$CI_COMMIT_REF_NAME',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142',
- options: {
- name: 'review-apps/$CI_COMMIT_REF_NAME',
- url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com'
- },
- variables: [
- { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' }
- ]
- }
+ let(:options) do
+ { name: 'review-apps/$CI_COMMIT_REF_NAME',
+ url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' }
end
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
-
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
+ before do
+ environment.update(name: 'review-apps/master')
+ job.update(environment: 'review-apps/$CI_COMMIT_REF_NAME')
end
- it 'does create a new deployment' do
+ it 'creates a new deployment' do
expect(subject).to be_persisted
end
- context 'and environment exist' do
- let!(:environment) { create(:environment, project: project, name: 'review-apps/feature-review-apps') }
-
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
-
- it 'updates external url' do
- subject
+ it 'does not create a new environment' do
+ expect { subject }.not_to change { Environment.count }
+ end
- expect(subject.environment.name).to eq('review-apps/feature-review-apps')
- expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com')
- end
+ it 'updates external url' do
+ subject
- it 'does create a new deployment' do
- expect(subject).to be_persisted
- end
+ expect(subject.environment.name).to eq('review-apps/master')
+ expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com')
end
end
context 'when project was removed' do
- let(:project) { nil }
+ let(:environment) {}
+
+ before do
+ job.update(project: nil)
+ end
it 'does not create deployment or environment' do
expect { subject }.not_to raise_error
@@ -162,34 +123,26 @@ describe CreateDeploymentService, services: true do
end
describe 'processing of builds' do
- let(:environment) { nil }
-
- shared_examples 'does not create environment and deployment' do
- it 'does not create a new environment' do
- expect { subject }.not_to change { Environment.count }
- end
-
+ shared_examples 'does not create deployment' do
it 'does not create a new deployment' do
expect { subject }.not_to change { Deployment.count }
end
it 'does not call a service' do
expect_any_instance_of(described_class).not_to receive(:execute)
+
subject
end
end
- shared_examples 'does create environment and deployment' do
- it 'does create a new environment' do
- expect { subject }.to change { Environment.count }.by(1)
- end
-
- it 'does create a new deployment' do
+ shared_examples 'creates deployment' do
+ it 'creates a new deployment' do
expect { subject }.to change { Deployment.count }.by(1)
end
- it 'does call a service' do
+ it 'calls a service' do
expect_any_instance_of(described_class).to receive(:execute)
+
subject
end
@@ -199,7 +152,7 @@ describe CreateDeploymentService, services: true do
expect(Deployment.last.deployable).to eq(deployable)
end
- it 'create environment has URL set' do
+ it 'updates environment URL' do
subject
expect(Deployment.last.environment.external_url).not_to be_nil
@@ -207,41 +160,39 @@ describe CreateDeploymentService, services: true do
end
context 'without environment specified' do
- let(:build) { create(:ci_build, project: project) }
+ let(:job) { create(:ci_build) }
- it_behaves_like 'does not create environment and deployment' do
- subject { build.success }
+ it_behaves_like 'does not create deployment' do
+ subject { job.success }
end
end
context 'when environment is specified' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) }
+ let(:deployable) { job }
+
let(:options) do
{ environment: { name: 'production', url: 'http://gitlab.com' } }
end
- context 'when build succeeds' do
- it_behaves_like 'does create environment and deployment' do
- let(:deployable) { build }
-
- subject { build.success }
+ context 'when job succeeds' do
+ it_behaves_like 'creates deployment' do
+ subject { job.success }
end
end
- context 'when build fails' do
- it_behaves_like 'does not create environment and deployment' do
- subject { build.drop }
+ context 'when job fails' do
+ it_behaves_like 'does not create deployment' do
+ subject { job.drop }
end
end
- context 'when build is retried' do
- it_behaves_like 'does create environment and deployment' do
+ context 'when job is retried' do
+ it_behaves_like 'creates deployment' do
before do
project.add_developer(user)
end
- let(:deployable) { Ci::Build.retry(build, user) }
+ let(:deployable) { Ci::Build.retry(job, user) }
subject { deployable.success }
end
@@ -250,15 +201,6 @@ describe CreateDeploymentService, services: true do
end
describe "merge request metrics" do
- let(:params) do
- {
- environment: 'production',
- ref: 'master',
- tag: false,
- sha: '97de212e80737a608d939f648d959671fb0a0142b'
- }
- end
-
let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) }
context "while updating the 'first_deployed_to_production_at' time" do
@@ -273,8 +215,8 @@ describe CreateDeploymentService, services: true do
end
it "doesn't set the time if the deploy's environment is not 'production'" do
- staging_params = params.merge(environment: 'staging')
- service = described_class.new(project, user, staging_params)
+ job.update(environment: 'staging')
+ service = described_class.new(job)
service.execute
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
@@ -298,7 +240,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time)
@@ -318,7 +260,7 @@ describe CreateDeploymentService, services: true do
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
# Current deploy
- service = described_class.new(project, user, params)
+ service = described_class.new(job)
Timecop.freeze(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 033e6ecd18c..3c566c04d6b 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -161,15 +161,13 @@ describe Projects::CreateService, '#execute', services: true do
end
context 'when a bad service template is created' do
- before do
- create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
- end
-
it 'reports an error in the imported project' do
opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce'
+ create(:service, type: 'DroneCiService', project: nil, template: true, active: true)
+
project = create_project(user, opts)
- expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/
+ expect(project.errors.full_messages_for(:base).first).to match(/Unable to save project. Error: Unable to save DroneCiService/)
expect(project.services.count).to eq 0
end
end
diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb
new file mode 100644
index 00000000000..63a1e78f274
--- /dev/null
+++ b/spec/services/submit_usage_ping_service_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe SubmitUsagePingService do
+ context 'when usage ping is disabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: false)
+ end
+
+ it 'does not run' do
+ expect(HTTParty).not_to receive(:post)
+
+ result = subject.execute
+
+ expect(result).to eq false
+ end
+ end
+
+ context 'when usage ping is enabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ it 'sends a POST request' do
+ response = stub_response(without_conv_index_params)
+
+ subject.execute
+
+ expect(response).to have_been_requested
+ end
+
+ it 'refreshes usage data statistics before submitting' do
+ stub_response(without_conv_index_params)
+
+ expect(Gitlab::UsageData).to receive(:to_json)
+ .with(force_refresh: true)
+ .and_call_original
+
+ subject.execute
+ end
+
+ it 'saves conversational development index data from the response' do
+ stub_response(with_conv_index_params)
+
+ expect { subject.execute }
+ .to change { ConversationalDevelopmentIndex::Metric.count }
+ .by(1)
+
+ expect(ConversationalDevelopmentIndex::Metric.last.leader_issues).to eq 10.2
+ end
+ end
+
+ def without_conv_index_params
+ {
+ conv_index: {}
+ }
+ end
+
+ def with_conv_index_params
+ {
+ conv_index: {
+ leader_issues: 10.2,
+ instance_issues: 3.2,
+
+ leader_notes: 25.3,
+ instance_notes: 23.2,
+
+ leader_milestones: 16.2,
+ instance_milestones: 5.5,
+
+ leader_boards: 5.2,
+ instance_boards: 3.2,
+
+ leader_merge_requests: 5.2,
+ instance_merge_requests: 3.2,
+
+ leader_ci_pipelines: 25.1,
+ instance_ci_pipelines: 21.3,
+
+ leader_environments: 3.3,
+ instance_environments: 2.2,
+
+ leader_deployments: 41.3,
+ instance_deployments: 15.2,
+
+ leader_projects_prometheus_active: 0.31,
+ instance_projects_prometheus_active: 0.30,
+
+ leader_service_desk_issues: 15.8,
+ instance_service_desk_issues: 15.1
+ }
+ }
+ end
+
+ def stub_response(body)
+ stub_request(:post, 'https://version.gitlab.com/usage_data').
+ to_return(
+ headers: { 'Content-Type' => 'application/json' },
+ body: body.to_json
+ )
+ end
+end
diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb
index 66545127a44..6e1eb5c678d 100644
--- a/spec/support/cycle_analytics_helpers.rb
+++ b/spec/support/cycle_analytics_helpers.rb
@@ -51,12 +51,43 @@ module CycleAnalyticsHelpers
end
def deploy_master(environment: 'production')
- CreateDeploymentService.new(project, user, {
- environment: environment,
- ref: 'master',
- tag: false,
- sha: project.repository.commit('master').sha
- }).execute
+ dummy_job =
+ case environment
+ when 'production'
+ dummy_production_job
+ when 'staging'
+ dummy_staging_job
+ else
+ raise ArgumentError
+ end
+
+ CreateDeploymentService.new(dummy_job).execute
+ end
+
+ def dummy_production_job
+ @dummy_job ||= new_dummy_job('production')
+ end
+
+ def dummy_staging_job
+ @dummy_job ||= new_dummy_job('staging')
+ end
+
+ def dummy_pipeline
+ @dummy_pipeline ||=
+ Ci::Pipeline.new(sha: project.repository.commit('master').sha)
+ end
+
+ def new_dummy_job(environment)
+ project.environments.find_or_create_by(name: environment)
+
+ Ci::Build.new(
+ project: project,
+ user: user,
+ environment: environment,
+ ref: 'master',
+ tag: false,
+ name: 'dummy',
+ pipeline: dummy_pipeline)
end
end
diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb
index 46b686fce94..b8289e6c5f1 100644
--- a/spec/support/git_http_helpers.rb
+++ b/spec/support/git_http_helpers.rb
@@ -35,9 +35,14 @@ module GitHttpHelpers
yield response
end
+ def download_or_upload(*args, &block)
+ download(*args, &block)
+ upload(*args, &block)
+ end
+
def auth_env(user, password, spnego_request_token)
env = workhorse_internal_api_request_header
- if user && password
+ if user
env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password)
elsif spnego_request_token
env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}"
@@ -45,4 +50,19 @@ module GitHttpHelpers
env
end
+
+ def git_access_error(error_key)
+ message = Gitlab::GitAccess::ERROR_MESSAGES[error_key]
+ message || raise("GitAccess error message key '#{error_key}' not found")
+ end
+
+ def git_access_wiki_error(error_key)
+ message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key]
+ message || raise("GitAccessWiki error message key '#{error_key}' not found")
+ end
+
+ def change_access_error(error_key)
+ message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key]
+ message || raise("ChangeAccess error message key '#{error_key}' not found")
+ end
end
diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb
new file mode 100644
index 00000000000..c3b72e7d677
--- /dev/null
+++ b/spec/uploaders/lfs_object_uploader_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe LfsObjectUploader do
+ let(:uploader) { described_class.new(build_stubbed(:empty_project)) }
+
+ describe '#cache!' do
+ it 'caches the file in the cache directory' do
+ # One to get the work dir, the other to remove it
+ expect(uploader).to receive(:workfile_path).exactly(2).times.and_call_original
+ expect(FileUtils).to receive(:mv).with(anything, /^#{uploader.work_dir}/).and_call_original
+ expect(FileUtils).to receive(:mv).with(/^#{uploader.work_dir}/, /^#{uploader.cache_dir}/).and_call_original
+
+ fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg')
+ uploader.cache!(fixture_file_upload(fixture))
+
+ expect(uploader.file.path).to start_with(uploader.cache_dir)
+ end
+ end
+
+ describe '#move_to_cache' do
+ it 'is true' do
+ expect(uploader.move_to_cache).to eq(true)
+ end
+ end
+
+ describe '#move_to_store' do
+ it 'is true' do
+ expect(uploader.move_to_store).to eq(true)
+ end
+ end
+end
diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb
index 26241044533..49b4e04dc7c 100644
--- a/spec/workers/gitlab_usage_ping_worker_spec.rb
+++ b/spec/workers/gitlab_usage_ping_worker_spec.rb
@@ -3,21 +3,11 @@ require 'spec_helper'
describe GitlabUsagePingWorker do
subject { described_class.new }
- it "sends POST request" do
- stub_application_setting(usage_ping_enabled: true)
+ it 'delegates to SubmitUsagePingService' do
+ allow(subject).to receive(:try_obtain_lease).and_return(true)
- stub_request(:post, "https://version.gitlab.com/usage_data").
- to_return(status: 200, body: '', headers: {})
- expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original
- expect(subject).to receive(:try_obtain_lease).and_return(true)
+ expect_any_instance_of(SubmitUsagePingService).to receive(:execute)
- expect(subject.perform.response.code.to_i).to eq(200)
- end
-
- it "does not run if usage ping is disabled" do
- stub_application_setting(usage_ping_enabled: false)
-
- expect(subject).not_to receive(:try_obtain_lease)
- expect(subject).not_to receive(:perform)
+ subject.perform
end
end
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 5e1cb74c7fc..6ea5569b438 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe RepositoryForkWorker do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :import_scheduled) }
let(:fork_project) { create(:project, :repository, forked_from_project: project) }
let(:shell) { Gitlab::Shell.new }
@@ -46,15 +46,27 @@ describe RepositoryForkWorker do
end
it "handles bad fork" do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+ error_message = "Unable to fork project #{project.id} for repository #{source_path} -> #{target_path}"
+
expect(shell).to receive(:fork_repository).and_return(false)
- expect(subject.logger).to receive(:error)
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError, error_message)
+ end
- subject.perform(
- project.id,
- '/test/path',
- project.full_path,
- fork_project.namespace.full_path)
+ it 'handles unexpected error' do
+ source_path = project.full_path
+ target_path = fork_project.namespace.full_path
+
+ allow_any_instance_of(Gitlab::Shell).to receive(:fork_repository).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id, '/test/path', source_path, target_path)
+ end.to raise_error(RepositoryForkWorker::ForkError)
+ expect(project.reload.import_status).to eq('failed')
end
end
end
diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb
index 5a2c0671dac..9c277c501f1 100644
--- a/spec/workers/repository_import_worker_spec.rb
+++ b/spec/workers/repository_import_worker_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe RepositoryImportWorker do
- let(:project) { create(:empty_project) }
+ let(:project) { create(:empty_project, :import_scheduled) }
subject { described_class.new }
@@ -21,15 +21,26 @@ describe RepositoryImportWorker do
context 'when the import has failed' do
it 'hide the credentials that were used in the import URL' do
error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found }
- expect_any_instance_of(Projects::ImportService).to receive(:execute).
- and_return({ status: :error, message: error })
- allow(subject).to receive(:jid).and_return('123')
- subject.perform(project.id)
+ expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error })
+ allow(subject).to receive(:jid).and_return('123')
- expect(project.reload.import_error).to include("https://*****:*****@test.com/root/repoC.git/")
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError, error)
expect(project.reload.import_jid).not_to be_nil
end
end
+
+ context 'with unexpected error' do
+ it 'marks import as failed' do
+ allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RepositoryImportWorker::ImportError)
+ expect(project.reload.import_status).to eq('failed')
+ end
+ end
end
end