summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--Gemfile.rails5.lock4
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/create_item_dropdown.js3
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue31
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue1
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue3
-rw-r--r--app/assets/javascripts/gl_dropdown.js588
-rw-r--r--app/assets/javascripts/importer_status.js17
-rw-r--r--app/assets/javascripts/lazy_loader.js18
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue73
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js17
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js29
-rw-r--r--app/assets/javascripts/notes/stores/getters.js85
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js12
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js15
-rw-r--r--app/assets/javascripts/search_autocomplete.js263
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue98
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue5
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss32
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/labels.scss78
-rw-r--r--app/assets/stylesheets/pages/search.scss72
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss5
-rw-r--r--app/assets/stylesheets/pages/todos.scss16
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/concerns/todos_actions.rb12
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb131
-rw-r--r--app/controllers/projects/todos_controller.rb14
-rw-r--r--app/finders/todos_finder.rb41
-rw-r--r--app/helpers/issuables_helper.rb13
-rw-r--r--app/helpers/namespaces_helper.rb29
-rw-r--r--app/helpers/search_helper.rb20
-rw-r--r--app/helpers/todos_helper.rb10
-rw-r--r--app/models/application_setting.rb2
-rw-r--r--app/models/clusters/applications/helm.rb49
-rw-r--r--app/models/clusters/applications/ingress.rb4
-rw-r--r--app/models/clusters/applications/jupyter.rb4
-rw-r--r--app/models/clusters/applications/prometheus.rb4
-rw-r--r--app/models/clusters/applications/runner.rb4
-rw-r--r--app/models/clusters/concerns/application_data.rb26
-rw-r--r--app/models/concerns/avatarable.rb7
-rw-r--r--app/models/concerns/issuable.rb6
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/merge_request.rb28
-rw-r--r--app/models/note.rb4
-rw-r--r--app/models/project.rb6
-rw-r--r--app/models/todo.rb11
-rw-r--r--app/services/groups/update_service.rb11
-rw-r--r--app/services/todo_service.rb30
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb90
-rw-r--r--app/services/todos/destroy/group_private_service.rb30
-rw-r--r--app/services/todos/destroy/project_private_service.rb2
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml16
-rw-r--r--app/views/admin/labels/_label.html.haml14
-rw-r--r--app/views/admin/labels/index.html.haml5
-rw-r--r--app/views/admin/projects/_projects.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml48
-rw-r--r--app/views/import/bitbucket_server/new.html.haml26
-rw-r--r--app/views/import/bitbucket_server/status.html.haml87
-rw-r--r--app/views/layouts/_search.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml2
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml37
-rw-r--r--app/views/shared/_label.html.haml6
-rw-r--r--app/views/shared/_label_row.html.haml13
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/workers/all_queues.yml1
-rw-r--r--app/workers/todos_destroyer/group_private_worker.rb10
-rw-r--r--changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml5
-rw-r--r--changelogs/unreleased/47156-improve-auto-devops-settings.yml5
-rw-r--r--changelogs/unreleased/48098-mutual-auth-cluster-applications.yml6
-rw-r--r--changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml5
-rw-r--r--changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml5
-rw-r--r--changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml5
-rw-r--r--changelogs/unreleased/git-rerere-link-doc-update.yml5
-rw-r--r--changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml5
-rw-r--r--config/routes/import.rb7
-rw-r--r--db/migrate/20180608091413_add_group_to_todos.rb36
-rw-r--r--db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb12
-rw-r--r--db/migrate/20180718005113_add_instance_statistics_visibility_to_application_setting.rb2
-rw-r--r--db/migrate/20180806094307_change_instance_stats_visibility_default.rb23
-rw-r--r--db/schema.rb12
-rw-r--r--doc/administration/gitaly/index.md38
-rw-r--r--doc/administration/operations/fast_ssh_key_lookup.md8
-rw-r--r--doc/api/todos.md1
-rw-r--r--doc/development/automatic_ce_ee_merge.md2
-rw-r--r--doc/user/profile/index.md22
-rw-r--r--doc/user/search/img/issues_mrs_shortcut.pngbin34115 -> 61888 bytes
-rw-r--r--doc/user/search/img/project_search.pngbin41900 -> 89002 bytes
-rw-r--r--doc/workflow/todos.md1
-rw-r--r--lib/api/entities.rb13
-rw-r--r--lib/bitbucket_server/client.rb71
-rw-r--r--lib/bitbucket_server/collection.rb23
-rw-r--r--lib/bitbucket_server/connection.rb122
-rw-r--r--lib/bitbucket_server/page.rb36
-rw-r--r--lib/bitbucket_server/paginator.rb38
-rw-r--r--lib/bitbucket_server/representation/activity.rb71
-rw-r--r--lib/bitbucket_server/representation/base.rb21
-rw-r--r--lib/bitbucket_server/representation/comment.rb130
-rw-r--r--lib/bitbucket_server/representation/pull_request.rb76
-rw-r--r--lib/bitbucket_server/representation/pull_request_comment.rb122
-rw-r--r--lib/bitbucket_server/representation/repo.rb69
-rw-r--r--lib/gitlab/bitbucket_server_import/importer.rb327
-rw-r--r--lib/gitlab/bitbucket_server_import/project_creator.rb36
-rw-r--r--lib/gitlab/checks/lfs_integrity.rb5
-rw-r--r--lib/gitlab/git/repository.rb11
-rw-r--r--lib/gitlab/gitaly_client/ref_service.rb17
-rw-r--r--lib/gitlab/import_sources.rb19
-rw-r--r--lib/gitlab/kubernetes/config_map.rb8
-rw-r--r--lib/gitlab/kubernetes/helm/api.rb2
-rw-r--r--lib/gitlab/kubernetes/helm/base_command.rb32
-rw-r--r--lib/gitlab/kubernetes/helm/certificate.rb73
-rw-r--r--lib/gitlab/kubernetes/helm/init_command.rb18
-rw-r--r--lib/gitlab/kubernetes/helm/install_command.rb35
-rw-r--r--lib/gitlab/kubernetes/helm/pod.rb8
-rw-r--r--locale/gitlab.pot77
-rw-r--r--qa/qa/factory/resource/kubernetes_cluster.rb5
-rw-r--r--qa/qa/page/project/operations/kubernetes/show.rb1
-rw-r--r--qa/qa/page/project/settings/ci_cd.rb6
-rw-r--r--spec/controllers/import/bitbucket_server_controller_spec.rb154
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb133
-rw-r--r--spec/factories/clusters/applications/helm.rb16
-rw-r--r--spec/factories/clusters/clusters.rb4
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/features/admin/admin_labels_spec.rb2
-rw-r--r--spec/features/admin/admin_settings_spec.rb2
-rw-r--r--spec/features/dashboard/active_tab_spec.rb4
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb5
-rw-r--r--spec/features/projects/clusters/applications_spec.rb16
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb60
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb4
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb2
-rw-r--r--spec/finders/todos_finder_spec.rb38
-rw-r--r--spec/fixtures/importers/bitbucket_server/activities.json1121
-rw-r--r--spec/fixtures/importers/bitbucket_server/pull_request.json146
-rw-r--r--spec/helpers/issuables_helper_spec.rb21
-rw-r--r--spec/helpers/namespaces_helper_spec.rb38
-rw-r--r--spec/javascripts/autosave_spec.js10
-rw-r--r--spec/javascripts/boards/issue_card_spec.js154
-rw-r--r--spec/javascripts/diffs/components/diff_line_note_form_spec.js41
-rw-r--r--spec/javascripts/fixtures/search_autocomplete.html.haml6
-rw-r--r--spec/javascripts/notes/components/discussion_counter_spec.js2
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js56
-rw-r--r--spec/javascripts/notes/mock_data.js84
-rw-r--r--spec/javascripts/notes/stores/getters_spec.js155
-rw-r--r--spec/javascripts/pipelines/pipeline_url_spec.js26
-rw-r--r--spec/javascripts/sidebar/todo_spec.js158
-rw-r--r--spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js30
-rw-r--r--spec/lib/bitbucket_server/client_spec.rb88
-rw-r--r--spec/lib/bitbucket_server/connection_spec.rb68
-rw-r--r--spec/lib/bitbucket_server/page_spec.rb51
-rw-r--r--spec/lib/bitbucket_server/paginator_spec.rb35
-rw-r--r--spec/lib/bitbucket_server/representation/activity_spec.rb38
-rw-r--r--spec/lib/bitbucket_server/representation/comment_spec.rb55
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb48
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_spec.rb79
-rw-r--r--spec/lib/bitbucket_server/representation/repo_spec.rb80
-rw-r--r--spec/lib/gitlab/bitbucket_server_import/importer_spec.rb291
-rw-r--r--spec/lib/gitlab/import_sources_spec.rb27
-rw-r--r--spec/lib/gitlab/kubernetes/config_map_spec.rb4
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/base_command_spec.rb28
-rw-r--r--spec/lib/gitlab/kubernetes/helm/certificate_spec.rb28
-rw-r--r--spec/lib/gitlab/kubernetes/helm/init_command_spec.rb4
-rw-r--r--spec/lib/gitlab/kubernetes/helm/install_command_spec.rb69
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb29
-rw-r--r--spec/models/clusters/applications/helm_spec.rb26
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb42
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb46
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb42
-rw-r--r--spec/models/clusters/applications/runner_spec.rb64
-rw-r--r--spec/models/concerns/avatarable_spec.rb4
-rw-r--r--spec/models/merge_request_spec.rb67
-rw-r--r--spec/models/project_spec.rb46
-rw-r--r--spec/models/todo_spec.rb1
-rw-r--r--spec/policies/global_policy_spec.rb16
-rw-r--r--spec/requests/api/settings_spec.rb2
-rw-r--r--spec/requests/api/todos_spec.rb14
-rw-r--r--spec/services/clusters/applications/install_service_spec.rb2
-rw-r--r--spec/services/groups/update_service_spec.rb28
-rw-r--r--spec/services/projects/create_service_spec.rb11
-rw-r--r--spec/services/todos/destroy/confidential_issue_service_spec.rb6
-rw-r--r--spec/services/todos/destroy/entity_leave_service_spec.rb223
-rw-r--r--spec/services/todos/destroy/group_private_service_spec.rb69
-rw-r--r--spec/services/todos/destroy/project_private_service_spec.rb17
-rw-r--r--spec/support/helpers/test_env.rb3
-rw-r--r--spec/support/shared_examples/controllers/todos_shared_examples.rb43
-rw-r--r--spec/support/shared_examples/instance_statistics_controllers_shared_examples.rb2
-rw-r--r--spec/workers/todos_destroyer/group_private_worker_spec.rb12
206 files changed, 7077 insertions, 1325 deletions
diff --git a/Gemfile b/Gemfile
index 687039b9afb..d9066081f74 100644
--- a/Gemfile
+++ b/Gemfile
@@ -423,7 +423,7 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 0.109.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 0.112.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
diff --git a/Gemfile.lock b/Gemfile.lock
index d8f878875f3..1537cacaadd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -284,7 +284,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
- gitaly-proto (0.109.0)
+ gitaly-proto (0.112.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@@ -1048,7 +1048,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 0.109.0)
+ gitaly-proto (~> 0.112.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock
index a763dcebe2d..39305927c0f 100644
--- a/Gemfile.rails5.lock
+++ b/Gemfile.rails5.lock
@@ -287,7 +287,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
- gitaly-proto (0.109.0)
+ gitaly-proto (0.112.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 0.109.0)
+ gitaly-proto (~> 0.112.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index fa00a3cf386..e8c59fab609 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -53,4 +53,8 @@ export default class Autosave {
return window.localStorage.removeItem(this.key);
}
+
+ dispose() {
+ this.field.off('input');
+ }
}
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index 42e9e568170..8ef9aa7f529 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -12,6 +12,7 @@ export default class CreateItemDropdown {
this.fieldName = options.fieldName;
this.onSelect = options.onSelect || (() => {});
this.getDataOption = options.getData;
+ this.getDataRemote = !!options.filterRemote;
this.createNewItemFromValueOption = options.createNewItemFromValue;
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
@@ -29,7 +30,7 @@ export default class CreateItemDropdown {
this.$dropdown.glDropdown({
data: this.getData.bind(this),
filterable: true,
- remote: false,
+ filterRemote: this.getDataRemote,
search: {
fields: ['text'],
},
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 20483161033..e64d5511d78 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -30,6 +30,7 @@ export default {
:render-header="false"
:render-diff-file="false"
:always-expanded="true"
+ :discussions-by-diff-order="true"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index ad838a32518..a73f898e10b 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -189,7 +189,6 @@ export default {
</button>
<a
v-if="lineNumber"
- v-once
:data-linenumber="lineNumber"
:href="lineHref"
>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 32f9516d332..cbe4551d06b 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,17 +1,17 @@
<script>
-import $ from 'jquery';
import { mapState, mapGetters, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue';
import { getNoteFormData } from '../store/utils';
-import Autosave from '../../autosave';
-import { DIFF_NOTE_TYPE, NOTE_TYPE } from '../constants';
+import autosave from '../../notes/mixins/autosave';
+import { DIFF_NOTE_TYPE } from '../constants';
export default {
components: {
noteForm,
},
+ mixins: [autosave],
props: {
diffFileHash: {
type: String,
@@ -41,28 +41,35 @@ export default {
},
mounted() {
if (this.isLoggedIn) {
- const noteableData = this.getNoteableData;
const keys = [
- NOTE_TYPE,
- this.noteableType,
- noteableData.id,
- noteableData.diff_head_sha,
+ this.noteableData.diff_head_sha,
DIFF_NOTE_TYPE,
- noteableData.source_project_id,
+ this.noteableData.source_project_id,
this.line.lineCode,
];
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
+ this.initAutoSave(this.noteableData, keys);
}
},
methods: {
...mapActions('diffs', ['cancelCommentForm']),
...mapActions(['saveNote', 'refetchDiscussionById']),
- handleCancelCommentForm() {
- this.autosave.reset();
+ handleCancelCommentForm(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
+
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(msg)) {
+ return;
+ }
+ }
+
this.cancelCommentForm({
lineCode: this.line.lineCode,
});
+ this.$nextTick(() => {
+ this.resetAutoSave();
+ });
},
handleSaveNote(note) {
const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 0197a510ef1..0e306f39a9f 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -101,7 +101,6 @@ export default {
class="diff-line-num new_line"
/>
<td
- v-once
:class="line.type"
class="line_content"
v-html="line.richText"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index ee5bb4d8d05..0031cedc68f 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -119,7 +119,6 @@ export default {
class="diff-line-num old_line"
/>
<td
- v-once
:id="line.left.lineCode"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
@@ -140,7 +139,6 @@ export default {
class="diff-line-num new_line"
/>
<td
- v-once
:id="line.right.lineCode"
:class="line.right.type"
class="line_content parallel right-side"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 21cf92d1bc5..11e3b781e5a 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -116,7 +116,8 @@ export default {
this.model &&
this.hasLastDeploymentKey &&
this.model.last_deployment &&
- this.model.last_deployment.deployable
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path
);
},
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 8d231e6c405..cbc05b229cb 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
+/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
/* global fuzzaldrinPlus */
import $ from 'jquery';
@@ -19,32 +19,42 @@ GitLabDropdownInput = (function() {
this.fieldName = this.options.fieldName || 'field-name';
$inputContainer = this.input.parent();
$clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input.val('').trigger('input').focus();
- };
- })(this));
+ $clearButton.on(
+ 'click',
+ (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input
+ .val('')
+ .trigger('input')
+ .focus();
+ };
+ })(this),
+ );
this.input
- .on('keydown', function (e) {
- var keyCode = e.which;
- if (keyCode === 13 && !options.elIsInput) {
- e.preventDefault();
- }
- })
- .on('input', function(e) {
- var val = e.currentTarget.value || _this.options.inputFieldName;
- val = val.split(' ').join('-') // replaces space with dash
- .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric
- .replace(/(-)\1+/g, '-'); // replace repeated dashes
- _this.cb(_this.options.fieldName, val, {}, true);
- _this.input.closest('.dropdown')
- .find('.dropdown-toggle-text')
- .text(val);
- });
+ .on('keydown', function(e) {
+ var keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .on('input', function(e) {
+ var val = e.currentTarget.value || _this.options.inputFieldName;
+ val = val
+ .split(' ')
+ .join('-') // replaces space with dash
+ .replace(/[^a-zA-Z0-9 -]/g, '')
+ .toLowerCase() // replace non alphanumeric
+ .replace(/(-)\1+/g, '-'); // replace repeated dashes
+ _this.cb(_this.options.fieldName, val, {}, true);
+ _this.input
+ .closest('.dropdown')
+ .find('.dropdown-toggle-text')
+ .text(val);
+ });
}
GitLabDropdownInput.prototype.onInput = function(cb) {
@@ -61,7 +71,7 @@ GitLabDropdownFilter = (function() {
ARROW_KEY_CODES = [38, 40];
- HAS_VALUE_CLASS = "has-value";
+ HAS_VALUE_CLASS = 'has-value';
function GitLabDropdownFilter(input, options) {
var $clearButton, $inputContainer, ref, timeout;
@@ -70,44 +80,59 @@ GitLabDropdownFilter = (function() {
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
$inputContainer = this.input.parent();
$clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input.val('').trigger('input').focus();
- };
- })(this));
+ $clearButton.on(
+ 'click',
+ (function(_this) {
+ // Clear click
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.input
+ .val('')
+ .trigger('input')
+ .focus();
+ };
+ })(this),
+ );
// Key events
- timeout = "";
+ timeout = '';
this.input
- .on('keydown', function (e) {
+ .on('keydown', function(e) {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on('input', function() {
- if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.addClass(HAS_VALUE_CLASS);
- } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.removeClass(HAS_VALUE_CLASS);
- }
- // Only filter asynchronously only if option remote is set
- if (this.options.remote) {
- clearTimeout(timeout);
- return timeout = setTimeout(function() {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(this.input.val(), function(data) {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- }.bind(this));
- }.bind(this), 250);
- } else {
- return this.filter(this.input.val());
- }
- }.bind(this));
+ .on(
+ 'input',
+ function() {
+ if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.addClass(HAS_VALUE_CLASS);
+ } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.removeClass(HAS_VALUE_CLASS);
+ }
+ // Only filter asynchronously only if option remote is set
+ if (this.options.remote) {
+ clearTimeout(timeout);
+ return (timeout = setTimeout(
+ function() {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(
+ this.input.val(),
+ function(data) {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ }.bind(this),
+ );
+ }.bind(this),
+ 250,
+ ));
+ } else {
+ return this.filter(this.input.val());
+ }
+ }.bind(this),
+ );
}
GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
@@ -120,7 +145,7 @@ GitLabDropdownFilter = (function() {
this.options.onFilter(search_text);
}
data = this.options.data();
- if ((data != null) && !this.options.filterByText) {
+ if (data != null && !this.options.filterByText) {
results = data;
if (search_text !== '') {
// When data is an array of objects therefore [object Array] e.g.
@@ -130,7 +155,7 @@ GitLabDropdownFilter = (function() {
// ]
if (_.isArray(data)) {
results = fuzzaldrinPlus.filter(data, search_text, {
- key: this.options.keys
+ key: this.options.keys,
});
} else {
// If data is grouped therefore an [object Object]. e.g.
@@ -149,7 +174,7 @@ GitLabDropdownFilter = (function() {
for (key in data) {
group = data[key];
tmp = fuzzaldrinPlus.filter(group, search_text, {
- key: this.options.keys
+ key: this.options.keys,
});
if (tmp.length) {
results[key] = tmp.map(function(item) {
@@ -180,7 +205,10 @@ GitLabDropdownFilter = (function() {
elements.show().removeClass('option-hidden');
}
- elements.parent().find('.dropdown-menu-empty-item').toggleClass('hidden', elements.is(':visible'));
+ elements
+ .parent()
+ .find('.dropdown-menu-empty-item')
+ .toggleClass('hidden', elements.is(':visible'));
}
};
@@ -194,23 +222,26 @@ GitLabDropdownRemote = (function() {
}
GitLabDropdownRemote.prototype.execute = function() {
- if (typeof this.dataEndpoint === "string") {
+ if (typeof this.dataEndpoint === 'string') {
return this.fetchData();
- } else if (typeof this.dataEndpoint === "function") {
+ } else if (typeof this.dataEndpoint === 'function') {
if (this.options.beforeSend) {
this.options.beforeSend();
}
- return this.dataEndpoint("", (function(_this) {
- // Fetch the data by calling the data funcfion
- return function(data) {
- if (_this.options.success) {
- _this.options.success(data);
- }
- if (_this.options.beforeSend) {
- return _this.options.beforeSend();
- }
- };
- })(this));
+ return this.dataEndpoint(
+ '',
+ (function(_this) {
+ // Fetch the data by calling the data funcfion
+ return function(data) {
+ if (_this.options.success) {
+ _this.options.success(data);
+ }
+ if (_this.options.beforeSend) {
+ return _this.options.beforeSend();
+ }
+ };
+ })(this),
+ );
}
};
@@ -220,33 +251,41 @@ GitLabDropdownRemote = (function() {
}
// Fetch the data through ajax if the data is a string
- return axios.get(this.dataEndpoint)
- .then(({ data }) => {
- if (this.options.success) {
- return this.options.success(data);
- }
- });
+ return axios.get(this.dataEndpoint).then(({ data }) => {
+ if (this.options.success) {
+ return this.options.success(data);
+ }
+ });
};
return GitLabDropdownRemote;
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex;
+ var ACTIVE_CLASS,
+ FILTER_INPUT,
+ NO_FILTER_INPUT,
+ INDETERMINATE_CLASS,
+ LOADING_CLASS,
+ PAGE_TWO_CLASS,
+ NON_SELECTABLE_CLASSES,
+ SELECTABLE_CLASSES,
+ CURSOR_SELECT_SCROLL_PADDING,
+ currentIndex;
- LOADING_CLASS = "is-loading";
+ LOADING_CLASS = 'is-loading';
- PAGE_TWO_CLASS = "is-page-two";
+ PAGE_TWO_CLASS = 'is-page-two';
- ACTIVE_CLASS = "is-active";
+ ACTIVE_CLASS = 'is-active';
- INDETERMINATE_CLASS = "is-indeterminate";
+ INDETERMINATE_CLASS = 'is-indeterminate';
currentIndex = -1;
NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
- SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)";
+ SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)';
CURSOR_SELECT_SCROLL_PADDING = 5;
@@ -263,15 +302,15 @@ GitLabDropdown = (function() {
this.opened = this.opened.bind(this);
this.shouldPropagate = this.shouldPropagate.bind(this);
self = this;
- selector = $(this.el).data("target");
+ selector = $(this.el).data('target');
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
// Set Defaults
this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
this.highlight = !!this.options.highlight;
- this.filterInputBlur = this.options.filterInputBlur != null
- ? this.options.filterInputBlur
- : true;
+ this.icon = !!this.options.icon;
+ this.filterInputBlur =
+ this.options.filterInputBlur != null ? this.options.filterInputBlur : true;
// If no input is passed create a default one
self = this;
// If selector was passed
@@ -296,11 +335,17 @@ GitLabDropdown = (function() {
_this.fullData = data;
_this.parseData(_this.fullData);
_this.focusTextInput();
- if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') {
+ if (
+ _this.options.filterable &&
+ _this.filter &&
+ _this.filter.input &&
+ _this.filter.input.val() &&
+ _this.filter.input.val().trim() !== ''
+ ) {
return _this.filter.input.trigger('input');
}
};
- // Remote data
+ // Remote data
})(this),
instance: this,
});
@@ -325,7 +370,7 @@ GitLabDropdown = (function() {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = ".dropdown-page-one " + selector;
+ selector = '.dropdown-page-one ' + selector;
}
return $(selector, this.instance.dropdown);
};
@@ -341,80 +386,97 @@ GitLabDropdown = (function() {
if (_this.filterInput.val() !== '') {
selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = ".dropdown-page-one " + selector;
+ selector = '.dropdown-page-one ' + selector;
}
if ($(_this.el).is('input')) {
currentIndex = -1;
} else {
- $(selector, _this.dropdown).first().find('a').addClass('is-focused');
+ $(selector, _this.dropdown)
+ .first()
+ .find('a')
+ .addClass('is-focused');
currentIndex = 0;
}
}
};
- })(this)
+ })(this),
});
}
// Event listeners
- this.dropdown.on("shown.bs.dropdown", this.opened);
- this.dropdown.on("hidden.bs.dropdown", this.hidden);
- $(this.el).on("update.label", this.updateLabel);
- this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate);
- this.dropdown.on('keyup', (function(_this) {
- return function(e) {
- // Escape key
- if (e.which === 27) {
- return $('.dropdown-menu-close', _this.dropdown).trigger('click');
- }
- };
- })(this));
- this.dropdown.on('blur', 'a', (function(_this) {
- return function(e) {
- var $dropdownMenu, $relatedTarget;
- if (e.relatedTarget != null) {
- $relatedTarget = $(e.relatedTarget);
- $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
- if ($dropdownMenu.length === 0) {
- return _this.dropdown.removeClass('show');
+ this.dropdown.on('shown.bs.dropdown', this.opened);
+ this.dropdown.on('hidden.bs.dropdown', this.hidden);
+ $(this.el).on('update.label', this.updateLabel);
+ this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
+ this.dropdown.on(
+ 'keyup',
+ (function(_this) {
+ return function(e) {
+ // Escape key
+ if (e.which === 27) {
+ return $('.dropdown-menu-close', _this.dropdown).trigger('click');
}
- }
- };
- })(this));
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) {
+ };
+ })(this),
+ );
+ this.dropdown.on(
+ 'blur',
+ 'a',
+ (function(_this) {
return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.togglePage();
+ var $dropdownMenu, $relatedTarget;
+ if (e.relatedTarget != null) {
+ $relatedTarget = $(e.relatedTarget);
+ $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
+ if ($dropdownMenu.length === 0) {
+ return _this.dropdown.removeClass('show');
+ }
+ }
};
- })(this));
+ })(this),
+ );
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on(
+ 'click',
+ (function(_this) {
+ return function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return _this.togglePage();
+ };
+ })(this),
+ );
}
if (this.options.selectable) {
- selector = ".dropdown-content a";
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one .dropdown-content a";
+ selector = '.dropdown-content a';
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one .dropdown-content a';
}
- this.dropdown.on("click", selector, function(e) {
- var $el, selected, selectedObj, isMarking;
- $el = $(e.currentTarget);
- selected = self.rowClicked($el);
- selectedObj = selected ? selected[0] : null;
- isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
- }
+ this.dropdown.on(
+ 'click',
+ selector,
+ function(e) {
+ var $el, selected, selectedObj, isMarking;
+ $el = $(e.currentTarget);
+ selected = self.rowClicked($el);
+ selectedObj = selected ? selected[0] : null;
+ isMarking = selected ? selected[1] : null;
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
+ }
- // Update label right after all modifications in dropdown has been done
- if (this.options.toggleLabel) {
- this.updateLabel(selectedObj, $el, this);
- }
+ // Update label right after all modifications in dropdown has been done
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
+ }
- $el.trigger('blur');
- }.bind(this));
+ $el.trigger('blur');
+ }.bind(this),
+ );
}
}
@@ -452,10 +514,15 @@ GitLabDropdown = (function() {
html = [];
for (name in data) {
groupData = data[name];
- html.push(this.renderItem({
- header: name
- // Add header for each group
- }, name));
+ html.push(
+ this.renderItem(
+ {
+ header: name,
+ // Add header for each group
+ },
+ name,
+ ),
+ );
this.renderData(groupData, name).map(function(item) {
return html.push(item);
});
@@ -474,20 +541,25 @@ GitLabDropdown = (function() {
if (group == null) {
group = false;
}
- return data.map((function(_this) {
- return function(obj, index) {
- return _this.renderItem(obj, group, index);
- };
- })(this));
+ return data.map(
+ (function(_this) {
+ return function(obj, index) {
+ return _this.renderItem(obj, group, index);
+ };
+ })(this),
+ );
};
GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target;
if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target);
- if ($target && !$target.hasClass('dropdown-menu-close') &&
- !$target.hasClass('dropdown-menu-close-icon') &&
- !$target.data('isLink')) {
+ if (
+ $target &&
+ !$target.hasClass('dropdown-menu-close') &&
+ !$target.hasClass('dropdown-menu-close-icon') &&
+ !$target.data('isLink')
+ ) {
e.stopPropagation();
return false;
} else {
@@ -497,9 +569,11 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.filteredFullData = function() {
- return this.fullData.filter(r => typeof r === 'object'
- && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
- && !Object.prototype.hasOwnProperty.call(r, 'header')
+ return this.fullData.filter(
+ r =>
+ typeof r === 'object' &&
+ !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') &&
+ !Object.prototype.hasOwnProperty.call(r, 'header'),
);
};
@@ -522,11 +596,16 @@ GitLabDropdown = (function() {
// matches the correct layout
const inputValue = this.filterInput.val();
if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
- this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ this.options.processData.call(
+ this.options,
+ inputValue,
+ this.filteredFullData(),
+ this.parseData.bind(this),
+ );
}
contentHtml = $('.dropdown-content', this.dropdown).html();
- if (this.remote && contentHtml === "") {
+ if (this.remote && contentHtml === '') {
this.remote.execute();
} else {
this.focusTextInput();
@@ -555,11 +634,11 @@ GitLabDropdown = (function() {
var $input;
this.resetRows();
this.removeArrayKeyEvent();
- $input = this.dropdown.find(".dropdown-input-field");
+ $input = this.dropdown.find('.dropdown-input-field');
if (this.options.filterable) {
$input.blur();
}
- if (this.dropdown.find(".dropdown-toggle-page").length) {
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
$('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS);
}
if (this.options.hidden) {
@@ -601,7 +680,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.clearMenu = function() {
var selector;
selector = '.dropdown-content';
- if (this.dropdown.find(".dropdown-toggle-page").length) {
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
if (this.options.containerSelector) {
selector = this.options.containerSelector;
} else {
@@ -619,7 +698,7 @@ GitLabDropdown = (function() {
value = this.options.id ? this.options.id(data) : data.id;
if (value) {
- value = value.toString().replace(/'/g, '\\\'');
+ value = value.toString().replace(/'/g, "\\'");
}
}
@@ -676,21 +755,27 @@ GitLabDropdown = (function() {
text = data.text != null ? data.text : '';
}
if (this.highlight) {
- text = this.highlightTextMatches(text, this.filterInput.val());
+ text = data.template
+ ? this.highlightTemplate(text, data.template)
+ : this.highlightTextMatches(text, this.filterInput.val());
}
// Create the list item & the link
var link = document.createElement('a');
link.href = url;
- if (this.highlight) {
+ if (this.icon) {
+ text = `<span>${text}</span>`;
+ link.classList.add('d-flex', 'align-items-center');
+ link.innerHTML = data.icon ? data.icon + text : text;
+ } else if (this.highlight) {
link.innerHTML = text;
} else {
link.textContent = text;
}
if (selected) {
- link.className = 'is-active';
+ link.classList.add('is-active');
}
if (group) {
@@ -703,17 +788,24 @@ GitLabDropdown = (function() {
return html;
};
+ GitLabDropdown.prototype.highlightTemplate = function(text, template) {
+ return `"<b>${_.escape(text)}</b>" ${template}`;
+ };
+
GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
const occurrences = fuzzaldrinPlus.match(text, term);
const { indexOf } = [];
- return text.split('').map(function(character, i) {
- if (indexOf.call(occurrences, i) !== -1) {
- return "<b>" + character + "</b>";
- } else {
- return character;
- }
- }).join('');
+ return text
+ .split('')
+ .map(function(character, i) {
+ if (indexOf.call(occurrences, i) !== -1) {
+ return '<b>' + character + '</b>';
+ } else {
+ return character;
+ }
+ })
+ .join('');
};
GitLabDropdown.prototype.noResults = function() {
@@ -748,13 +840,15 @@ GitLabDropdown = (function() {
}
field = [];
- value = this.options.id
- ? this.options.id(selectedObject, el)
- : selectedObject.id;
+ value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
if (isInput) {
field = $(this.el);
} else if (value != null) {
- field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']");
+ field = this.dropdown
+ .parent()
+ .find(
+ "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']",
+ );
}
if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
@@ -780,9 +874,12 @@ GitLabDropdown = (function() {
} else {
isMarking = true;
if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
- this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
+ this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
if (!isInput) {
- this.dropdown.parent().find("input[name='" + fieldName + "']").remove();
+ this.dropdown
+ .parent()
+ .find("input[name='" + fieldName + "']")
+ .remove();
}
}
if (field && field.length && value == null) {
@@ -823,13 +920,16 @@ GitLabDropdown = (function() {
$('input[name="' + fieldName + '"]').remove();
}
- $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value);
+ $input = $('<input>')
+ .attr('type', 'hidden')
+ .attr('name', fieldName)
+ .val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
if (this.options.multiSelect) {
- Object.keys(selectedObject).forEach((attribute) => {
+ Object.keys(selectedObject).forEach(attribute => {
$input.attr(`data-${attribute}`, selectedObject[attribute]);
});
}
@@ -844,13 +944,13 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
var $el, selector;
// If we pass an option index
- if (typeof index !== "undefined") {
- selector = SELECTABLE_CLASSES + ":eq(" + index + ") a";
+ if (typeof index !== 'undefined') {
+ selector = SELECTABLE_CLASSES + ':eq(' + index + ') a';
} else {
- selector = ".dropdown-content .is-focused";
+ selector = '.dropdown-content .is-focused';
}
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one " + selector;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one ' + selector;
}
// simulate a click on the first link
$el = $(selector, this.dropdown);
@@ -867,44 +967,47 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.addArrowKeyEvent = function() {
var $input, ARROW_KEY_CODES, selector;
ARROW_KEY_CODES = [38, 40];
- $input = this.dropdown.find(".dropdown-input-field");
+ $input = this.dropdown.find('.dropdown-input-field');
selector = SELECTABLE_CLASSES;
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one " + selector;
- }
- return $('body').on('keydown', (function(_this) {
- return function(e) {
- var $listItems, PREV_INDEX, currentKeyCode;
- currentKeyCode = e.which;
- if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
- e.preventDefault();
- e.stopImmediatePropagation();
- PREV_INDEX = currentIndex;
- $listItems = $(selector, _this.dropdown);
- // if @options.filterable
- // $input.blur()
- if (currentKeyCode === 40) {
- // Move down
- if (currentIndex < ($listItems.length - 1)) {
- currentIndex += 1;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one ' + selector;
+ }
+ return $('body').on(
+ 'keydown',
+ (function(_this) {
+ return function(e) {
+ var $listItems, PREV_INDEX, currentKeyCode;
+ currentKeyCode = e.which;
+ if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ PREV_INDEX = currentIndex;
+ $listItems = $(selector, _this.dropdown);
+ // if @options.filterable
+ // $input.blur()
+ if (currentKeyCode === 40) {
+ // Move down
+ if (currentIndex < $listItems.length - 1) {
+ currentIndex += 1;
+ }
+ } else if (currentKeyCode === 38) {
+ // Move up
+ if (currentIndex > 0) {
+ currentIndex -= 1;
+ }
}
- } else if (currentKeyCode === 38) {
- // Move up
- if (currentIndex > 0) {
- currentIndex -= 1;
+ if (currentIndex !== PREV_INDEX) {
+ _this.highlightRowAtIndex($listItems, currentIndex);
}
+ return false;
}
- if (currentIndex !== PREV_INDEX) {
- _this.highlightRowAtIndex($listItems, currentIndex);
+ if (currentKeyCode === 13 && currentIndex !== -1) {
+ e.preventDefault();
+ _this.selectRowAtIndex();
}
- return false;
- }
- if (currentKeyCode === 13 && currentIndex !== -1) {
- e.preventDefault();
- _this.selectRowAtIndex();
- }
- };
- })(this));
+ };
+ })(this),
+ );
};
GitLabDropdown.prototype.removeArrayKeyEvent = function() {
@@ -917,12 +1020,25 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
- var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop;
+ var $dropdownContent,
+ $listItem,
+ dropdownContentBottom,
+ dropdownContentHeight,
+ dropdownContentTop,
+ dropdownScrollTop,
+ listItemBottom,
+ listItemHeight,
+ listItemTop;
+
+ if (!$listItems) {
+ $listItems = $(SELECTABLE_CLASSES, this.dropdown);
+ }
+
// Remove the class for the previously focused row
$('.is-focused', this.dropdown).removeClass('is-focused');
// Update the class for the row at the specific index
$listItem = $listItems.eq(index);
- $listItem.find('a:first-child').addClass("is-focused");
+ $listItem.find('a:first-child').addClass('is-focused');
// Dropdown content scroll area
$dropdownContent = $listItem.closest('.dropdown-content');
dropdownScrollTop = $dropdownContent.scrollTop();
@@ -936,15 +1052,19 @@ GitLabDropdown = (function() {
if (!index) {
// Scroll the dropdown content to the top
$dropdownContent.scrollTop(0);
- } else if (index === ($listItems.length - 1)) {
+ } else if (index === $listItems.length - 1) {
// Scroll the dropdown content to the bottom
$dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
- } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) {
+ } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
// Scroll the dropdown content down
- $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING);
- } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) {
+ $dropdownContent.scrollTop(
+ listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING,
+ );
+ } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
// Scroll the dropdown content up
- return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING);
+ return $dropdownContent.scrollTop(
+ listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING,
+ );
}
};
@@ -965,7 +1085,9 @@ GitLabDropdown = (function() {
toggleText = this.options.updateLabel;
}
- return $(this.el).find(".dropdown-toggle-text").text(toggleText);
+ return $(this.el)
+ .find('.dropdown-toggle-text')
+ .text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index f9ff0722c01..0035d809062 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -36,6 +36,8 @@ class ImporterStatus {
const $targetField = $tr.find('.import-target');
const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
const id = $tr.attr('id').replace('repo_', '');
+ const repoData = $tr.data();
+
let targetNamespace;
let newName;
if ($namespaceInput.length > 0) {
@@ -45,12 +47,20 @@ class ImporterStatus {
}
$btn.disable().addClass('is-loading');
- return axios.post(this.importUrl, {
+ this.id = id;
+
+ let attributes = {
repo_id: id,
target_namespace: targetNamespace,
new_name: newName,
ci_cd_only: this.ciCdOnly,
- })
+ };
+
+ if (repoData) {
+ attributes = Object.assign(repoData, attributes);
+ }
+
+ return axios.post(this.importUrl, attributes)
.then(({ data }) => {
const job = $(`tr#repo_${id}`);
job.attr('id', `project_${data.id}`);
@@ -70,6 +80,9 @@ class ImporterStatus {
.catch((error) => {
let details = error;
+ const $statusField = $(`#repo_${this.id} .job-status`);
+ $statusField.text(__('Failed'));
+
if (error.response && error.response.data && error.response.data.errors) {
details = error.response.data.errors;
}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 9482d131344..9bba341e3a3 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
-export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+export const placeholderImage =
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300;
export default class LazyLoader {
@@ -48,7 +49,7 @@ export default class LazyLoader {
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
// Loading Images which are in the current viewport or close to them
- this.lazyImages = this.lazyImages.filter((selectedImage) => {
+ this.lazyImages = this.lazyImages.filter(selectedImage => {
if (selectedImage.getAttribute('data-src')) {
const imgBoundRect = selectedImage.getBoundingClientRect();
const imgTop = scrollTop + imgBoundRect.top;
@@ -66,7 +67,18 @@ export default class LazyLoader {
}
static loadImage(img) {
if (img.getAttribute('data-src')) {
- img.setAttribute('src', img.getAttribute('data-src'));
+ let imgUrl = img.getAttribute('data-src');
+ // Only adding width + height for avatars for now
+ if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
+ let targetWidth = null;
+ if (img.getAttribute('width')) {
+ targetWidth = img.getAttribute('width');
+ } else {
+ targetWidth = img.width;
+ }
+ if (targetWidth) imgUrl += `?width=${targetWidth}`;
+ }
+ img.setAttribute('src', imgUrl);
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 17a6d5bcd2a..6afaefc56f8 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -147,6 +147,7 @@ export default {
}
this.showEmptyState = false;
})
+ .then(this.resize)
.catch(() => {
this.state = 'unableToConnect';
});
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 6385b75e557..ad6e7cf501d 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -5,19 +5,20 @@ import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
-import { scrollToElement } from '../../lib/utils/common_utils';
+import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
+ mixins: [discussionNavigation],
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
- 'unresolvedDiscussions',
+ 'firstUnresolvedDiscussionId',
'resolvedDiscussionCount',
]),
isLoggedIn() {
@@ -35,11 +36,6 @@ export default {
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
- firstUnresolvedDiscussionId() {
- const item = this.unresolvedDiscussions[0] || {};
-
- return item.id;
- },
},
created() {
this.resolveSvg = resolveSvg;
@@ -50,22 +46,10 @@ export default {
methods: {
...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() {
- const discussionId = this.firstUnresolvedDiscussionId;
- if (!discussionId) {
- return;
- }
-
- const el = document.querySelector(`[data-discussion-id="${discussionId}"]`);
- const activeTab = window.mrTabs.currentAction;
-
- if (activeTab === 'commits' || activeTab === 'pipelines') {
- window.mrTabs.activateTab('show');
- }
+ const diffTab = window.mrTabs.currentAction === 'diffs';
+ const discussionId = this.firstUnresolvedDiscussionId(diffTab);
- if (el) {
- this.expandDiscussion({ discussionId });
- scrollToElement(el);
- }
+ this.jumpToDiscussion(discussionId);
},
},
};
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 26482a02e00..abcd4422d7c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -7,7 +7,7 @@ import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default {
- name: 'IssueNoteForm',
+ name: 'NoteForm',
components: {
issueWarning,
markdownField,
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index bee635398b3..0fe1c16854a 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,11 +1,11 @@
<script>
-import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg';
-import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import systemNote from '~/vue_shared/components/notes/system_note.vue';
+import { s__ } from '~/locale';
import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -20,6 +20,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder
import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
+import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
@@ -39,7 +40,7 @@ export default {
directives: {
tooltip,
},
- mixins: [autosave, noteable, resolvable],
+ mixins: [autosave, noteable, resolvable, discussionNavigation],
props: {
discussion: {
type: Object,
@@ -60,6 +61,11 @@ export default {
required: false,
default: false,
},
+ discussionsByDiffOrder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -74,7 +80,12 @@ export default {
'discussionCount',
'resolvedDiscussionCount',
'allDiscussions',
+ 'unresolvedDiscussionsIdsByDiff',
+ 'unresolvedDiscussionsIdsByDate',
'unresolvedDiscussions',
+ 'unresolvedDiscussionsIdsOrdered',
+ 'nextUnresolvedDiscussionId',
+ 'isLastUnresolvedDiscussion',
]),
transformedDiscussion() {
return {
@@ -125,6 +136,10 @@ export default {
hasMultipleUnresolvedDiscussions() {
return this.unresolvedDiscussions.length > 1;
},
+ showJumpToNextDiscussion() {
+ return this.hasMultipleUnresolvedDiscussions &&
+ !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder);
+ },
shouldRenderDiffs() {
const { diffDiscussion, diffFile } = this.transformedDiscussion;
@@ -144,19 +159,17 @@ export default {
return this.isDiffDiscussion ? '' : 'card discussion-wrapper';
},
},
- mounted() {
- if (this.isReplying) {
- this.initAutoSave(this.transformedDiscussion);
- }
- },
- updated() {
- if (this.isReplying) {
- if (!this.autosave) {
- this.initAutoSave(this.transformedDiscussion);
+ watch: {
+ isReplying() {
+ if (this.isReplying) {
+ this.$nextTick(() => {
+ // Pass an extra key to separate reply and note edit forms
+ this.initAutoSave(this.transformedDiscussion, ['Reply']);
+ });
} else {
- this.setAutoSave();
+ this.disposeAutoSave();
}
- }
+ },
},
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
@@ -194,16 +207,18 @@ export default {
showReplyForm() {
this.isReplying = true;
},
- cancelReplyForm(shouldConfirm) {
- if (shouldConfirm && this.$refs.noteForm.isDirty) {
+ cancelReplyForm(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
+
// eslint-disable-next-line no-alert
- if (!window.confirm('Are you sure you want to cancel creating this comment?')) {
+ if (!window.confirm(msg)) {
return;
}
}
- this.resetAutoSave();
this.isReplying = false;
+ this.resetAutoSave();
},
saveReply(noteText, form, callback) {
const postData = {
@@ -241,21 +256,10 @@ Please check your network connection and try again.`;
});
},
jumpToNextDiscussion() {
- const discussionIds = this.allDiscussions.map(d => d.id);
- const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
- const currentIndex = discussionIds.indexOf(this.discussion.id);
- const remainingAfterCurrent = discussionIds.slice(currentIndex + 1);
- const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1);
-
- if (nextIndex > -1) {
- const nextId = remainingAfterCurrent[nextIndex];
- const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
+ const nextId =
+ this.nextUnresolvedDiscussionId(this.discussion.id, this.discussionsByDiffOrder);
- if (el) {
- this.expandDiscussion({ discussionId: nextId });
- scrollToElement(el);
- }
- }
+ this.jumpToDiscussion(nextId);
},
},
};
@@ -397,7 +401,7 @@ Please check your network connection and try again.`;
</a>
</div>
<div
- v-if="hasMultipleUnresolvedDiscussions"
+ v-if="showJumpToNextDiscussion"
class="btn-group"
role="group">
<button
@@ -420,7 +424,8 @@ Please check your network connection and try again.`;
:is-editing="false"
save-button-title="Comment"
@handleFormUpdate="saveReply"
- @cancelForm="cancelReplyForm" />
+ @cancelForm="cancelReplyForm"
+ />
<note-signed-out-widget v-if="!canReply" />
</div>
</div>
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index 36cc8d5d056..4f45f912479 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -4,12 +4,18 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
- initAutoSave(noteable) {
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
+ initAutoSave(noteable, extraKeys = []) {
+ let keys = [
'Note',
- capitalizeFirstCharacter(noteable.noteable_type),
+ capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
noteable.id,
- ]);
+ ];
+
+ if (extraKeys) {
+ keys = keys.concat(extraKeys);
+ }
+
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
},
resetAutoSave() {
this.autosave.reset();
@@ -17,5 +23,8 @@ export default {
setAutoSave() {
this.autosave.save();
},
+ disposeAutoSave() {
+ this.autosave.dispose();
+ },
},
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
new file mode 100644
index 00000000000..f7c4deee1f8
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -0,0 +1,29 @@
+import { scrollToElement } from '~/lib/utils/common_utils';
+
+export default {
+ methods: {
+ jumpToDiscussion(id) {
+ if (id) {
+ const activeTab = window.mrTabs.currentAction;
+ const selector =
+ activeTab === 'diffs'
+ ? `ul.notes[data-discussion-id="${id}"]`
+ : `div.discussion[data-discussion-id="${id}"]`;
+ const el = document.querySelector(selector);
+
+ if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.activateTab('show');
+ }
+
+ if (el) {
+ this.expandDiscussion({ discussionId: id });
+
+ scrollToElement(el);
+ return true;
+ }
+ }
+
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 5c65e1c3bb5..5b3b9f8776f 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -82,6 +82,9 @@ export const allDiscussions = (state, getters) => {
return Object.values(resolved).concat(unresolved);
};
+export const allResolvableDiscussions = (state, getters) =>
+ getters.allDiscussions.filter(d => !d.individual_note && d.resolvable);
+
export const resolvedDiscussionsById = state => {
const map = {};
@@ -98,6 +101,51 @@ export const resolvedDiscussionsById = state => {
return map;
};
+// Gets Discussions IDs ordered by the date of their initial note
+export const unresolvedDiscussionsIdsByDate = (state, getters) =>
+ getters.allResolvableDiscussions
+ .filter(d => !d.resolved)
+ .sort((a, b) => {
+ const aDate = new Date(a.notes[0].created_at);
+ const bDate = new Date(b.notes[0].created_at);
+
+ if (aDate < bDate) {
+ return -1;
+ }
+
+ return aDate === bDate ? 0 : 1;
+ })
+ .map(d => d.id);
+
+// Gets Discussions IDs ordered by their position in the diff
+//
+// Sorts the array of resolvable yet unresolved discussions by
+// comparing file names first. If file names are the same, compares
+// line numbers.
+export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
+ getters.allResolvableDiscussions
+ .filter(d => !d.resolved)
+ .sort((a, b) => {
+ if (!a.diff_file || !b.diff_file) {
+ return 0;
+ }
+
+ // Get file names comparison result
+ const filenameComparison = a.diff_file.file_path.localeCompare(b.diff_file.file_path);
+
+ // Get the line numbers, to compare within the same file
+ const aLines = [a.position.formatter.new_line, a.position.formatter.old_line];
+ const bLines = [b.position.formatter.new_line, b.position.formatter.old_line];
+
+ return filenameComparison < 0 ||
+ (filenameComparison === 0 &&
+ // .max() because one of them might be zero (if removed/added)
+ Math.max(aLines[0], aLines[1]) < Math.max(bLines[0], bLines[1]))
+ ? -1
+ : 1;
+ })
+ .map(d => d.id);
+
export const resolvedDiscussionCount = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
@@ -114,5 +162,42 @@ export const discussionTabCounter = state => {
return all.length;
};
+// Returns the list of discussion IDs ordered according to given parameter
+// @param {Boolean} diffOrder - is ordered by diff?
+export const unresolvedDiscussionsIdsOrdered = (state, getters) => diffOrder => {
+ if (diffOrder) {
+ return getters.unresolvedDiscussionsIdsByDiff;
+ }
+ return getters.unresolvedDiscussionsIdsByDate;
+};
+
+// Checks if a given discussion is the last in the current order (diff or date)
+// @param {Boolean} discussionId - id of the discussion
+// @param {Boolean} diffOrder - is ordered by diff?
+export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, diffOrder) => {
+ const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
+ const lastDiscussionId = idsOrdered[idsOrdered.length - 1];
+
+ return lastDiscussionId === discussionId;
+};
+
+// Gets the ID of the discussion following the one provided, respecting order (diff or date)
+// @param {Boolean} discussionId - id of the current discussion
+// @param {Boolean} diffOrder - is ordered by diff?
+export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
+ const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
+ const currentIndex = idsOrdered.indexOf(discussionId);
+
+ return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0];
+};
+
+// @param {Boolean} diffOrder - is ordered by diff?
+export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
+ if (diffOrder) {
+ return getters.unresolvedDiscussionsIdsByDiff[0];
+ }
+ return getters.unresolvedDiscussionsIdsByDate[0];
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index ff19b9a9c30..9aa83ce6269 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -39,6 +39,7 @@ export default class Todos {
}
initFilters() {
+ this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']);
this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
@@ -53,7 +54,16 @@ export default class Todos {
filterable: searchFields ? true : false,
search: { fields: searchFields },
data: $dropdown.data('data'),
- clicked: () => $dropdown.closest('form.filter-form').submit(),
+ clicked: () => {
+ const $formEl = $dropdown.closest('form.filter-form');
+ const mutexDropdowns = {
+ group_id: 'project_id',
+ project_id: 'group_id',
+ };
+
+ $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove();
+ $formEl.submit();
+ },
});
}
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 1faa59fb45b..8f5ac3d8082 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -23,17 +23,12 @@ document.addEventListener('DOMContentLoaded', () => {
saveEndpoint: variableListEl.dataset.saveEndpoint,
});
- // hide extra auto devops settings based on data-attributes
- const autoDevOpsSettings = document.querySelector('.js-auto-devops-settings');
+ // hide extra auto devops settings based checkbox state
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
-
- autoDevOpsSettings.addEventListener('click', event => {
+ const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
+ document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => {
const { target } = event;
- if (target.classList.contains('js-toggle-extra-settings')) {
- autoDevOpsExtraSettings.classList.toggle(
- 'hidden',
- !!(target.dataset && target.dataset.hideExtraSettings),
- );
- }
+ if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
+ autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
});
});
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 72a2c7ca101..aec09b8bc0a 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,9 +1,18 @@
-/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, quotes, class-methods-use-this, no-lonely-if, no-else-return, vars-on-top, max-len */
+/* eslint-disable no-return-assign, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top, max-len */
import $ from 'jquery';
+import { escape, throttle } from 'underscore';
+import { s__, sprintf } from '~/locale';
+import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import DropdownUtils from './filtered_search/dropdown_utils';
-import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils';
+import {
+ isInGroupsPage,
+ isInProjectPage,
+ getGroupSlug,
+ getProjectSlug,
+ spriteIcon,
+} from './lib/utils/common_utils';
/**
* Search input in top navigation bar.
@@ -52,6 +61,7 @@ function setSearchOptions() {
if ($dashboardOptionsDataEl.length) {
gl.dashboardOptions = {
+ name: s__('SearchAutocomplete|All GitLab'),
issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
mrPath: $dashboardOptionsDataEl.data('mrPath'),
};
@@ -69,8 +79,8 @@ export default class SearchAutocomplete {
this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
this.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
+ this.dropdownMenu = this.dropdown.find('.dropdown-menu');
this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.locationBadgeEl = this.getElement('.location-badge');
this.scopeInputEl = this.getElement('#scope');
this.searchInput = this.getElement('.search-input');
this.projectInputEl = this.getElement('#search_project_id');
@@ -78,6 +88,7 @@ export default class SearchAutocomplete {
this.searchCodeInputEl = this.getElement('#search_code');
this.repositoryInputEl = this.getElement('#repository_ref');
this.clearInput = this.getElement('.js-clear-input');
+ this.scrollFadeInitialized = false;
this.saveOriginalState();
// Only when user is logged in
@@ -98,17 +109,18 @@ export default class SearchAutocomplete {
this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
+ this.setScrollFade = this.setScrollFade.bind(this);
}
getElement(selector) {
return this.wrap.find(selector);
}
saveOriginalState() {
- return this.originalState = this.serializeState();
+ return (this.originalState = this.serializeState());
}
saveTextLength() {
- return this.lastTextLength = this.searchInput.val().length;
+ return (this.lastTextLength = this.searchInput.val().length);
}
createAutocomplete() {
@@ -117,6 +129,7 @@ export default class SearchAutocomplete {
filterable: true,
filterRemote: true,
highlight: true,
+ icon: true,
enterCallback: false,
filterInput: 'input#search',
search: {
@@ -154,60 +167,87 @@ export default class SearchAutocomplete {
this.loadingSuggestions = true;
- return axios.get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term: term,
- },
- }).then((response) => {
- // Hide dropdown menu if no suggestions returns
- if (!response.data.length) {
- this.disableAutocomplete();
- return;
- }
+ return axios
+ .get(this.autocompletePath, {
+ params: {
+ project_id: this.projectId,
+ project_ref: this.projectRef,
+ term: term,
+ },
+ })
+ .then(response => {
+ // Hide dropdown menu if no suggestions returns
+ if (!response.data.length) {
+ this.disableAutocomplete();
+ return;
+ }
- const data = [];
- // List results
- let firstCategory = true;
- let lastCategory;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- if (!firstCategory) {
- data.push('separator');
- }
- if (firstCategory) {
- firstCategory = false;
+ const data = [];
+ // List results
+ let firstCategory = true;
+ let lastCategory;
+ for (let i = 0, len = response.data.length; i < len; i += 1) {
+ const suggestion = response.data[i];
+ // Add group header before list each group
+ if (lastCategory !== suggestion.category) {
+ if (!firstCategory) {
+ data.push('separator');
+ }
+ if (firstCategory) {
+ firstCategory = false;
+ }
+ data.push({
+ header: suggestion.category,
+ });
+ lastCategory = suggestion.category;
}
data.push({
- header: suggestion.category,
+ id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
+ icon: this.getAvatar(suggestion),
+ category: suggestion.category,
+ text: suggestion.label,
+ url: suggestion.url,
});
- lastCategory = suggestion.category;
}
- data.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
- // Add option to proceed with the search
- if (data.length) {
- data.push('separator');
- data.push({
- text: `Result name contains "${term}"`,
- url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
- });
- }
+ // Add option to proceed with the search
+ if (data.length) {
+ const icon = spriteIcon('search', 's16 inline-search-icon');
+ let template;
- callback(data);
+ if (this.projectInputEl.val()) {
+ template = s__('SearchAutocomplete|in this project');
+ }
+ if (this.groupInputEl.val()) {
+ template = s__('SearchAutocomplete|in this group');
+ }
- this.loadingSuggestions = false;
- }).catch(() => {
- this.loadingSuggestions = false;
- });
+ data.unshift('separator');
+ data.unshift({
+ icon,
+ text: term,
+ template: s__('SearchAutocomplete|in all GitLab'),
+ url: `/search?search=${term}`,
+ });
+
+ if (template) {
+ data.unshift({
+ icon,
+ text: term,
+ template,
+ url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
+ });
+ }
+ }
+
+ callback(data);
+
+ this.loadingSuggestions = false;
+ this.highlightFirstRow();
+ this.setScrollFade();
+ })
+ .catch(() => {
+ this.loadingSuggestions = false;
+ });
}
getCategoryContents() {
@@ -236,21 +276,21 @@ export default class SearchAutocomplete {
const issueItems = [
{
- text: 'Issues assigned to me',
+ text: s__('SearchAutocomplete|Issues assigned to me'),
url: `${issuesPath}/?assignee_id=${userId}`,
},
{
- text: "Issues I've created",
+ text: s__("SearchAutocomplete|Issues I've created"),
url: `${issuesPath}/?author_id=${userId}`,
},
];
const mergeRequestItems = [
{
- text: 'Merge requests assigned to me',
+ text: s__('SearchAutocomplete|Merge requests assigned to me'),
url: `${mrPath}/?assignee_id=${userId}`,
},
{
- text: "Merge requests I've created",
+ text: s__("SearchAutocomplete|Merge requests I've created"),
url: `${mrPath}/?author_id=${userId}`,
},
];
@@ -259,7 +299,7 @@ export default class SearchAutocomplete {
if (issuesDisabled) {
items = baseItems.concat(mergeRequestItems);
} else {
- items = baseItems.concat(...issueItems, 'separator', ...mergeRequestItems);
+ items = baseItems.concat(...issueItems, ...mergeRequestItems);
}
return items;
}
@@ -272,8 +312,6 @@ export default class SearchAutocomplete {
search_code: this.searchCodeInputEl.val(),
repository_ref: this.repositoryInputEl.val(),
scope: this.scopeInputEl.val(),
- // Location badge
- _location: this.locationBadgeEl.text(),
};
}
@@ -283,10 +321,12 @@ export default class SearchAutocomplete {
this.searchInput.on('focus', this.onSearchInputFocus);
this.searchInput.on('blur', this.onSearchInputBlur);
this.clearInput.on('click', this.onClearInputClick);
- this.locationBadgeEl.on('click', () => this.searchInput.focus());
+ this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
}
enableAutocomplete() {
+ this.setScrollFade();
+
// No need to enable anything if user is not logged in
if (!gon.current_user_id) {
return;
@@ -308,10 +348,6 @@ export default class SearchAutocomplete {
onSearchInputKeyUp(e) {
switch (e.keyCode) {
case KEYCODE.BACKSPACE:
- // when trying to remove the location badge
- if (this.lastTextLength === 0 && this.badgePresent()) {
- this.removeLocationBadge();
- }
// When removing the last character and no badge is present
if (this.lastTextLength === 1) {
this.disableAutocomplete();
@@ -372,37 +408,13 @@ export default class SearchAutocomplete {
}
}
- addLocationBadge(item) {
- var badgeText, category, value;
- category = item.category != null ? item.category + ": " : '';
- value = item.value != null ? item.value : '';
- badgeText = "" + category + value;
- this.locationBadgeEl.text(badgeText).show();
- return this.wrap.addClass('has-location-badge');
- }
-
- hasLocationBadge() {
- return this.wrap.is('.has-location-badge');
- }
-
restoreOriginalState() {
var i, input, inputs, len;
inputs = Object.keys(this.originalState);
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- this.getElement("#" + input).val(this.originalState[input]);
+ this.getElement('#' + input).val(this.originalState[input]);
}
- if (this.originalState._location === '') {
- return this.locationBadgeEl.hide();
- } else {
- return this.addLocationBadge({
- value: this.originalState._location,
- });
- }
- }
-
- badgePresent() {
- return this.locationBadgeEl.length;
}
resetSearchState() {
@@ -411,22 +423,11 @@ export default class SearchAutocomplete {
results = [];
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- // _location isnt a input
- if (input === '_location') {
- break;
- }
- results.push(this.getElement("#" + input).val(''));
+ results.push(this.getElement('#' + input).val(''));
}
return results;
}
- removeLocationBadge() {
- this.locationBadgeEl.hide();
- this.resetSearchState();
- this.wrap.removeClass('has-location-badge');
- return this.disableAutocomplete();
- }
-
disableAutocomplete() {
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('disabled');
@@ -444,23 +445,57 @@ export default class SearchAutocomplete {
onClick(item, $el, e) {
if (window.location.pathname.indexOf(item.url) !== -1) {
if (!e.metaKey) e.preventDefault();
- if (!this.badgePresent) {
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This project',
- });
- }
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- this.addLocationBadge({
- value: 'This group',
- });
- }
+ if (item.category === 'Projects') {
+ this.projectInputEl.val(item.id);
+ }
+ if (item.category === 'Groups') {
+ this.groupInputEl.val(item.id);
}
$el.removeClass('is-active');
this.disableAutocomplete();
return this.searchInput.val('').focus();
}
}
+
+ highlightFirstRow() {
+ this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
+ }
+
+ getAvatar(item) {
+ if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
+ return false;
+ }
+
+ const { label, id } = item;
+ const avatarUrl = item.avatar_url;
+ const avatar = avatarUrl
+ ? `<img class="search-item-avatar" src="${avatarUrl}" />`
+ : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
+ escape(label),
+ )}</div>`;
+
+ return avatar;
+ }
+
+ isScrolledUp() {
+ const el = this.dropdownContent[0];
+ const currentPosition = this.contentClientHeight + el.scrollTop;
+
+ return currentPosition < this.maxPosition;
+ }
+
+ initScrollFade() {
+ const el = this.dropdownContent[0];
+ this.scrollFadeInitialized = true;
+
+ this.contentClientHeight = el.clientHeight;
+ this.maxPosition = el.scrollHeight;
+ this.dropdownMenu.addClass('dropdown-content-faded-mask');
+ }
+
+ setScrollFade() {
+ this.initScrollFade();
+
+ this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
+ }
}
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
new file mode 100644
index 00000000000..ffaed9c7193
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -0,0 +1,98 @@
+<script>
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+
+const MARK_TEXT = __('Mark todo as done');
+const TODO_TEXT = __('Add todo');
+
+export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ issuableId: {
+ type: Number,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ isTodo: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ isActionActive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ buttonClasses() {
+ return this.collapsed ?
+ 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' :
+ 'btn btn-default btn-todo issuable-header-btn float-right';
+ },
+ buttonLabel() {
+ return this.isTodo ? MARK_TEXT : TODO_TEXT;
+ },
+ collapsedButtonIconClasses() {
+ return this.isTodo ? 'todo-undone' : '';
+ },
+ collapsedButtonIcon() {
+ return this.isTodo ? 'todo-done' : 'todo-add';
+ },
+ },
+ methods: {
+ handleButtonClick() {
+ this.$emit('toggleTodo');
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ v-tooltip
+ :class="buttonClasses"
+ :title="buttonLabel"
+ :aria-label="buttonLabel"
+ :data-issuable-id="issuableId"
+ :data-issuable-type="issuableType"
+ type="button"
+ data-container="body"
+ data-placement="left"
+ data-boundary="viewport"
+ @click="handleButtonClick"
+ >
+ <icon
+ v-show="collapsed"
+ :css-classes="collapsedButtonIconClasses"
+ :name="collapsedButtonIcon"
+ />
+ <span
+ v-show="!collapsed"
+ class="issuable-todo-inner"
+ >
+ {{ buttonLabel }}
+ </span>
+ <loading-icon
+ v-show="isActionActive"
+ :inline="true"
+ />
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index ac2e99abe77..80dc7d3557c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -12,6 +12,11 @@ export default {
type: Boolean,
required: true,
},
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
tooltipLabel() {
@@ -30,10 +35,12 @@ export default {
<button
v-tooltip
:title="tooltipLabel"
+ :class="cssClasses"
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
data-container="body"
data-placement="left"
+ data-boundary="viewport"
@click="toggle"
>
<i
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index 3a413c74410..7737b9f2697 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -1,5 +1,4 @@
<script>
-
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
@@ -67,7 +66,9 @@ export default {
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
+ return baseSrc;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index dff6bce370f..50ebc6d0dd1 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -3,7 +3,6 @@
*/
@mixin gitlab-theme(
- $location-badge-color,
$search-and-nav-links,
$active-tab-border,
$border-and-box-shadow,
@@ -119,12 +118,6 @@
}
}
- .location-badge {
- color: $location-badge-color;
- background-color: rgba($search-and-nav-links, 0.1);
- border-right: 1px solid $sidebar-text;
- }
-
.search-input::placeholder {
color: rgba($search-and-nav-links, 0.8);
}
@@ -141,10 +134,6 @@
background-color: $white-light;
}
- .location-badge {
- color: $gl-text-color;
- }
-
.search-input-wrap {
.search-icon {
fill: rgba($search-and-nav-links, 0.8);
@@ -200,7 +189,6 @@
body {
&.ui-indigo {
@include gitlab-theme(
- $indigo-100,
$indigo-200,
$indigo-500,
$indigo-700,
@@ -212,7 +200,6 @@ body {
&.ui-light-indigo {
@include gitlab-theme(
- $indigo-100,
$indigo-200,
$indigo-500,
$indigo-500,
@@ -224,7 +211,6 @@ body {
&.ui-blue {
@include gitlab-theme(
- $theme-blue-100,
$theme-blue-200,
$theme-blue-500,
$theme-blue-700,
@@ -236,7 +222,6 @@ body {
&.ui-light-blue {
@include gitlab-theme(
- $theme-light-blue-100,
$theme-light-blue-200,
$theme-light-blue-500,
$theme-light-blue-500,
@@ -248,7 +233,6 @@ body {
&.ui-green {
@include gitlab-theme(
- $theme-green-100,
$theme-green-200,
$theme-green-500,
$theme-green-700,
@@ -260,7 +244,6 @@ body {
&.ui-light-green {
@include gitlab-theme(
- $theme-green-100,
$theme-green-200,
$theme-green-500,
$theme-green-500,
@@ -272,7 +255,6 @@ body {
&.ui-red {
@include gitlab-theme(
- $theme-red-100,
$theme-red-200,
$theme-red-500,
$theme-red-700,
@@ -284,7 +266,6 @@ body {
&.ui-light-red {
@include gitlab-theme(
- $theme-light-red-100,
$theme-light-red-200,
$theme-light-red-500,
$theme-light-red-500,
@@ -296,7 +277,6 @@ body {
&.ui-dark {
@include gitlab-theme(
- $theme-gray-100,
$theme-gray-200,
$theme-gray-500,
$theme-gray-700,
@@ -308,7 +288,6 @@ body {
&.ui-light {
@include gitlab-theme(
- $theme-gray-900,
$theme-gray-700,
$theme-gray-800,
$theme-gray-700,
@@ -357,10 +336,6 @@ body {
&:hover {
background-color: $white-light;
box-shadow: inset 0 0 0 1px $blue-200;
-
- .location-badge {
- box-shadow: inset 0 0 0 1px $blue-200;
- }
}
}
@@ -373,13 +348,6 @@ body {
color: $gl-text-color;
}
}
-
- .location-badge {
- color: $theme-gray-700;
- box-shadow: inset 0 0 0 1px $border-color;
- background-color: $nav-badge-bg;
- border-right: 0;
- }
}
.nav-sidebar li.active {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 56940a7564a..4db9efff6ee 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -467,7 +467,8 @@ $award-emoji-positive-add-lines: #bb9c13;
*/
$search-input-border-color: rgba($blue-400, 0.8);
$search-input-focus-shadow-color: $dropdown-input-focus-shadow;
-$search-input-width: 220px;
+$search-input-width: 240px;
+$search-input-active-width: 320px;
$location-badge-active-bg: $blue-500;
$location-icon-color: #e7e9ed;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index d5ae2b673d9..8e78d9f65eb 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -449,6 +449,7 @@
.todo-undone {
color: $gl-link-color;
+ fill: $gl-link-color;
}
.author {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 391dfea0703..2b40404971c 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -72,6 +72,9 @@
}
.manage-labels-list {
+ padding: 0;
+ margin-bottom: 0;
+
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
margin-bottom: 5px;
@@ -81,6 +84,10 @@
border-radius: $border-radius-default;
border: 1px solid $theme-gray-100;
+ &:last-child {
+ margin-bottom: 0;
+ }
+
&.sortable-ghost {
opacity: 0.3;
}
@@ -243,7 +250,10 @@
.label-actions-list {
list-style: none;
flex-shrink: 0;
+ text-align: right;
padding: 0;
+ position: relative;
+ top: -3px;
}
.label-badge {
@@ -272,6 +282,16 @@
padding: 0;
}
+.label-description {
+ .description-text {
+ margin-bottom: 10px;
+
+ .admin-labels & {
+ margin-bottom: 0;
+ }
+ }
+}
+
.label-list-item {
.content-list &::before,
.content-list &::after {
@@ -319,6 +339,64 @@
fill: $blue-600;
}
}
+
+ &.remove-row {
+ &:hover {
+ color: $gl-text-red;
+
+ svg {
+ fill: $gl-text-red;
+ }
+ }
+ }
+ }
+}
+
+@media (max-width: map-get($grid-breakpoints, md)-1) {
+ .manage-labels-list {
+ > li:not(.empty-message):not(.is-not-draggable) {
+ flex-wrap: wrap;
+ }
+
+ .label-name {
+ order: 1;
+ flex-grow: 1;
+ width: auto;
+ max-width: 100%;
+ }
+
+ .label-actions-list {
+ order: 2;
+ flex-shrink: 1;
+ text-align: left;
+ }
+
+ .label-links {
+ white-space: normal;
+ }
+
+ .label-description {
+ order: 3;
+ width: 100%;
+
+ > .append-right-default.prepend-left-default {
+ margin-left: 0;
+ margin-right: 0;
+ }
+ }
+ }
+}
+
+@media (max-width: 910px) {
+ .priority-badge {
+ display: block;
+ width: 100%;
+ margin-left: 0;
+ margin-top: $gl-padding;
+
+ .label-badge {
+ display: inline-block;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 2d66f336076..60b280fd12e 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -1,3 +1,6 @@
+$search-dropdown-max-height: 400px;
+$search-avatar-size: 16px;
+
.search-results {
.search-result-row {
border-bottom: 1px solid $border-color;
@@ -24,8 +27,9 @@
box-shadow: 0 0 4px lighten($search-input-focus-shadow-color, 20%);
}
-input[type="checkbox"]:hover {
- box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%), 0 0 0 1px lighten($search-input-focus-shadow-color, 20%);
+input[type='checkbox']:hover {
+ box-shadow: 0 0 2px 2px lighten($search-input-focus-shadow-color, 20%),
+ 0 0 0 1px lighten($search-input-focus-shadow-color, 20%);
}
.search {
@@ -40,24 +44,15 @@ input[type="checkbox"]:hover {
height: 32px;
border: 0;
border-radius: $border-radius-default;
- transition: border-color ease-in-out $default-transition-duration, background-color ease-in-out $default-transition-duration;
+ transition: border-color ease-in-out $default-transition-duration,
+ background-color ease-in-out $default-transition-duration,
+ width ease-in-out $default-transition-duration;
&:hover {
box-shadow: none;
}
}
- .location-badge {
- white-space: nowrap;
- height: 32px;
- font-size: 12px;
- margin: -4px 4px -4px -4px;
- line-height: 25px;
- padding: 4px 8px;
- border-radius: $border-radius-default 0 0 $border-radius-default;
- transition: border-color ease-in-out $default-transition-duration;
- }
-
.search-input {
border: 0;
font-size: 14px;
@@ -104,17 +99,28 @@ input[type="checkbox"]:hover {
}
.dropdown-header {
- text-transform: uppercase;
- font-size: 11px;
+ // Necessary because glDropdown doesn't support a second style of headers
+ font-weight: $gl-font-weight-bold;
+ // .dropdown-menu li has 1px side padding
+ padding: $gl-padding-8 17px;
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ line-height: 16px;
}
// Custom dropdown positioning
.dropdown-menu {
left: -5px;
+ max-height: $search-dropdown-max-height;
+ overflow: auto;
+
+ @include media-breakpoint-up(xl) {
+ width: $search-input-active-width;
+ }
}
.dropdown-content {
- max-height: none;
+ max-height: $search-dropdown-max-height - 18px;
}
}
@@ -124,6 +130,10 @@ input[type="checkbox"]:hover {
border-color: $dropdown-input-focus-border;
box-shadow: none;
+ @include media-breakpoint-up(xl) {
+ width: $search-input-active-width;
+ }
+
.search-input-wrap {
.search-icon,
.clear-icon {
@@ -141,12 +151,6 @@ input[type="checkbox"]:hover {
color: $gl-text-color-tertiary;
}
}
-
- .location-badge {
- transition: all $default-transition-duration;
- background-color: $nav-badge-bg;
- border-color: $border-color;
- }
}
&.has-value {
@@ -160,10 +164,24 @@ input[type="checkbox"]:hover {
}
}
- &.has-location-badge {
- .search-input-wrap {
- width: 68%;
- }
+ .inline-search-icon {
+ position: relative;
+ margin-right: 4px;
+ color: $gl-text-color-secondary;
+ }
+
+ .identicon,
+ .search-item-avatar {
+ flex-basis: $search-avatar-size;
+ flex-shrink: 0;
+ margin-right: 4px;
+ }
+
+ .search-item-avatar {
+ width: $search-avatar-size;
+ height: $search-avatar-size;
+ border-radius: 50%;
+ border: 1px solid $avatar-border;
}
}
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index 777fdb3581e..239123fc3ab 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -19,9 +19,4 @@
.auto-devops-card {
margin-bottom: $gl-vert-padding;
-
- > .card-body {
- border-radius: $card-border-radius;
- padding: $gl-padding $gl-padding-24;
- }
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index e5d7dd13915..010a2c05a1c 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -174,6 +174,18 @@
}
}
+@include media-breakpoint-down(lg) {
+ .todos-filters {
+ .filter-categories {
+ width: 75%;
+
+ .filter-item {
+ margin-bottom: 10px;
+ }
+ }
+ }
+}
+
@include media-breakpoint-down(xs) {
.todo {
.avatar {
@@ -199,6 +211,10 @@
}
.todos-filters {
+ .filter-categories {
+ width: auto;
+ }
+
.dropdown-menu-toggle {
width: 100%;
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 783831748a7..7228a2f1715 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -35,6 +35,7 @@ class ApplicationController < ActionController::Base
:gitea_import_enabled?, :github_import_configured?,
:gitlab_import_enabled?, :gitlab_import_configured?,
:bitbucket_import_enabled?, :bitbucket_import_configured?,
+ :bitbucket_server_import_enabled?,
:google_code_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
:manifest_import_enabled?
@@ -337,6 +338,10 @@ class ApplicationController < ActionController::Base
!Gitlab::CurrentSettings.import_sources.empty?
end
+ def bitbucket_server_import_enabled?
+ Gitlab::CurrentSettings.import_sources.include?('bitbucket_server')
+ end
+
def github_import_enabled?
Gitlab::CurrentSettings.import_sources.include?('github')
end
diff --git a/app/controllers/concerns/todos_actions.rb b/app/controllers/concerns/todos_actions.rb
new file mode 100644
index 00000000000..c0acdb3498d
--- /dev/null
+++ b/app/controllers/concerns/todos_actions.rb
@@ -0,0 +1,12 @@
+module TodosActions
+ extend ActiveSupport::Concern
+
+ def create
+ todo = TodoService.new.mark_todo(issuable, current_user)
+
+ render json: {
+ count: TodosFinder.new(current_user, state: :pending).execute.count,
+ delete_path: dashboard_todo_path(todo)
+ }
+ end
+end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index f9e8fe624e8..bd7111e28bc 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def todo_params
- params.permit(:action_id, :author_id, :project_id, :type, :sort, :state)
+ params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id)
end
def redirect_out_of_range(todos)
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
new file mode 100644
index 00000000000..798daeca6c9
--- /dev/null
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+class Import::BitbucketServerController < Import::BaseController
+ before_action :verify_bitbucket_server_import_enabled
+ before_action :bitbucket_auth, except: [:new, :configure]
+ before_action :validate_import_params, only: [:create]
+
+ # As a basic sanity check to prevent URL injection, restrict project
+ # repository input and repository slugs to allowed characters. For Bitbucket:
+ #
+ # Project keys must start with a letter and may only consist of ASCII letters, numbers and underscores (A-Z, a-z, 0-9, _).
+ #
+ # Repository names are limited to 128 characters. They must start with a
+ # letter or number and may contain spaces, hyphens, underscores, and periods.
+ # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054)
+ VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/
+
+ def new
+ end
+
+ def create
+ repo = bitbucket_client.repo(@project_key, @repo_slug)
+
+ unless repo
+ return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity
+ end
+
+ project_name = params[:new_name].presence || repo.name
+ namespace_path = params[:new_namespace].presence || current_user.username
+ target_namespace = find_or_create_namespace(namespace_path, current_user)
+
+ if current_user.can?(:create_projects, target_namespace)
+ project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project_save_error(project) }, status: :unprocessable_entity
+ end
+ else
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ end
+ rescue BitbucketServer::Client::ServerError => e
+ render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity
+ end
+
+ def configure
+ session[personal_access_token_key] = params[:personal_access_token]
+ session[bitbucket_server_username_key] = params[:bitbucket_username]
+ session[bitbucket_server_url_key] = params[:bitbucket_server_url]
+
+ redirect_to status_import_bitbucket_server_path
+ end
+
+ def status
+ repos = bitbucket_client.repos
+
+ @repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
+
+ @already_added_projects = find_already_added_projects('bitbucket_server')
+ already_added_projects_names = @already_added_projects.pluck(:import_source)
+
+ @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.browse_url) }
+ rescue BitbucketServer::Connection::ConnectionError, BitbucketServer::Client::ServerError => e
+ flash[:alert] = "Unable to connect to server: #{e}"
+ clear_session_data
+ redirect_to new_import_bitbucket_server_path
+ end
+
+ def jobs
+ render json: find_jobs('bitbucket_server')
+ end
+
+ private
+
+ def bitbucket_client
+ @bitbucket_client ||= BitbucketServer::Client.new(credentials)
+ end
+
+ def validate_import_params
+ @project_key = params[:project]
+ @repo_slug = params[:repository]
+
+ return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present?
+ return render_validation_error('Missing repository slug') unless @repo_slug.present?
+ return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS
+ return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS
+ end
+
+ def render_validation_error(message)
+ render json: { errors: message }, status: :unprocessable_entity
+ end
+
+ def bitbucket_auth
+ unless session[bitbucket_server_url_key].present? &&
+ session[bitbucket_server_username_key].present? &&
+ session[personal_access_token_key].present?
+ redirect_to new_import_bitbucket_server_path
+ end
+ end
+
+ def verify_bitbucket_server_import_enabled
+ render_404 unless bitbucket_server_import_enabled?
+ end
+
+ def bitbucket_server_url_key
+ :bitbucket_server_url
+ end
+
+ def bitbucket_server_username_key
+ :bitbucket_server_username
+ end
+
+ def personal_access_token_key
+ :bitbucket_server_personal_access_token
+ end
+
+ def clear_session_data
+ session[bitbucket_server_url_key] = nil
+ session[bitbucket_server_username_key] = nil
+ session[personal_access_token_key] = nil
+ end
+
+ def credentials
+ {
+ base_uri: session[bitbucket_server_url_key],
+ user: session[bitbucket_server_username_key],
+ password: session[personal_access_token_key]
+ }
+ end
+end
diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb
index a41fcb85c40..93fb9da6510 100644
--- a/app/controllers/projects/todos_controller.rb
+++ b/app/controllers/projects/todos_controller.rb
@@ -1,19 +1,13 @@
class Projects::TodosController < Projects::ApplicationController
- before_action :authenticate_user!, only: [:create]
-
- def create
- todo = TodoService.new.mark_todo(issuable, current_user)
+ include Gitlab::Utils::StrongMemoize
+ include TodosActions
- render json: {
- count: TodosFinder.new(current_user, state: :pending).execute.count,
- delete_path: dashboard_todo_path(todo)
- }
- end
+ before_action :authenticate_user!, only: [:create]
private
def issuable
- @issuable ||= begin
+ strong_memoize(:issuable) do
case params[:issuable_type]
when "issue"
IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id])
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 09e2c586f2a..6e9c8ea6fde 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -15,6 +15,7 @@
class TodosFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
+ include Gitlab::Utils::StrongMemoize
requires_cross_project_access unless: -> { project? }
@@ -34,6 +35,7 @@ class TodosFinder
items = by_author(items)
items = by_state(items)
items = by_type(items)
+ items = by_group(items)
# Filtering by project HAS TO be the last because we use
# the project IDs yielded by the todos query thus far
items = by_project(items)
@@ -82,6 +84,10 @@ class TodosFinder
params[:project_id].present?
end
+ def group?
+ params[:group_id].present?
+ end
+
def project
return @project if defined?(@project)
@@ -89,10 +95,6 @@ class TodosFinder
@project = Project.find(params[:project_id])
@project = nil if @project.pending_delete?
-
- unless Ability.allowed?(current_user, :read_project, @project)
- @project = nil
- end
else
@project = nil
end
@@ -100,18 +102,14 @@ class TodosFinder
@project
end
- def project_ids(items)
- ids = items.except(:order).select(:project_id)
- if Gitlab::Database.mysql?
- # To make UPDATE work on MySQL, wrap it in a SELECT with an alias
- ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
+ def group
+ strong_memoize(:group) do
+ Group.find(params[:group_id])
end
-
- ids
end
def type?
- type.present? && %w(Issue MergeRequest).include?(type)
+ type.present? && %w(Issue MergeRequest Epic).include?(type)
end
def type
@@ -148,12 +146,23 @@ class TodosFinder
def by_project(items)
if project?
- items.where(project: project)
- else
- projects = Project.public_or_visible_to_user(current_user)
+ items = items.where(project: project)
+ end
- items.joins(:project).merge(projects)
+ items
+ end
+
+ def by_group(items)
+ if group?
+ groups = group.self_and_descendants
+ project_todos = items.where(project_id: Project.where(group: groups).select(:id))
+ group_todos = items.where(group_id: groups.select(:id))
+
+ union = Gitlab::SQL::Union.new([project_todos, group_todos])
+ items = Todo.from("(#{union.to_sql}) #{Todo.table_name}")
end
+
+ items
end
def by_state(items)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 678fed9c414..c84ed8091c3 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -131,6 +131,19 @@ module IssuablesHelper
end
end
+ def group_dropdown_label(group_id, default_label)
+ return default_label if group_id.nil?
+ return "Any group" if group_id == "0"
+
+ group = ::Group.find_by(id: group_id)
+
+ if group
+ group.full_name
+ else
+ default_label
+ end
+ end
+
def milestone_dropdown_label(milestone_title, default_label = "Milestone")
title =
case milestone_title
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 9008db1b300..66aaf055cf2 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -9,13 +9,23 @@ module NamespacesHelper
.includes(:route)
.order('routes.path')
users = [current_user.namespace]
+ selected_id = selected
unless extra_group.nil? || extra_group.is_a?(Group)
extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group'
end
- if extra_group && extra_group.is_a?(Group) && (!Group.exists?(name: extra_group.name) || Ability.allowed?(current_user, :read_group, extra_group))
- groups |= [extra_group]
+ if extra_group && extra_group.is_a?(Group)
+ extra_group = dedup_extra_group(extra_group)
+
+ if Ability.allowed?(current_user, :read_group, extra_group)
+ # Assign the value to an invalid primary ID so that the select box works
+ extra_group.id = -1 unless extra_group.persisted?
+ selected_id = extra_group.id if selected == :extra_group
+ groups |= [extra_group]
+ else
+ selected_id = current_user.namespace.id
+ end
end
options = []
@@ -25,11 +35,11 @@ module NamespacesHelper
options << options_for_group(users, display_path: display_path, type: 'user')
if selected == :current_user && current_user.namespace
- selected = current_user.namespace.id
+ selected_id = current_user.namespace.id
end
end
- grouped_options_for_select(options, selected)
+ grouped_options_for_select(options, selected_id)
end
def namespace_icon(namespace, size = 40)
@@ -42,6 +52,17 @@ module NamespacesHelper
private
+ # Many importers create a temporary Group, so use the real
+ # group if one exists by that name to prevent duplicates.
+ def dedup_extra_group(extra_group)
+ unless extra_group.persisted?
+ existing_group = Group.find_by(name: extra_group.name)
+ extra_group = existing_group if existing_group&.persisted?
+ end
+
+ extra_group
+ end
+
def options_for_group(namespaces, display_path:, type:)
group_label = type.pluralize
elements = namespaces.sort_by(&:human_name).map! do |n|
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index cadb88ba632..98074a4c0c5 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -82,16 +82,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "Current Project", label: "Files", url: project_tree_path(@project, ref) },
- { category: "Current Project", label: "Commits", url: project_commits_path(@project, ref) },
- { category: "Current Project", label: "Network", url: project_network_path(@project, ref) },
- { category: "Current Project", label: "Graph", url: project_graph_path(@project, ref) },
- { category: "Current Project", label: "Issues", url: project_issues_path(@project) },
- { category: "Current Project", label: "Merge Requests", url: project_merge_requests_path(@project) },
- { category: "Current Project", label: "Milestones", url: project_milestones_path(@project) },
- { category: "Current Project", label: "Snippets", url: project_snippets_path(@project) },
- { category: "Current Project", label: "Members", url: project_project_members_path(@project) },
- { category: "Current Project", label: "Wiki", url: project_wikis_path(@project) }
+ { category: "In this project", label: "Files", url: project_tree_path(@project, ref) },
+ { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) },
+ { category: "In this project", label: "Network", url: project_network_path(@project, ref) },
+ { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) },
+ { category: "In this project", label: "Issues", url: project_issues_path(@project) },
+ { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) },
+ { category: "In this project", label: "Milestones", url: project_milestones_path(@project) },
+ { category: "In this project", label: "Snippets", url: project_snippets_path(@project) },
+ { category: "In this project", label: "Members", url: project_project_members_path(@project) },
+ { category: "In this project", label: "Wiki", url: project_wikis_path(@project) }
]
else
[]
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index f7620e0b6b8..7cd74358168 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -43,7 +43,7 @@ module TodosHelper
project_commit_path(todo.project,
todo.target, anchor: anchor)
else
- path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target]
+ path = [todo.parent, todo.target]
path.unshift(:pipelines) if todo.build_failed?
@@ -167,4 +167,12 @@ module TodosHelper
def show_todo_state?(todo)
(todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state)
end
+
+ def todo_group_options
+ groups = current_user.authorized_groups.map do |group|
+ { id: group.id, text: group.full_name }
+ end
+
+ groups.unshift({ id: '', text: 'Any Group' }).to_json
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 04495ab2908..bbe7811841a 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -297,7 +297,7 @@ class ApplicationSetting < ActiveRecord::Base
unique_ips_limit_per_user: 10,
unique_ips_limit_time_window: 3600,
usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
- instance_statistics_visibility_private: true,
+ instance_statistics_visibility_private: false,
user_default_external: false
}
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 61df6174c86..55bbf7cae7e 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -1,15 +1,28 @@
# frozen_string_literal: true
+require 'openssl'
+
module Clusters
module Applications
class Helm < ActiveRecord::Base
self.table_name = 'clusters_applications_helm'
+ attr_encrypted :ca_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-cbc'
+
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION
+ before_create :create_keys_and_certs
+
+ def issue_client_cert
+ ca_cert_obj.issue
+ end
+
def set_initial_status
return unless not_installable?
@@ -17,7 +30,41 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InitCommand.new(name)
+ Gitlab::Kubernetes::Helm::InitCommand.new(
+ name: name,
+ files: files
+ )
+ end
+
+ def has_ssl?
+ ca_key.present? && ca_cert.present?
+ end
+
+ private
+
+ def files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': tiller_cert.cert_string,
+ 'key.pem': tiller_cert.key_string
+ }
+ end
+
+ def create_keys_and_certs
+ ca_cert = Gitlab::Kubernetes::Helm::Certificate.generate_root
+ self.ca_key = ca_cert.key_string
+ self.ca_cert = ca_cert.cert_string
+ end
+
+ def tiller_cert
+ @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::Certificate::INFINITE_EXPIRY)
+ end
+
+ def ca_cert_obj
+ return unless has_ssl?
+
+ Gitlab::Kubernetes::Helm::Certificate
+ .from_strings(ca_key, ca_cert)
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 2440efe76ab..93f654e0638 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -37,10 +37,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values
+ files: files
)
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 33d54ba86fe..ef1c76c03bd 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -38,10 +38,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values,
+ files: files,
repository: repository
)
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index ccb415b3fe2..88399dbbb95 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -46,10 +46,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values
+ files: files
)
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 426aed91089..bde255723c8 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -31,10 +31,10 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
- name,
+ name: name,
version: VERSION,
chart: chart,
- values: values,
+ files: files,
repository: repository
)
end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 14e004b9a57..52498f123ff 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -14,8 +14,34 @@ module Clusters
File.read(chart_values_file)
end
+ def files
+ @files ||= begin
+ files = { 'values.yaml': values }
+
+ files.merge!(certificate_files) if cluster.application_helm.has_ssl?
+
+ files
+ end
+ end
+
private
+ def certificate_files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': helm_cert.cert_string,
+ 'key.pem': helm_cert.key_string
+ }
+ end
+
+ def ca_cert
+ cluster.application_helm.ca_cert
+ end
+
+ def helm_cert
+ @helm_cert ||= cluster.application_helm.issue_client_cert
+ end
+
def chart_values_file
"#{Rails.root}/vendor/#{name}/values.yaml"
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 095897b08e3..a6d604a580d 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -19,7 +19,7 @@ module Avatarable
# We use avatar_path instead of overriding avatar_url because of carrierwave.
# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
- avatar_path(only_path: args.fetch(:only_path, true)) || super
+ avatar_path(only_path: args.fetch(:only_path, true), size: args[:size]) || super
end
def retrieve_upload(identifier, paths)
@@ -40,12 +40,13 @@ module Avatarable
end
end
- def avatar_path(only_path: true)
+ def avatar_path(only_path: true, size: nil)
return unless self[:avatar].present?
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
use_authentication = respond_to?(:public?) && !public?
+ query_params = size&.nonzero? ? "?width=#{size}" : ""
# Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host.
@@ -64,7 +65,7 @@ module Avatarable
url_base << gitlab_config.relative_url_root
end
- url_base + avatar.local_url
+ url_base + avatar.local_url + query_params
end
# Path that is persisted in the tracking Upload model. Used to fetch the
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index b93c1145f82..7a459078151 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -243,6 +243,12 @@ module Issuable
opened?
end
+ def overdue?
+ return false unless respond_to?(:due_date)
+
+ due_date.try(:past?) || false
+ end
+
def user_notes_count
if notes.loaded?
# Use the in-memory association to select and count to avoid hitting the db
diff --git a/app/models/group.rb b/app/models/group.rb
index cd548fc0061..106a1f4a94c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -41,6 +41,8 @@ class Group < Namespace
has_many :boards
has_many :badges, class_name: 'GroupBadge'
+ has_many :todos
+
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 0d135f54038..94cf12f3c2b 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -278,10 +278,6 @@ class Issue < ActiveRecord::Base
user ? readable_by?(user) : publicly_visible?
end
- def overdue?
- due_date.try(:past?) || false
- end
-
def check_for_spam?
project.public? && (title_changed? || description_changed?)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index acad8b91e9f..9b3e2d4446d 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1089,23 +1089,29 @@ class MergeRequest < ActiveRecord::Base
def can_be_reverted?(current_user)
return false unless merge_commit
+ return false unless merged_at
- merged_at = metrics&.merged_at
- notes_association = notes_with_associations
+ # It is not guaranteed that Note#created_at will be strictly later than
+ # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
+ # comparison, as will a HA environment if clocks are not *precisely*
+ # synchronized. Add a minute's leeway to compensate for both possibilities
+ cutoff = merged_at - 1.minute
- if merged_at
- # It is not guaranteed that Note#created_at will be strictly later than
- # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
- # comparison, as will a HA environment if clocks are not *precisely*
- # synchronized. Add a minute's leeway to compensate for both possibilities
- cutoff = merged_at - 1.minute
-
- notes_association = notes_association.where('created_at >= ?', cutoff)
- end
+ notes_association = notes_with_associations.where('created_at >= ?', cutoff)
!merge_commit.has_been_reverted?(current_user, notes_association)
end
+ def merged_at
+ strong_memoize(:merged_at) do
+ next unless merged?
+
+ metrics&.merged_at ||
+ merge_event&.created_at ||
+ notes.system.reorder(nil).find_by(note: 'merged')&.created_at
+ end
+ end
+
def can_be_cherry_picked?
merge_commit.present?
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 969d34ae09a..2e343b8f9f8 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -231,6 +231,10 @@ class Note < ActiveRecord::Base
!for_personal_snippet?
end
+ def for_issuable?
+ for_issue? || for_merge_request?
+ end
+
def skip_project_check?
!for_project_noteable?
end
diff --git a/app/models/project.rb b/app/models/project.rb
index cb4d2610e0d..36089995ed3 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -507,6 +507,10 @@ class Project < ActiveRecord::Base
end
end
+ def has_auto_devops_implicitly_enabled?
+ auto_devops&.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled?
+ end
+
def has_auto_devops_implicitly_disabled?
auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled?
end
@@ -654,6 +658,8 @@ class Project < ActiveRecord::Base
project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials)
end
+
+ project_import_data
end
def import?
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 5f5c2f9073d..48d92ad04b3 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -24,15 +24,18 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
+ belongs_to :group
belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
- validates :action, :project, :target_type, :user, presence: true
+ validates :action, :target_type, :user, presence: true
validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
+ validates :project, presence: true, unless: :group_id
+ validates :group, presence: true, unless: :project_id
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
@@ -46,7 +49,7 @@ class Todo < ActiveRecord::Base
state :done
end
- after_save :keep_around_commit
+ after_save :keep_around_commit, if: :commit_id
class << self
# Priority sorting isn't displayed in the dropdown, because we don't show
@@ -81,6 +84,10 @@ class Todo < ActiveRecord::Base
end
end
+ def parent
+ project
+ end
+
def unmergeable?
action == UNMERGEABLE
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 436a6b18cb1..fe47aa2f140 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -14,7 +14,9 @@ module Groups
group.assign_attributes(params)
begin
- group.save
+ after_update if group.save
+
+ true
rescue Gitlab::UpdatePathError => e
group.errors.add(:base, e.message)
@@ -24,6 +26,13 @@ module Groups
private
+ def after_update
+ if group.previous_changes.include?(:visibility_level) && group.private?
+ # don't enqueue immediately to prevent todos removal in case of a mistake
+ TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id)
+ end
+ end
+
def reject_parent_id!
params.except!(:parent_id)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 0bcd53c76a9..0df61ad3bce 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -262,15 +262,15 @@ class TodoService
end
end
- def create_mention_todos(project, target, author, note = nil, skip_users = [])
+ def create_mention_todos(parent, target, author, note = nil, skip_users = [])
# Create Todos for directly addressed users
- directly_addressed_users = filter_directly_addressed_users(project, note || target, author, skip_users)
- attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note)
+ directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users)
+ attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note)
create_todos(directly_addressed_users, attributes)
# Create Todos for mentioned users
- mentioned_users = filter_mentioned_users(project, note || target, author, skip_users)
- attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note)
+ mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users)
+ attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note)
create_todos(mentioned_users, attributes)
end
@@ -301,36 +301,36 @@ class TodoService
def attributes_for_todo(project, target, author, action, note = nil)
attributes_for_target(target).merge!(
- project_id: project.id,
+ project_id: project&.id,
author_id: author.id,
action: action,
note: note
)
end
- def filter_todo_users(users, project, target)
- reject_users_without_access(users, project, target).uniq
+ def filter_todo_users(users, parent, target)
+ reject_users_without_access(users, parent, target).uniq
end
- def filter_mentioned_users(project, target, author, skip_users = [])
+ def filter_mentioned_users(parent, target, author, skip_users = [])
mentioned_users = target.mentioned_users(author) - skip_users
- filter_todo_users(mentioned_users, project, target)
+ filter_todo_users(mentioned_users, parent, target)
end
- def filter_directly_addressed_users(project, target, author, skip_users = [])
+ def filter_directly_addressed_users(parent, target, author, skip_users = [])
directly_addressed_users = target.directly_addressed_users(author) - skip_users
- filter_todo_users(directly_addressed_users, project, target)
+ filter_todo_users(directly_addressed_users, parent, target)
end
- def reject_users_without_access(users, project, target)
- if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?)
+ def reject_users_without_access(users, parent, target)
+ if target.is_a?(Note) && target.for_issuable?
target = target.noteable
end
if target.is_a?(Issuable)
select_users(users, :"read_#{target.to_ability_name}", target)
else
- select_users(users, :read_project, project)
+ select_users(users, :read_project, parent)
end
end
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index 2ff9f94b718..045f5ecaae7 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -3,55 +3,97 @@ module Todos
class EntityLeaveService < ::Todos::Destroy::BaseService
extend ::Gitlab::Utils::Override
- attr_reader :user_id, :entity
+ attr_reader :user, :entity
def initialize(user_id, entity_id, entity_type)
unless %w(Group Project).include?(entity_type)
raise ArgumentError.new("#{entity_type} is not an entity user can leave")
end
- @user_id = user_id
+ @user = User.find_by(id: user_id)
@entity = entity_type.constantize.find_by(id: entity_id)
end
- private
+ def execute
+ return unless entity && user
+
+ # if at least reporter, all entities including confidential issues can be accessed
+ return if user_has_reporter_access?
+
+ remove_confidential_issue_todos
- override :todos
- def todos
if entity.private?
- Todo.where(project_id: project_ids, user_id: user_id)
+ remove_project_todos
+ remove_group_todos
else
- project_ids.each do |project_id|
- TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user_id)
- end
+ enqueue_private_features_worker
+ end
+ end
+
+ private
- Todo.where(
- target_id: confidential_issues.select(:id), target_type: Issue, user_id: user_id
- )
+ def enqueue_private_features_worker
+ project_ids.each do |project_id|
+ TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id)
end
end
+ def remove_confidential_issue_todos
+ Todo.where(
+ target_id: confidential_issues.select(:id), target_type: Issue, user_id: user.id
+ ).delete_all
+ end
+
+ def remove_project_todos
+ Todo.where(project_id: non_authorized_projects, user_id: user.id).delete_all
+ end
+
+ def remove_group_todos
+ Todo.where(group_id: non_authorized_groups, user_id: user.id).delete_all
+ end
+
override :project_ids
def project_ids
- case entity
- when Project
- [entity.id]
- when Namespace
- Project.select(:id).where(namespace_id: entity.self_and_descendants.select(:id))
- end
+ condition = case entity
+ when Project
+ { id: entity.id }
+ when Namespace
+ { namespace_id: non_member_groups }
+ end
+
+ Project.where(condition).select(:id)
end
- override :todos_to_remove?
- def todos_to_remove?
- # if an entity is provided we want to check always at least private features
- !!entity
+ def non_authorized_projects
+ project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id))
+ end
+
+ def non_authorized_groups
+ return [] unless entity.is_a?(Namespace)
+
+ entity.self_and_descendants.select(:id)
+ .where('id NOT IN (?)', GroupsFinder.new(user).execute.select(:id))
+ end
+
+ def non_member_groups
+ entity.self_and_descendants.select(:id)
+ .where('id NOT IN (?)', user.membership_groups.select(:id))
+ end
+
+ def user_has_reporter_access?
+ return unless entity.is_a?(Namespace)
+
+ entity.member?(User.find(user.id), Gitlab::Access::REPORTER)
end
def confidential_issues
- assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user_id)
+ assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user.id)
+ authorized_reporter_projects = user
+ .authorized_projects(Gitlab::Access::REPORTER).select(:id)
Issue.where(project_id: project_ids, confidential: true)
- .where('author_id != ?', user_id)
+ .where('project_id NOT IN(?)', authorized_reporter_projects)
+ .where('author_id != ?', user.id)
.where('id NOT IN (?)', assigned_ids)
end
end
diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb
new file mode 100644
index 00000000000..d13fa7a6516
--- /dev/null
+++ b/app/services/todos/destroy/group_private_service.rb
@@ -0,0 +1,30 @@
+module Todos
+ module Destroy
+ class GroupPrivateService < ::Todos::Destroy::BaseService
+ extend ::Gitlab::Utils::Override
+
+ attr_reader :group
+
+ def initialize(group_id)
+ @group = Group.find_by(id: group_id)
+ end
+
+ private
+
+ override :todos
+ def todos
+ Todo.where(group_id: group.id)
+ end
+
+ override :authorized_users
+ def authorized_users
+ group.direct_and_indirect_users.select(:id)
+ end
+
+ override :todos_to_remove?
+ def todos_to_remove?
+ group&.private?
+ end
+ end
+ end
+end
diff --git a/app/services/todos/destroy/project_private_service.rb b/app/services/todos/destroy/project_private_service.rb
index 171933e7cbc..315a0c33398 100644
--- a/app/services/todos/destroy/project_private_service.rb
+++ b/app/services/todos/destroy/project_private_service.rb
@@ -13,7 +13,7 @@ module Todos
override :todos
def todos
- Todo.where(project_id: project_ids)
+ Todo.where(project_id: project.id)
end
override :project_ids
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 472616b1315..5037017e38a 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -3,13 +3,15 @@
%fieldset
.form-group
- .form-check
- = f.check_box :auto_devops_enabled, class: 'form-check-input'
- = f.label :auto_devops_enabled, class: 'form-check-label' do
- Enabled Auto DevOps for projects by default
- .form-text.text-muted
- It will automatically build, test, and deploy applications based on a predefined CI/CD configuration
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md')
+ .card.auto-devops-card
+ .card-body
+ .form-check
+ = f.check_box :auto_devops_enabled, class: 'form-check-input'
+ = f.label :auto_devops_enabled, class: 'form-check-label' do
+ Default to Auto DevOps pipeline for all projects
+ .form-text.text-muted
+ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
.form-group
= f.label :auto_devops_domain, class: 'label-bold'
= f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com'
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index c3ea2352898..dbb7224f5f9 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
-%li{ id: dom_id(label) }
- .label-row
- = render_colored_label(label, tooltip: false)
- = markdown_field(label, :description)
- .float-right
- = link_to _('Edit'), edit_admin_label_path(label), class: 'btn btn-sm'
- = link_to _('Delete'), admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"}
+%li.label-list-item{ id: dom_id(label) }
+ = render "shared/label_row", label: label
+ .label-actions-list
+ = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = sprite_icon('pencil')
+ = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = sprite_icon('remove')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index d3e5247447a..f1b8658f84e 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -7,10 +7,11 @@
= _('Labels')
%hr
-.labels
+.labels.labels-container.admin-labels
- if @labels.present?
- %ul.bordered-list.manage-labels-list
+ %ul.manage-labels-list
= render @labels
+
= paginate @labels, theme: 'gitlab'
- else
.card.bg-light
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index fdaacc098e0..50296a2afe7 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -20,7 +20,7 @@
= link_to(admin_namespace_project_path(project.namespace, project)) do
.dash-project-avatar
.avatar-container.s40
- = project_icon(project, alt: '', class: 'avatar project-avatar s40')
+ = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
- if project.namespace
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index d5a9cc646a6..8b3974d97f8 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -30,27 +30,33 @@
.todos-filters
.row-content-block.second-block
- = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do
- .filter-item.inline
- - if params[:project_id].present?
- = hidden_field_tag(:project_id, params[:project_id])
- = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
- placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
- .filter-item.inline
- - if params[:author_id].present?
- = hidden_field_tag(:author_id, params[:author_id])
- = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
- placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
- .filter-item.inline
- - if params[:type].present?
- = hidden_field_tag(:type, params[:type])
- = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
- data: { data: todo_types_options, default_label: 'Type' } })
- .filter-item.inline.actions-filter
- - if params[:action_id].present?
- = hidden_field_tag(:action_id, params[:action_id])
- = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
- data: { data: todo_actions_options, default_label: 'Action' } })
+ = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do
+ .filter-categories.flex-fill
+ .filter-item.inline
+ - if params[:group_id].present?
+ = hidden_field_tag(:group_id, params[:group_id])
+ = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
+ placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } })
+ .filter-item.inline
+ - if params[:project_id].present?
+ = hidden_field_tag(:project_id, params[:project_id])
+ = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
+ placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
+ .filter-item.inline
+ - if params[:author_id].present?
+ = hidden_field_tag(:author_id, params[:author_id])
+ = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
+ placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
+ .filter-item.inline
+ - if params[:type].present?
+ = hidden_field_tag(:type, params[:type])
+ = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
+ data: { data: todo_types_options, default_label: 'Type' } })
+ .filter-item.inline.actions-filter
+ - if params[:action_id].present?
+ = hidden_field_tag(:action_id, params[:action_id])
+ = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
+ data: { data: todo_actions_options, default_label: 'Action' } })
.filter-item.sort-filter
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' }
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
new file mode 100644
index 00000000000..ac86be8fa7a
--- /dev/null
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -0,0 +1,26 @@
+- title = _('Bitbucket Server Import')
+- page_title title
+- breadcrumb_title title
+- header_title "Projects", root_path
+
+%h3.page-title
+ = icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server')
+
+%p
+ = _('Enter in your Bitbucket Server URL and personal access token below')
+
+= form_tag configure_import_bitbucket_server_path, method: :post do
+ .form-group.row
+ = label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2'
+ .col-md-4
+ = text_field_tag :bitbucket_server_url, '', class: 'form-control append-right-8', placeholder: _('https://your-bitbucket-server'), size: 40
+ .form-group.row
+ = label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2'
+ .col-md-4
+ = text_field_tag :bitbucket_username, '', class: 'form-control append-right-8', placeholder: _('username'), size: 40
+ .form-group.row
+ = label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2'
+ .col-md-4
+ = password_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
+ .form-actions
+ = submit_tag _('List your Bitbucket Server repositories'), class: 'btn btn-success'
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
new file mode 100644
index 00000000000..3d05a5e696f
--- /dev/null
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -0,0 +1,87 @@
+- page_title 'Bitbucket Server import'
+- header_title 'Projects', root_path
+
+%h3.page-title
+ %i.fa.fa-bitbucket-square
+ = _('Import projects from Bitbucket Server')
+
+- if @repos.any?
+ %p.light
+ = _('Select projects you want to import.')
+ .btn-group
+ - if @incompatible_repos.any?
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all compatible projects')
+ = icon('spinner spin', class: 'loading-icon')
+ - else
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all projects')
+ = icon('spinner spin', class: 'loading-icon')
+ .btn-group
+ = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post)
+
+.table-responsive.prepend-top-10
+ %table.table.import-jobs
+ %colgroup.import-jobs-from-col
+ %colgroup.import-jobs-to-col
+ %colgroup.import-jobs-status-col
+ %thead
+ %tr
+ %th= _('From Bitbucket Server')
+ %th= _('To GitLab')
+ %th= _(' Status')
+ %tbody
+ - @already_added_projects.each do |project|
+ %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
+ %td
+ = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer'
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - if project.import_status == 'finished'
+ = icon('check', text: 'Done')
+ - elsif project.import_status == 'started'
+ = icon('spin', text: 'started')
+ - else
+ = project.human_import_status_name
+
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %fieldset.row
+ .input-group
+ .project-path.input-group-prepend
+ - if current_user.can_select_namespace?
+ - selected = params[:namespace_id] || :extra_group
+ - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {}
+ = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 }
+ - else
+ = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
+ %span.input-group-prepend
+ .input-group-text /
+ = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
+ %td.import-actions.job-status
+ = button_tag class: 'btn btn-import js-add-to-import' do
+ Import
+ = icon('spinner spin', class: 'loading-icon')
+ - @incompatible_repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %td.import-actions-job-status
+ = label_tag 'Incompatible Project', nil, class: 'label badge-danger'
+
+- if @incompatible_repos.any?
+ %p
+ One or more of your Bitbucket Server projects cannot be imported into GitLab
+ directly because they use Subversion or Mercurial for version control,
+ rather than Git. Please convert
+ = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview'
+ and go through the
+ = link_to 'import flow', status_import_bitbucket_server_path
+ again.
+
+.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } }
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 556ad8cf306..9a7a67cfa83 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -6,21 +6,19 @@
- group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) }
- if @project && @project.persisted?
- project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? }
-.search.search-form{ class: "#{'has-location-badge' if label.present?}" }
+.search.search-form
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
- - if label.present?
- .location-badge= label
.search-input-wrap
.dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: _('Search'),
+ = search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
tabindex: '1',
autocomplete: 'off',
data: { issues_path: issues_dashboard_path,
mr_path: merge_requests_dashboard_path },
- aria: { label: _('Search') }
+ aria: { label: _('Search or jump to…') }
%button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
.dropdown-menu.dropdown-select
= dropdown_content do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 2c262a2b7dd..34f47806205 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -4,7 +4,7 @@
.context-header
= link_to project_path(@project), title: @project.name do
.avatar-container.s40.project-avatar
- = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
+ = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title
= @project.name
%ul.sidebar-top-level-items
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 74ab8cf8250..fbe88ec9618 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,7 +3,7 @@
.project-home-panel.text-center{ class: ("empty-project" if empty_repo) }
.limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar
- = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile')
+ = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile', width: 70, height: 70)
%h1.project-title.qa-project-name
= @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 3da6db08580..70e1c557547 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -18,10 +18,14 @@
- if bitbucket_import_enabled?
%div
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
+ = icon('bitbucket', text: 'Bitbucket Cloud')
- unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
-
+ - if bitbucket_server_import_enabled?
+ %div
+ = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket" do
+ = icon('bitbucket-square', text: 'Bitbucket Server')
+ %div
- if gitlab_import_enabled?
%div
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 0ff88b82ae6..f483fad6142 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -51,7 +51,7 @@
.form-group
- if @project.avatar?
.avatar-container.s160.append-bottom-15
- = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160')
+ = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- if @project.avatar_in_git
%p.light
= _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 31c2616d283..7d878b38e85 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -13,23 +13,15 @@
.card.auto-devops-card
.card-body
.form-check
- = form.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings'
- = form.label :enabled_true, class: 'form-check-label' do
- %strong= s_('CICD|Enable Auto DevOps')
+ = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled?
+ = form.label :enabled, class: 'form-check-label' do
+ %strong= s_('CICD|Default to Auto DevOps pipeline')
+ - if @project.has_auto_devops_implicitly_enabled?
+ %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled')
.form-text.text-muted
- = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted }
-
- .card.auto-devops-card
- .card-body
- .form-check
- = form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings'
- = form.label :enabled_, class: 'form-check-label' do
- %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
- .form-text.text-muted
- = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted }
-
- .card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil }
- .card-body.bg-light
+ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
+ .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' }
= form.label :domain do
%strong= _('Domain')
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
@@ -46,21 +38,12 @@
.form-check
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
- %strong= s_('CICD|Continuous deployment to production')
+ = s_('CICD|Continuous deployment to production')
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'manual', class: 'form-check-input'
= form.label :deploy_strategy_manual, class: 'form-check-label' do
- %strong= s_('CICD|Automatic deployment to staging, manual deployment to production')
+ = s_('CICD|Automatic deployment to staging, manual deployment to production')
= link_to icon('question-circle'), help_page_path('ci/environments.md', anchor: 'manually-deploying-to-environments'), target: '_blank'
- .card.auto-devops-card
- .card-body
- .form-check
- = form.radio_button :enabled, 'false', class: 'form-check-input js-toggle-extra-settings', data: { hide_extra_settings: true }
- = form.label :enabled_false, class: 'form-check-label' do
- %strong= s_('CICD|Disable Auto DevOps')
- .form-text.text-muted
- = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted }
-
= f.submit _('Save changes'), class: "btn btn-success prepend-top-15"
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index e93925b5ef9..2c3cbd0b986 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -17,13 +17,13 @@
- if can?(current_user, :admin_label, @project)
%li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
- %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'top' }, aria_label: _('Prioritize label') }
+ %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') }
= sprite_icon('star-o')
- %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') }
+ %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') }
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
- = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do
+ = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
%li.inline
.dropdown
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 0ae3ab8f090..c5ea15a7f63 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,14 +1,17 @@
- subject = local_assigns[:subject]
- force_priority = local_assigns.fetch(:force_priority, false)
-- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
-- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
+- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project)
.label-name
- = link_to_label(label, subject: @project, tooltip: false)
+ - if defined?(@project)
+ = link_to_label(label, subject: @project, tooltip: false)
+ - else
+ = render_colored_label(label, tooltip: false)
.label-description
.append-right-default.prepend-left-default
- if label.description.present?
- .description-text.append-bottom-10
+ .description-text
= markdown_field(label, :description)
%ul.label-links
- if show_label_issues_link
@@ -19,5 +22,5 @@
%li.label-link-item.inline
= link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') }
- if force_priority
- %li.label-link-item.js-priority-badge.inline.prepend-left-10
+ %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
.label-badge.label-badge-blue= _('Prioritized label')
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 6be1fb485a4..be053d481e4 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -19,7 +19,7 @@
- if project.creator && use_creator_avatar
= image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else
- = project_icon(project, alt: '', class: 'avatar project-avatar s40')
+ = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
.project-details
%h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: 'text-plain' do
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index e8b9999f83b..f95df7ecf03 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -77,6 +77,7 @@
- todos_destroyer:todos_destroyer_entity_leave
- todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_private_features
+- todos_destroyer:todos_destroyer_group_private
- default
- mailers # ActionMailer::DeliveryJob.queue_name
diff --git a/app/workers/todos_destroyer/group_private_worker.rb b/app/workers/todos_destroyer/group_private_worker.rb
new file mode 100644
index 00000000000..3e47eec7461
--- /dev/null
+++ b/app/workers/todos_destroyer/group_private_worker.rb
@@ -0,0 +1,10 @@
+module TodosDestroyer
+ class GroupPrivateWorker
+ include ApplicationWorker
+ include TodosDestroyerQueue
+
+ def perform(group_id)
+ ::Todos::Destroy::GroupPrivateService.new(group_id).execute
+ end
+ end
+end
diff --git a/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml
new file mode 100644
index 00000000000..efa13c9ab3c
--- /dev/null
+++ b/changelogs/unreleased/36409-frontend-for-clarifying-the-usefulness-of-the-search-bar.yml
@@ -0,0 +1,5 @@
+---
+title: UX improvements to top nav search bar
+merge_request: 20537
+author:
+type: changed
diff --git a/changelogs/unreleased/47156-improve-auto-devops-settings.yml b/changelogs/unreleased/47156-improve-auto-devops-settings.yml
new file mode 100644
index 00000000000..d8993565047
--- /dev/null
+++ b/changelogs/unreleased/47156-improve-auto-devops-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Improve and simplify Auto DevOps settings flow
+merge_request: 20946
+author:
+type: other
diff --git a/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml
new file mode 100644
index 00000000000..43125ef25c4
--- /dev/null
+++ b/changelogs/unreleased/48098-mutual-auth-cluster-applications.yml
@@ -0,0 +1,6 @@
+---
+title: Ensure installed Helm Tiller For GitLab Managed Apps Is protected by mutual
+ auth
+merge_request: 20928
+author:
+type: changed
diff --git a/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml
new file mode 100644
index 00000000000..b3ccbb121f0
--- /dev/null
+++ b/changelogs/unreleased/48419-charts-with-long-label-appear-oversized.yml
@@ -0,0 +1,5 @@
+---
+title: fix height of full-width Metrics charts on large screens
+merge_request: 20866
+author:
+type: fixed
diff --git a/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml
new file mode 100644
index 00000000000..c34750a3b88
--- /dev/null
+++ b/changelogs/unreleased/48456-fix-system-level-labels-admin-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the UI for listing system-level labels
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml
new file mode 100644
index 00000000000..ffa4a3bc710
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-1.yml
@@ -0,0 +1,5 @@
+---
+title: Fix rendering of the context lines in MR diffs page.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml
new file mode 100644
index 00000000000..42b0e4194f1
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-2.yml
@@ -0,0 +1,5 @@
+---
+title: Fix autosave and ESC confirmation issues for MR discussions.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml
new file mode 100644
index 00000000000..29419091d02
--- /dev/null
+++ b/changelogs/unreleased/49854-recover-mr-regression-fixes-safe-3.yml
@@ -0,0 +1,5 @@
+---
+title: Fix navigation to First and Next discussion on MR Changes tab.
+merge_request: 20968
+author:
+type: fixed
diff --git a/changelogs/unreleased/git-rerere-link-doc-update.yml b/changelogs/unreleased/git-rerere-link-doc-update.yml
new file mode 100644
index 00000000000..06093e8ec13
--- /dev/null
+++ b/changelogs/unreleased/git-rerere-link-doc-update.yml
@@ -0,0 +1,5 @@
+---
+title: Update git rerere link in docs
+merge_request: 21060
+author: gfyoung
+type: other
diff --git a/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml
new file mode 100644
index 00000000000..dc8148fa1a5
--- /dev/null
+++ b/changelogs/unreleased/osw-fix-n-plus-1-for-mrs-without-merge-info.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid N+1 on MRs page when metrics merging date cannot be found
+merge_request: 21053
+author:
+type: performance
diff --git a/config/routes/import.rb b/config/routes/import.rb
index efd0260ff60..3998d977c81 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -24,6 +24,13 @@ namespace :import do
get :jobs
end
+ resource :bitbucket_server, only: [:create, :new], controller: :bitbucket_server do
+ post :configure
+ get :status
+ get :callback
+ get :jobs
+ end
+
resource :google_code, only: [:create, :new], controller: :google_code do
get :status
post :callback
diff --git a/db/migrate/20180608091413_add_group_to_todos.rb b/db/migrate/20180608091413_add_group_to_todos.rb
new file mode 100644
index 00000000000..20ba4849057
--- /dev/null
+++ b/db/migrate/20180608091413_add_group_to_todos.rb
@@ -0,0 +1,36 @@
+class AddGroupToTodos < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Todo < ActiveRecord::Base
+ self.table_name = 'todos'
+
+ include ::EachBatch
+ end
+
+ def up
+ add_column(:todos, :group_id, :integer) unless group_id_exists?
+ add_concurrent_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade
+ add_concurrent_index :todos, :group_id
+
+ change_column_null :todos, :project_id, true
+ end
+
+ def down
+ remove_foreign_key_without_error(:todos, column: :group_id)
+ remove_concurrent_index(:todos, :group_id)
+ remove_column(:todos, :group_id) if group_id_exists?
+
+ Todo.where(project_id: nil).each_batch { |batch| batch.delete_all }
+ change_column_null :todos, :project_id, false
+ end
+
+ private
+
+ def group_id_exists?
+ column_exists?(:todos, :group_id)
+ end
+end
diff --git a/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb
new file mode 100644
index 00000000000..57cea18abcd
--- /dev/null
+++ b/db/migrate/20180612103626_add_columns_for_helm_tiller_certificates.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+class AddColumnsForHelmTillerCertificates < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :clusters_applications_helm, :encrypted_ca_key, :text
+ add_column :clusters_applications_helm, :encrypted_ca_key_iv, :text
+ add_column :clusters_applications_helm, :ca_cert, :text
+ end
+end
diff --git a/db/migrate/20180718005113_add_instance_statistics_visibility_to_application_setting.rb b/db/migrate/20180718005113_add_instance_statistics_visibility_to_application_setting.rb
index f5106f07500..4b6c1f74346 100644
--- a/db/migrate/20180718005113_add_instance_statistics_visibility_to_application_setting.rb
+++ b/db/migrate/20180718005113_add_instance_statistics_visibility_to_application_setting.rb
@@ -10,7 +10,7 @@ class AddInstanceStatisticsVisibilityToApplicationSetting < ActiveRecord::Migrat
def up
add_column_with_default(:application_settings, :instance_statistics_visibility_private,
:boolean,
- default: true,
+ default: false,
allow_null: false)
end
diff --git a/db/migrate/20180806094307_change_instance_stats_visibility_default.rb b/db/migrate/20180806094307_change_instance_stats_visibility_default.rb
deleted file mode 100644
index 4c27cfb8cab..00000000000
--- a/db/migrate/20180806094307_change_instance_stats_visibility_default.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-# See http://doc.gitlab.com/ce/development/migration_style_guide.html
-# for more information on how to write migrations for GitLab.
-
-class ChangeInstanceStatsVisibilityDefault < ActiveRecord::Migration
- include Gitlab::Database::MigrationHelpers
-
- DOWNTIME = false
-
- def up
- change_column_default :application_settings,
- :instance_statistics_visibility_private,
- true
- ApplicationSetting.update_all(instance_statistics_visibility_private: true)
- end
-
- def down
- change_column_default :application_settings,
- :instance_statistics_visibility_private,
- false
- end
-end
diff --git a/db/schema.rb b/db/schema.rb
index 924d2d81dcc..30b8147a474 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20180806094307) do
+ActiveRecord::Schema.define(version: 20180726172057) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -168,7 +168,7 @@ ActiveRecord::Schema.define(version: 20180806094307) do
t.boolean "enforce_terms", default: false
t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false
- t.boolean "instance_statistics_visibility_private", default: true, null: false
+ t.boolean "instance_statistics_visibility_private", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -637,6 +637,9 @@ ActiveRecord::Schema.define(version: 20180806094307) do
t.integer "status", null: false
t.string "version", null: false
t.text "status_reason"
+ t.text "encrypted_ca_key"
+ t.text "encrypted_ca_key_iv"
+ t.text "ca_cert"
end
create_table "clusters_applications_ingress", force: :cascade do |t|
@@ -1988,7 +1991,7 @@ ActiveRecord::Schema.define(version: 20180806094307) do
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
- t.integer "project_id", null: false
+ t.integer "project_id"
t.integer "target_id"
t.string "target_type", null: false
t.integer "author_id", null: false
@@ -1998,10 +2001,12 @@ ActiveRecord::Schema.define(version: 20180806094307) do
t.datetime "updated_at"
t.integer "note_id"
t.string "commit_id"
+ t.integer "group_id"
end
add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree
add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree
+ add_index "todos", ["group_id"], name: "index_todos_on_group_id", using: :btree
add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree
add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree
add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree
@@ -2389,6 +2394,7 @@ ActiveRecord::Schema.define(version: 20180806094307) do
add_foreign_key "term_agreements", "users", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
+ add_foreign_key "todos", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "todos", "notes", name: "fk_91d1f47b13", on_delete: :cascade
add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index d9a61aea6ef..d2acc32fe71 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -63,7 +63,7 @@ Gitaly network traffic is unencrypted so you should use a firewall to
restrict access to your Gitaly server.
Below we describe how to configure a Gitaly server at address
-`gitaly.internal:9999` with secret token `abc123secret`. We assume
+`gitaly.internal:8075` with secret token `abc123secret`. We assume
your GitLab installation has two repository storages, `default` and
`storage1`.
@@ -108,8 +108,30 @@ Omnibus installations:
```ruby
# /etc/gitlab/gitlab.rb
-gitaly['listen_addr'] = '0.0.0.0:9999'
+
+# Avoid running unnecessary services on the gitaly server
+postgresql['enable'] = false
+redis['enable'] = false
+nginx['enable'] = false
+prometheus['enable'] = false
+unicorn['enable'] = false
+sidekiq['enable'] = false
+gitlab_workhorse['enable'] = false
+
+# Prevent database connections during 'gitlab-ctl reconfigure'
+gitlab_rails['rake_cache_clear'] = false
+gitlab_rails['auto_migrate'] = false
+
+# Configure the gitlab-shell API callback URL. Without this, `git push` will
+# fail. This can be your 'front door' GitLab URL or an internal load
+# balancer.
+gitlab_rails['internal_api_url'] = 'https://gitlab.example.com'
+
+# Make Gitaly accept connections on all network interfaces. You must use
+# firewalls to restrict access to this address/port.
+gitaly['listen_addr'] = "0.0.0.0:8075"
gitaly['auth_token'] = 'abc123secret'
+
gitaly['storage'] = [
{ 'name' => 'default', 'path' => '/path/to/default/repositories' },
{ 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' },
@@ -120,7 +142,7 @@ Source installations:
```toml
# /home/git/gitaly/config.toml
-listen_addr = '0.0.0.0:9999'
+listen_addr = '0.0.0.0:8075'
[auth]
token = 'abc123secret'
@@ -146,7 +168,7 @@ server from reaching the Gitaly server then all Gitaly requests will
fail.
We assume that your Gitaly server can be reached at
-`gitaly.internal:9999` from your GitLab server, and that your GitLab
+`gitaly.internal:8075` from your GitLab server, and that your GitLab
NFS shares are mounted at `/mnt/gitlab/default` and
`/mnt/gitlab/storage1` respectively.
@@ -155,8 +177,8 @@ Omnibus installations:
```ruby
# /etc/gitlab/gitlab.rb
git_data_dirs({
- 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' },
- 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' },
+ 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
+ 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
})
gitlab_rails['gitaly_token'] = 'abc123secret'
@@ -171,10 +193,10 @@ gitlab:
storages:
default:
path: /mnt/gitlab/default/repositories
- gitaly_address: tcp://gitlab.internal:9999
+ gitaly_address: tcp://gitaly.internal:8075
storage1:
path: /mnt/gitlab/storage1/repositories
- gitaly_address: tcp://gitlab.internal:9999
+ gitaly_address: tcp://gitaly.internal:8075
gitaly:
token: 'abc123secret'
diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md
index 752a2774bd7..eada7b19dcd 100644
--- a/doc/administration/operations/fast_ssh_key_lookup.md
+++ b/doc/administration/operations/fast_ssh_key_lookup.md
@@ -1,11 +1,9 @@
-# Consider using SSH certificates instead of, or in addition to this
+# Fast lookup of authorized SSH keys in the database
-This document describes a drop-in replacement for the
+NOTE: **Note:** This document describes a drop-in replacement for the
`authorized_keys` file for normal (non-deploy key) users. Consider
using [ssh certificates](ssh_certificates.md), they are even faster,
-but are not is not a drop-in replacement.
-
-# Fast lookup of authorized SSH keys in the database
+but are not a drop-in replacement.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in
> [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3.
diff --git a/doc/api/todos.md b/doc/api/todos.md
index 27e623007cc..0843e4eedc6 100644
--- a/doc/api/todos.md
+++ b/doc/api/todos.md
@@ -18,6 +18,7 @@ Parameters:
| `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. |
| `author_id` | integer | no | The ID of an author |
| `project_id` | integer | no | The ID of a project |
+| `group_id` | integer | no | The ID of a group |
| `state` | string | no | The state of the todo. Can be either `pending` or `done` |
| `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` |
diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md
index a85e5b1b1cc..8d41503f874 100644
--- a/doc/development/automatic_ce_ee_merge.md
+++ b/doc/development/automatic_ce_ee_merge.md
@@ -100,7 +100,7 @@ Notes:
number of times you have to resolve conflicts.
- Please remember to
[always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts).
-- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
+- You can use [`git rerere`](https://git-scm.com/docs/git-rerere)
to avoid resolving the same conflicts multiple times.
### Cherry-picking from CE to EE
diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md
index 96a08c04905..b1b822f25bd 100644
--- a/doc/user/profile/index.md
+++ b/doc/user/profile/index.md
@@ -30,6 +30,7 @@ You can edit your account settings by navigating from the up-right corner menu b
From there, you can:
- Update your personal information
+- Set a [custom status](#current-status) for your profile
- Manage [2FA](account/two_factor_authentication.md)
- Change your username and [delete your account](account/delete_account.md)
- Manage applications that can
@@ -90,6 +91,27 @@ To enable private profile:
NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private.
+## Current status
+
+> Introduced in GitLab 11.2.
+
+You can provide a custom status message for your user profile along with an emoji that describes it.
+This may be helpful when you are out of office or otherwise not available.
+Other users can then take your status into consideration when responding to your issues or assigning work to you.
+Please be aware that your status is publicly visible even if your [profile is private](#private-profile).
+
+To set your current status:
+
+1. Navigate to your personal [profile settings](#profile-settings).
+1. In the text field below `Your status`, enter your status message.
+1. Select an emoji from the dropdown if you like.
+1. Hit **Update profile settings**.
+
+Status messages are restricted to 100 characters of plain text.
+They may however contain emoji codes such as `I'm on vacation :palm_tree:`.
+
+You can also set your current status [using the API](../../api/users.md#user-status).
+
## Troubleshooting
### Why do I keep getting signed out?
diff --git a/doc/user/search/img/issues_mrs_shortcut.png b/doc/user/search/img/issues_mrs_shortcut.png
index 6380b337b54..cf43df98aa0 100644
--- a/doc/user/search/img/issues_mrs_shortcut.png
+++ b/doc/user/search/img/issues_mrs_shortcut.png
Binary files differ
diff --git a/doc/user/search/img/project_search.png b/doc/user/search/img/project_search.png
index 3150b40de29..0b76d7d6038 100644
--- a/doc/user/search/img/project_search.png
+++ b/doc/user/search/img/project_search.png
Binary files differ
diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md
index 760cd87d4cc..dda82352c67 100644
--- a/doc/workflow/todos.md
+++ b/doc/workflow/todos.md
@@ -109,6 +109,7 @@ There are four kinds of filters you can use on your Todos dashboard.
| Filter | Description |
| ------- | ----------- |
| Project | Filter by project |
+| Group | Filter by group |
| Author | Filter by the author that triggered the Todo |
| Type | Filter by issue or merge request |
| Action | Filter by the action that triggered the Todo |
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index f858d9fa23d..27f28e1df93 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -795,28 +795,33 @@ module API
class Todo < Grape::Entity
expose :id
- expose :project, using: Entities::BasicProjectDetails
+ expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project_id }
+ expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group_id }
expose :author, using: Entities::UserBasic
expose :action_name
expose :target_type
expose :target do |todo, options|
- Entities.const_get(todo.target_type).represent(todo.target, options)
+ todo_target_class(todo.target_type).represent(todo.target, options)
end
expose :target_url do |todo, options|
target_type = todo.target_type.underscore
- target_url = "namespace_project_#{target_type}_url"
+ target_url = "#{todo.parent.class.to_s.underscore}_#{target_type}_url"
target_anchor = "note_#{todo.note_id}" if todo.note_id?
Gitlab::Routing
.url_helpers
- .public_send(target_url, todo.project.namespace, todo.project, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
+ .public_send(target_url, todo.parent, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
end
expose :body
expose :state
expose :created_at
+
+ def todo_target_class(target_type)
+ ::API::Entities.const_get(target_type)
+ end
end
class NamespaceBasic < Grape::Entity
diff --git a/lib/bitbucket_server/client.rb b/lib/bitbucket_server/client.rb
new file mode 100644
index 00000000000..15e59f93141
--- /dev/null
+++ b/lib/bitbucket_server/client.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Client
+ attr_reader :connection
+
+ ServerError = Class.new(StandardError)
+
+ SERVER_ERRORS = [SocketError,
+ OpenSSL::SSL::SSLError,
+ Errno::ECONNRESET,
+ Errno::ECONNREFUSED,
+ Errno::EHOSTUNREACH,
+ Net::OpenTimeout,
+ Net::ReadTimeout,
+ Gitlab::HTTP::BlockedUrlError,
+ BitbucketServer::Connection::ConnectionError].freeze
+
+ def initialize(options = {})
+ @connection = Connection.new(options)
+ end
+
+ def pull_requests(project_key, repo)
+ path = "/projects/#{project_key}/repos/#{repo}/pull-requests?state=ALL"
+ get_collection(path, :pull_request)
+ end
+
+ def activities(project_key, repo, pull_request_id)
+ path = "/projects/#{project_key}/repos/#{repo}/pull-requests/#{pull_request_id}/activities"
+ get_collection(path, :activity)
+ end
+
+ def repo(project, repo_name)
+ parsed_response = connection.get("/projects/#{project}/repos/#{repo_name}")
+ BitbucketServer::Representation::Repo.new(parsed_response)
+ end
+
+ def repos
+ path = "/repos"
+ get_collection(path, :repo)
+ end
+
+ def create_branch(project_key, repo, branch_name, sha)
+ payload = {
+ name: branch_name,
+ startPoint: sha,
+ message: 'GitLab temporary branch for import'
+ }
+
+ connection.post("/projects/#{project_key}/repos/#{repo}/branches", payload.to_json)
+ end
+
+ def delete_branch(project_key, repo, branch_name, sha)
+ payload = {
+ name: Gitlab::Git::BRANCH_REF_PREFIX + branch_name,
+ dryRun: false
+ }
+
+ connection.delete(:branches, "/projects/#{project_key}/repos/#{repo}/branches", payload.to_json)
+ end
+
+ private
+
+ def get_collection(path, type)
+ paginator = BitbucketServer::Paginator.new(connection, Addressable::URI.escape(path), type)
+ BitbucketServer::Collection.new(paginator)
+ rescue *SERVER_ERRORS => e
+ raise ServerError, e
+ end
+ end
+end
diff --git a/lib/bitbucket_server/collection.rb b/lib/bitbucket_server/collection.rb
new file mode 100644
index 00000000000..b50c5dde352
--- /dev/null
+++ b/lib/bitbucket_server/collection.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Collection < Enumerator
+ def initialize(paginator)
+ super() do |yielder|
+ loop do
+ paginator.items.each { |item| yielder << item }
+ end
+ end
+
+ lazy
+ end
+
+ def method_missing(method, *args)
+ return super unless self.respond_to?(method)
+
+ self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
+ block_given? ? yield(item) : item
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/connection.rb b/lib/bitbucket_server/connection.rb
new file mode 100644
index 00000000000..45a437844bd
--- /dev/null
+++ b/lib/bitbucket_server/connection.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Connection
+ include ActionView::Helpers::SanitizeHelper
+
+ DEFAULT_API_VERSION = '1.0'
+ SEPARATOR = '/'
+
+ attr_reader :api_version, :base_uri, :username, :token
+
+ ConnectionError = Class.new(StandardError)
+
+ def initialize(options = {})
+ @api_version = options.fetch(:api_version, DEFAULT_API_VERSION)
+ @base_uri = options[:base_uri]
+ @username = options[:user]
+ @token = options[:password]
+ end
+
+ def get(path, extra_query = {})
+ response = Gitlab::HTTP.get(build_url(path),
+ basic_auth: auth,
+ headers: accept_headers,
+ query: extra_query)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ def post(path, body)
+ response = Gitlab::HTTP.post(build_url(path),
+ basic_auth: auth,
+ headers: post_headers,
+ body: body)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ # We need to support two different APIs for deletion:
+ #
+ # /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/branches/default
+ # /rest/branch-utils/1.0/projects/{projectKey}/repos/{repositorySlug}/branches
+ def delete(resource, path, body)
+ url = delete_url(resource, path)
+
+ response = Gitlab::HTTP.delete(url,
+ basic_auth: auth,
+ headers: post_headers,
+ body: body)
+
+ check_errors!(response)
+
+ response.parsed_response
+ end
+
+ private
+
+ def check_errors!(response)
+ raise ConnectionError, "Response is not valid JSON" unless response.parsed_response.is_a?(Hash)
+
+ return if response.code >= 200 && response.code < 300
+
+ details = sanitize(response.parsed_response.dig('errors', 0, 'message'))
+ message = "Error #{response.code}"
+ message += ": #{details}" if details
+
+ raise ConnectionError, message
+ rescue JSON::ParserError
+ raise ConnectionError, "Unable to parse the server response as JSON"
+ end
+
+ def auth
+ @auth ||= { username: username, password: token }
+ end
+
+ def accept_headers
+ @accept_headers ||= { 'Accept' => 'application/json' }
+ end
+
+ def post_headers
+ @post_headers ||= accept_headers.merge({ 'Content-Type' => 'application/json' })
+ end
+
+ def build_url(path)
+ return path if path.starts_with?(root_url)
+
+ url_join_paths(root_url, path)
+ end
+
+ def root_url
+ url_join_paths(base_uri, "/rest/api/#{api_version}")
+ end
+
+ def delete_url(resource, path)
+ if resource == :branches
+ url_join_paths(base_uri, "/rest/branch-utils/#{api_version}#{path}")
+ else
+ build_url(path)
+ end
+ end
+
+ # URI.join is stupid in that slashes are important:
+ #
+ # # URI.join('http://example.com/subpath', 'hello')
+ # => http://example.com/hello
+ #
+ # We really want http://example.com/subpath/hello
+ #
+ def url_join_paths(*paths)
+ paths.map { |path| strip_slashes(path) }.join(SEPARATOR)
+ end
+
+ def strip_slashes(path)
+ path = path[1..-1] if path.starts_with?(SEPARATOR)
+ path.chomp(SEPARATOR)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/page.rb b/lib/bitbucket_server/page.rb
new file mode 100644
index 00000000000..5d9a3168876
--- /dev/null
+++ b/lib/bitbucket_server/page.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Page
+ attr_reader :attrs, :items
+
+ def initialize(raw, type)
+ @attrs = parse_attrs(raw)
+ @items = parse_values(raw, representation_class(type))
+ end
+
+ def next?
+ !attrs.fetch(:isLastPage, true)
+ end
+
+ def next
+ attrs.fetch(:nextPageStart)
+ end
+
+ private
+
+ def parse_attrs(raw)
+ raw.slice('size', 'nextPageStart', 'isLastPage').symbolize_keys
+ end
+
+ def parse_values(raw, bitbucket_rep_class)
+ return [] unless raw['values'] && raw['values'].is_a?(Array)
+
+ bitbucket_rep_class.decorate(raw['values'])
+ end
+
+ def representation_class(type)
+ BitbucketServer::Representation.const_get(type.to_s.camelize)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/paginator.rb b/lib/bitbucket_server/paginator.rb
new file mode 100644
index 00000000000..c351fb2f11f
--- /dev/null
+++ b/lib/bitbucket_server/paginator.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ class Paginator
+ PAGE_LENGTH = 25
+
+ def initialize(connection, url, type)
+ @connection = connection
+ @type = type
+ @url = url
+ @page = nil
+ end
+
+ def items
+ raise StopIteration unless has_next_page?
+
+ @page = fetch_next_page
+ @page.items
+ end
+
+ private
+
+ attr_reader :connection, :page, :url, :type
+
+ def has_next_page?
+ page.nil? || page.next?
+ end
+
+ def next_offset
+ page.nil? ? 0 : page.next
+ end
+
+ def fetch_next_page
+ parsed_response = connection.get(@url, start: next_offset, limit: PAGE_LENGTH)
+ Page.new(parsed_response, type)
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/activity.rb b/lib/bitbucket_server/representation/activity.rb
new file mode 100644
index 00000000000..08bf30a5d1e
--- /dev/null
+++ b/lib/bitbucket_server/representation/activity.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Activity < Representation::Base
+ def comment?
+ action == 'COMMENTED'
+ end
+
+ def inline_comment?
+ !!(comment? && comment_anchor)
+ end
+
+ def comment
+ return unless comment?
+
+ @comment ||=
+ if inline_comment?
+ PullRequestComment.new(raw)
+ else
+ Comment.new(raw)
+ end
+ end
+
+ # TODO Move this into MergeEvent
+ def merge_event?
+ action == 'MERGED'
+ end
+
+ def committer_user
+ commit.dig('committer', 'displayName')
+ end
+
+ def committer_email
+ commit.dig('committer', 'emailAddress')
+ end
+
+ def merge_timestamp
+ timestamp = commit['committerTimestamp']
+
+ self.class.convert_timestamp(timestamp)
+ end
+
+ def merge_commit
+ commit['id']
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ private
+
+ def commit
+ raw.fetch('commit', {})
+ end
+
+ def action
+ raw['action']
+ end
+
+ def comment_anchor
+ raw['commentAnchor']
+ end
+
+ def created_date
+ raw['createdDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/base.rb b/lib/bitbucket_server/representation/base.rb
new file mode 100644
index 00000000000..a1961bae6ef
--- /dev/null
+++ b/lib/bitbucket_server/representation/base.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Base
+ attr_reader :raw
+
+ def initialize(raw)
+ @raw = raw
+ end
+
+ def self.decorate(entries)
+ entries.map { |entry| new(entry)}
+ end
+
+ def self.convert_timestamp(time_usec)
+ Time.at(time_usec / 1000) if time_usec.is_a?(Integer)
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/comment.rb b/lib/bitbucket_server/representation/comment.rb
new file mode 100644
index 00000000000..99b97a3b181
--- /dev/null
+++ b/lib/bitbucket_server/representation/comment.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ # A general comment with the structure:
+ # "comment": {
+ # "author": {
+ # "active": true,
+ # "displayName": "root",
+ # "emailAddress": "stanhu+bitbucket@gitlab.com",
+ # "id": 1,
+ # "links": {
+ # "self": [
+ # {
+ # "href": "http://localhost:7990/users/root"
+ # }
+ # ]
+ # },
+ # "name": "root",
+ # "slug": "root",
+ # "type": "NORMAL"
+ # }
+ # }
+ # }
+ class Comment < Representation::Base
+ attr_reader :parent_comment
+
+ CommentNode = Struct.new(:raw_comments, :parent)
+
+ def initialize(raw, parent_comment: nil)
+ super(raw)
+
+ @parent_comment = parent_comment
+ end
+
+ def id
+ raw_comment['id']
+ end
+
+ def author_username
+ author['displayName']
+ end
+
+ def author_email
+ author['emailAddress']
+ end
+
+ def note
+ raw_comment['text']
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ def updated_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ # Bitbucket Server supports the ability to reply to any comment
+ # and created multiple threads. It represents these as a linked list
+ # of comments within comments. For example:
+ #
+ # "comments": [
+ # {
+ # "author" : ...
+ # "comments": [
+ # {
+ # "author": ...
+ #
+ # Since GitLab only supports a single thread, we flatten all these
+ # comments into a single discussion.
+ def comments
+ @comments ||= flatten_comments
+ end
+
+ private
+
+ # In order to provide context for each reply, we need to track
+ # the parent of each comment. This method works as follows:
+ #
+ # 1. Insert the root comment into the workset. The root element is the current note.
+ # 2. For each node in the workset:
+ # a. Examine if it has replies to that comment. If it does,
+ # insert that node into the workset.
+ # b. Parse that note into a Comment structure and add it to a flat list.
+ def flatten_comments
+ comments = raw_comment['comments']
+ workset =
+ if comments
+ [CommentNode.new(comments, self)]
+ else
+ []
+ end
+
+ all_comments = []
+
+ until workset.empty?
+ node = workset.pop
+ parent = node.parent
+
+ node.raw_comments.each do |comment|
+ new_comments = comment.delete('comments')
+ current_comment = Comment.new({ 'comment' => comment }, parent_comment: parent)
+ all_comments << current_comment
+ workset << CommentNode.new(new_comments, current_comment) if new_comments
+ end
+ end
+
+ all_comments
+ end
+
+ def raw_comment
+ raw.fetch('comment', {})
+ end
+
+ def author
+ raw_comment['author']
+ end
+
+ def created_date
+ raw_comment['createdDate']
+ end
+
+ def updated_date
+ raw_comment['updatedDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/pull_request.rb b/lib/bitbucket_server/representation/pull_request.rb
new file mode 100644
index 00000000000..c3e927d8de7
--- /dev/null
+++ b/lib/bitbucket_server/representation/pull_request.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class PullRequest < Representation::Base
+ def author
+ raw.dig('author', 'user', 'name')
+ end
+
+ def author_email
+ raw.dig('author', 'user', 'emailAddress')
+ end
+
+ def description
+ raw['description']
+ end
+
+ def iid
+ raw['id']
+ end
+
+ def state
+ case raw['state']
+ when 'MERGED'
+ 'merged'
+ when 'DECLINED'
+ 'closed'
+ else
+ 'opened'
+ end
+ end
+
+ def merged?
+ state == 'merged'
+ end
+
+ def created_at
+ self.class.convert_timestamp(created_date)
+ end
+
+ def updated_at
+ self.class.convert_timestamp(updated_date)
+ end
+
+ def title
+ raw['title']
+ end
+
+ def source_branch_name
+ raw.dig('fromRef', 'id')
+ end
+
+ def source_branch_sha
+ raw.dig('fromRef', 'latestCommit')
+ end
+
+ def target_branch_name
+ raw.dig('toRef', 'id')
+ end
+
+ def target_branch_sha
+ raw.dig('toRef', 'latestCommit')
+ end
+
+ private
+
+ def created_date
+ raw['createdDate']
+ end
+
+ def updated_date
+ raw['updatedDate']
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/pull_request_comment.rb b/lib/bitbucket_server/representation/pull_request_comment.rb
new file mode 100644
index 00000000000..a2b3873a397
--- /dev/null
+++ b/lib/bitbucket_server/representation/pull_request_comment.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ # An inline comment with the following structure that identifies
+ # the part of the diff:
+ #
+ # "commentAnchor": {
+ # "diffType": "EFFECTIVE",
+ # "fileType": "TO",
+ # "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ # "line": 1,
+ # "lineType": "ADDED",
+ # "orphaned": false,
+ # "path": "CHANGELOG.md",
+ # "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ # }
+ #
+ # More details in https://docs.atlassian.com/bitbucket-server/rest/5.12.0/bitbucket-rest.html.
+ class PullRequestComment < Comment
+ def from_sha
+ comment_anchor['fromHash']
+ end
+
+ def to_sha
+ comment_anchor['toHash']
+ end
+
+ def to?
+ file_type == 'TO'
+ end
+
+ def from?
+ file_type == 'FROM'
+ end
+
+ def added?
+ line_type == 'ADDED'
+ end
+
+ def removed?
+ line_type == 'REMOVED'
+ end
+
+ # There are three line comment types: added, removed, or context.
+ #
+ # 1. An added type means a new line was inserted, so there is no old position.
+ # 2. A removed type means a line was removed, so there is no new position.
+ # 3. A context type means the line was unmodified, so there is both a
+ # old and new position.
+ def new_pos
+ return if removed?
+ return unless line_position
+
+ line_position[1]
+ end
+
+ def old_pos
+ return if added?
+ return unless line_position
+
+ line_position[0]
+ end
+
+ def file_path
+ comment_anchor.fetch('path')
+ end
+
+ private
+
+ def file_type
+ comment_anchor['fileType']
+ end
+
+ def line_type
+ comment_anchor['lineType']
+ end
+
+ # Each comment contains the following information about the diff:
+ #
+ # hunks: [
+ # {
+ # segments: [
+ # {
+ # "lines": [
+ # {
+ # "commentIds": [ N ],
+ # "source": X,
+ # "destination": Y
+ # }, ...
+ # ] ....
+ #
+ # To determine the line position of a comment, we search all the lines
+ # entries until we find this comment ID.
+ def line_position
+ @line_position ||= diff_hunks.each do |hunk|
+ segments = hunk.fetch('segments', [])
+ segments.each do |segment|
+ lines = segment.fetch('lines', [])
+ lines.each do |line|
+ if line['commentIds']&.include?(id)
+ return [line['source'], line['destination']]
+ end
+ end
+ end
+ end
+ end
+
+ def comment_anchor
+ raw.fetch('commentAnchor', {})
+ end
+
+ def diff
+ raw.fetch('diff', {})
+ end
+
+ def diff_hunks
+ diff.fetch('hunks', [])
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket_server/representation/repo.rb b/lib/bitbucket_server/representation/repo.rb
new file mode 100644
index 00000000000..6c494b79166
--- /dev/null
+++ b/lib/bitbucket_server/representation/repo.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module BitbucketServer
+ module Representation
+ class Repo < Representation::Base
+ def initialize(raw)
+ super(raw)
+ end
+
+ def project_key
+ raw.dig('project', 'key')
+ end
+
+ def project_name
+ raw.dig('project', 'name')
+ end
+
+ def slug
+ raw['slug']
+ end
+
+ def browse_url
+ # The JSON reponse contains an array of 1 element. Not sure if there
+ # are cases where multiple links would be provided.
+ raw.dig('links', 'self').first.fetch('href')
+ end
+
+ def clone_url
+ raw['links']['clone'].find { |link| link['name'].starts_with?('http') }.fetch('href')
+ end
+
+ def description
+ project['description']
+ end
+
+ def full_name
+ "#{project_name}/#{name}"
+ end
+
+ def issues_enabled?
+ true
+ end
+
+ def name
+ raw['name']
+ end
+
+ def valid?
+ raw['scmId'] == 'git'
+ end
+
+ def visibility_level
+ if project['public']
+ Gitlab::VisibilityLevel::PUBLIC
+ else
+ Gitlab::VisibilityLevel::PRIVATE
+ end
+ end
+
+ def project
+ raw['project']
+ end
+
+ def to_s
+ full_name
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb
new file mode 100644
index 00000000000..268d21a77d1
--- /dev/null
+++ b/lib/gitlab/bitbucket_server_import/importer.rb
@@ -0,0 +1,327 @@
+module Gitlab
+ module BitbucketServerImport
+ class Importer
+ include Gitlab::ShellAdapter
+ attr_reader :recover_missing_commits
+ attr_reader :project, :project_key, :repository_slug, :client, :errors, :users
+
+ REMOTE_NAME = 'bitbucket_server'.freeze
+ BATCH_SIZE = 100
+
+ TempBranch = Struct.new(:name, :sha)
+
+ def self.imports_repository?
+ true
+ end
+
+ def self.refmap
+ [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head']
+ end
+
+ # Unlike GitHub, you can't grab the commit SHAs for pull requests that
+ # have been closed but not merged even though Bitbucket has these
+ # commits internally. We can recover these pull requests by creating a
+ # branch with the Bitbucket REST API, but by default we turn this
+ # behavior off.
+ def initialize(project, recover_missing_commits: false)
+ @project = project
+ @recover_missing_commits = recover_missing_commits
+ @project_key = project.import_data.data['project_key']
+ @repository_slug = project.import_data.data['repo_slug']
+ @client = BitbucketServer::Client.new(project.import_data.credentials)
+ @formatter = Gitlab::ImportFormatter.new
+ @errors = []
+ @users = {}
+ @temp_branches = []
+ end
+
+ def execute
+ import_repository
+ import_pull_requests
+ delete_temp_branches
+ handle_errors
+
+ true
+ end
+
+ private
+
+ def handle_errors
+ return unless errors.any?
+
+ project.update_column(:import_error, {
+ message: 'The remote data could not be fully imported.',
+ errors: errors
+ }.to_json)
+ end
+
+ def gitlab_user_id(email)
+ find_user_id(email) || project.creator_id
+ end
+
+ def find_user_id(email)
+ return nil unless email
+
+ return users[email] if users.key?(email)
+
+ user = User.find_by_any_email(email, confirmed: true)
+ users[email] = user&.id
+
+ user&.id
+ end
+
+ def repo
+ @repo ||= client.repo(project_key, repository_slug)
+ end
+
+ def sha_exists?(sha)
+ project.repository.commit(sha)
+ end
+
+ def temp_branch_name(pull_request, suffix)
+ "gitlab/import/pull-request/#{pull_request.iid}/#{suffix}"
+ end
+
+ # This method restores required SHAs that GitLab needs to create diffs
+ # into branch names as the following:
+ #
+ # gitlab/import/pull-request/N/{to,from}
+ def restore_branches(pull_requests)
+ shas_to_restore = []
+
+ pull_requests.each do |pull_request|
+ shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :from),
+ pull_request.source_branch_sha)
+ shas_to_restore << TempBranch.new(temp_branch_name(pull_request, :to),
+ pull_request.target_branch_sha)
+ end
+
+ # Create the branches on the Bitbucket Server first
+ created_branches = restore_branch_shas(shas_to_restore)
+
+ @temp_branches += created_branches
+ # Now sync the repository so we get the new branches
+ import_repository unless created_branches.empty?
+ end
+
+ def restore_branch_shas(shas_to_restore)
+ shas_to_restore.each_with_object([]) do |temp_branch, branches_created|
+ branch_name = temp_branch.name
+ sha = temp_branch.sha
+
+ next if sha_exists?(sha)
+
+ begin
+ client.create_branch(project_key, repository_slug, branch_name, sha)
+ branches_created << temp_branch
+ rescue BitbucketServer::Connection::ConnectionError => e
+ Rails.logger.warn("BitbucketServerImporter: Unable to recreate branch for SHA #{sha}: #{e}")
+ end
+ end
+ end
+
+ def import_repository
+ project.ensure_repository
+ project.repository.fetch_as_mirror(project.import_url, refmap: self.class.refmap, remote_name: REMOTE_NAME)
+ rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e
+ # Expire cache to prevent scenarios such as:
+ # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
+ # 2. Retried import, repo is broken or not imported but +exists?+ still returns true
+ project.repository.expire_content_cache if project.repository_exists?
+
+ raise e.message
+ end
+
+ # Bitbucket Server keeps tracks of references for open pull requests in
+ # refs/heads/pull-requests, but closed and merged requests get moved
+ # into hidden internal refs under stash-refs/pull-requests. Unless the
+ # SHAs involved are at the tip of a branch or tag, there is no way to
+ # retrieve the server for those commits.
+ #
+ # To avoid losing history, we use the Bitbucket API to re-create the branch
+ # on the remote server. Then we have to issue a `git fetch` to download these
+ # branches.
+ def import_pull_requests
+ pull_requests = client.pull_requests(project_key, repository_slug).to_a
+
+ # Creating branches on the server and fetching the newly-created branches
+ # may take a number of network round-trips. Do this in batches so that we can
+ # avoid doing a git fetch for every new branch.
+ pull_requests.each_slice(BATCH_SIZE) do |batch|
+ restore_branches(batch) if recover_missing_commits
+
+ batch.each do |pull_request|
+ begin
+ import_bitbucket_pull_request(pull_request)
+ rescue StandardError => e
+ errors << { type: :pull_request, iid: pull_request.iid, errors: e.message, trace: e.backtrace.join("\n"), raw_response: pull_request.raw }
+ end
+ end
+ end
+ end
+
+ def delete_temp_branches
+ @temp_branches.each do |branch|
+ begin
+ client.delete_branch(project_key, repository_slug, branch.name, branch.sha)
+ project.repository.delete_branch(branch.name)
+ rescue BitbucketServer::Connection::ConnectionError => e
+ @errors << { type: :delete_temp_branches, branch_name: branch.name, errors: e.message }
+ end
+ end
+ end
+
+ def import_bitbucket_pull_request(pull_request)
+ description = ''
+ description += @formatter.author_line(pull_request.author) unless find_user_id(pull_request.author_email)
+ description += pull_request.description if pull_request.description
+
+ source_branch_sha = pull_request.source_branch_sha
+ target_branch_sha = pull_request.target_branch_sha
+ author_id = gitlab_user_id(pull_request.author_email)
+
+ attributes = {
+ iid: pull_request.iid,
+ title: pull_request.title,
+ description: description,
+ source_project: project,
+ source_branch: Gitlab::Git.ref_name(pull_request.source_branch_name),
+ source_branch_sha: source_branch_sha,
+ target_project: project,
+ target_branch: Gitlab::Git.ref_name(pull_request.target_branch_name),
+ target_branch_sha: target_branch_sha,
+ state: pull_request.state,
+ author_id: author_id,
+ assignee_id: nil,
+ created_at: pull_request.created_at,
+ updated_at: pull_request.updated_at
+ }
+
+ merge_request = project.merge_requests.create!(attributes)
+ import_pull_request_comments(pull_request, merge_request) if merge_request.persisted?
+ end
+
+ def import_pull_request_comments(pull_request, merge_request)
+ comments, other_activities = client.activities(project_key, repository_slug, pull_request.iid).partition(&:comment?)
+
+ merge_event = other_activities.find(&:merge_event?)
+ import_merge_event(merge_request, merge_event) if merge_event
+
+ inline_comments, pr_comments = comments.partition(&:inline_comment?)
+
+ import_inline_comments(inline_comments.map(&:comment), merge_request)
+ import_standalone_pr_comments(pr_comments.map(&:comment), merge_request)
+ end
+
+ def import_merge_event(merge_request, merge_event)
+ committer = merge_event.committer_email
+
+ user_id = gitlab_user_id(committer)
+ timestamp = merge_event.merge_timestamp
+ merge_request.update({ merge_commit_sha: merge_event.merge_commit })
+ metric = MergeRequest::Metrics.find_or_initialize_by(merge_request: merge_request)
+ metric.update(merged_by_id: user_id, merged_at: timestamp)
+ end
+
+ def import_inline_comments(inline_comments, merge_request)
+ inline_comments.each do |comment|
+ position = build_position(merge_request, comment)
+ parent = create_diff_note(merge_request, comment, position)
+
+ next unless parent&.persisted?
+
+ discussion_id = parent.discussion_id
+
+ comment.comments.each do |reply|
+ create_diff_note(merge_request, reply, position, discussion_id)
+ end
+ end
+ end
+
+ def create_diff_note(merge_request, comment, position, discussion_id = nil)
+ attributes = pull_request_comment_attributes(comment)
+ attributes.merge!(position: position, type: 'DiffNote')
+ attributes[:discussion_id] = discussion_id if discussion_id
+
+ note = merge_request.notes.build(attributes)
+
+ if note.valid?
+ note.save
+ return note
+ end
+
+ # Bitbucket Server supports the ability to comment on any line, not just the
+ # line in the diff. If we can't add the note as a DiffNote, fallback to creating
+ # a regular note.
+ create_fallback_diff_note(merge_request, comment, position)
+ rescue StandardError => e
+ errors << { type: :pull_request, id: comment.id, errors: e.message }
+ nil
+ end
+
+ def create_fallback_diff_note(merge_request, comment, position)
+ attributes = pull_request_comment_attributes(comment)
+ note = "*Comment on"
+
+ note += " #{position.old_path}:#{position.old_line} -->" if position.old_line
+ note += " #{position.new_path}:#{position.new_line}" if position.new_line
+ note += "*\n\n#{comment.note}"
+
+ attributes[:note] = note
+ merge_request.notes.create!(attributes)
+ end
+
+ def build_position(merge_request, pr_comment)
+ params = {
+ diff_refs: merge_request.diff_refs,
+ old_path: pr_comment.file_path,
+ new_path: pr_comment.file_path,
+ old_line: pr_comment.old_pos,
+ new_line: pr_comment.new_pos
+ }
+
+ Gitlab::Diff::Position.new(params)
+ end
+
+ def import_standalone_pr_comments(pr_comments, merge_request)
+ pr_comments.each do |comment|
+ begin
+ merge_request.notes.create!(pull_request_comment_attributes(comment))
+
+ comment.comments.each do |replies|
+ merge_request.notes.create!(pull_request_comment_attributes(replies))
+ end
+ rescue StandardError => e
+ errors << { type: :pull_request, iid: comment.id, errors: e.message }
+ end
+ end
+ end
+
+ def pull_request_comment_attributes(comment)
+ author = find_user_id(comment.author_email)
+ note = ''
+
+ unless author
+ author = project.creator_id
+ note = "*By #{comment.author_username} (#{comment.author_email})*\n\n"
+ end
+
+ note +=
+ # Provide some context for replying
+ if comment.parent_comment
+ "> #{comment.parent_comment.note.truncate(80)}\n\n#{comment.note}"
+ else
+ comment.note
+ end
+
+ {
+ project: project,
+ note: note,
+ author_id: author,
+ created_at: comment.created_at,
+ updated_at: comment.updated_at
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/project_creator.rb b/lib/gitlab/bitbucket_server_import/project_creator.rb
new file mode 100644
index 00000000000..35e8cd7e0ab
--- /dev/null
+++ b/lib/gitlab/bitbucket_server_import/project_creator.rb
@@ -0,0 +1,36 @@
+module Gitlab
+ module BitbucketServerImport
+ class ProjectCreator
+ attr_reader :project_key, :repo_slug, :repo, :name, :namespace, :current_user, :session_data
+
+ def initialize(project_key, repo_slug, repo, name, namespace, current_user, session_data)
+ @project_key = project_key
+ @repo_slug = repo_slug
+ @repo = repo
+ @name = name
+ @namespace = namespace
+ @current_user = current_user
+ @session_data = session_data
+ end
+
+ def execute
+ ::Projects::CreateService.new(
+ current_user,
+ name: name,
+ path: name,
+ description: repo.description,
+ namespace_id: namespace.id,
+ visibility_level: repo.visibility_level,
+ import_type: 'bitbucket_server',
+ import_source: repo.browse_url,
+ import_url: repo.clone_url,
+ import_data: {
+ credentials: session_data,
+ data: { project_key: project_key, repo_slug: repo_slug }
+ },
+ skip_wiki: true
+ ).execute
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/lfs_integrity.rb b/lib/gitlab/checks/lfs_integrity.rb
index f0e5773ec3c..b816a8f00cd 100644
--- a/lib/gitlab/checks/lfs_integrity.rb
+++ b/lib/gitlab/checks/lfs_integrity.rb
@@ -1,8 +1,6 @@
module Gitlab
module Checks
class LfsIntegrity
- REV_LIST_OBJECT_LIMIT = 2_000
-
def initialize(project, newrev)
@project = project
@newrev = newrev
@@ -11,7 +9,8 @@ module Gitlab
def objects_missing?
return false unless @newrev && @project.lfs_enabled?
- new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev).new_pointers(object_limit: REV_LIST_OBJECT_LIMIT)
+ new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev)
+ .new_pointers(object_limit: ::Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT)
return false unless new_lfs_pointers.present?
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 73151e4a4c5..de189ac6dfc 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -19,6 +19,7 @@ module Gitlab
GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
].freeze
SEARCH_CONTEXT_LINES = 3
+ REV_LIST_COMMIT_LIMIT = 2_000
# In https://gitlab.com/gitlab-org/gitaly/merge_requests/698
# We copied these two prefixes into gitaly-go, so don't change these
# or things will break! (REBASE_WORKTREE_PREFIX and SQUASH_WORKTREE_PREFIX)
@@ -380,6 +381,16 @@ module Gitlab
end
end
+ def new_blobs(newrev)
+ return [] if newrev == ::Gitlab::Git::BLANK_SHA
+
+ strong_memoize("new_blobs_#{newrev}") do
+ wrapped_gitaly_errors do
+ gitaly_ref_client.list_new_blobs(newrev, REV_LIST_COMMIT_LIMIT)
+ end
+ end
+ end
+
def count_commits(options)
options = process_count_commits_options(options.dup)
diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb
index 41d58192818..8acc22e809e 100644
--- a/lib/gitlab/gitaly_client/ref_service.rb
+++ b/lib/gitlab/gitaly_client/ref_service.rb
@@ -82,6 +82,23 @@ module Gitlab
commits
end
+ def list_new_blobs(newrev, limit = 0)
+ request = Gitaly::ListNewBlobsRequest.new(
+ repository: @gitaly_repo,
+ commit_id: newrev,
+ limit: limit
+ )
+
+ response = GitalyClient
+ .call(@storage, :ref_service, :list_new_blobs, request, timeout: GitalyClient.medium_timeout)
+
+ response.flat_map do |msg|
+ # Returns an Array of Gitaly::NewBlobObject objects
+ # Available methods are: #size, #oid and #path
+ msg.new_blob_objects
+ end
+ end
+
def count_tag_names
tag_names.count
end
diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb
index 45816bee176..f7f5c5787f6 100644
--- a/lib/gitlab/import_sources.rb
+++ b/lib/gitlab/import_sources.rb
@@ -9,15 +9,16 @@ module Gitlab
# We exclude `bare_repository` here as it has no import class associated
ImportTable = [
- ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter),
- ImportSource.new('bitbucket', 'Bitbucket', Gitlab::BitbucketImport::Importer),
- ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer),
- ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer),
- ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer),
- ImportSource.new('git', 'Repo by URL', nil),
- ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer),
- ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer),
- ImportSource.new('manifest', 'Manifest file', nil)
+ ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter),
+ ImportSource.new('bitbucket', 'Bitbucket Cloud', Gitlab::BitbucketImport::Importer),
+ ImportSource.new('bitbucket_server', 'Bitbucket Server', Gitlab::BitbucketServerImport::Importer),
+ ImportSource.new('gitlab', 'GitLab.com', Gitlab::GitlabImport::Importer),
+ ImportSource.new('google_code', 'Google Code', Gitlab::GoogleCodeImport::Importer),
+ ImportSource.new('fogbugz', 'FogBugz', Gitlab::FogbugzImport::Importer),
+ ImportSource.new('git', 'Repo by URL', nil),
+ ImportSource.new('gitlab_project', 'GitLab export', Gitlab::ImportExport::Importer),
+ ImportSource.new('gitea', 'Gitea', Gitlab::LegacyGithubImport::Importer),
+ ImportSource.new('manifest', 'Manifest file', nil)
].freeze
class << self
diff --git a/lib/gitlab/kubernetes/config_map.rb b/lib/gitlab/kubernetes/config_map.rb
index 8a8a59a9cd4..9e55dae137c 100644
--- a/lib/gitlab/kubernetes/config_map.rb
+++ b/lib/gitlab/kubernetes/config_map.rb
@@ -1,15 +1,15 @@
module Gitlab
module Kubernetes
class ConfigMap
- def initialize(name, values = "")
+ def initialize(name, files)
@name = name
- @values = values
+ @files = files
end
def generate
resource = ::Kubeclient::Resource.new
resource.metadata = metadata
- resource.data = { values: values }
+ resource.data = files
resource
end
@@ -19,7 +19,7 @@ module Gitlab
private
- attr_reader :name, :values
+ attr_reader :name, :files
def metadata
{
diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb
index c4de9a398cc..d65374cc23b 100644
--- a/lib/gitlab/kubernetes/helm/api.rb
+++ b/lib/gitlab/kubernetes/helm/api.rb
@@ -9,7 +9,7 @@ module Gitlab
def install(command)
namespace.ensure_exists!
- create_config_map(command) if command.config_map?
+ create_config_map(command)
kubeclient.create_pod(command.pod_resource)
end
diff --git a/lib/gitlab/kubernetes/helm/base_command.rb b/lib/gitlab/kubernetes/helm/base_command.rb
index f9ebe53d6af..afcfd109de0 100644
--- a/lib/gitlab/kubernetes/helm/base_command.rb
+++ b/lib/gitlab/kubernetes/helm/base_command.rb
@@ -1,13 +1,7 @@
module Gitlab
module Kubernetes
module Helm
- class BaseCommand
- attr_reader :name
-
- def initialize(name)
- @name = name
- end
-
+ module BaseCommand
def pod_resource
Gitlab::Kubernetes::Helm::Pod.new(self, namespace).generate
end
@@ -24,16 +18,32 @@ module Gitlab
HEREDOC
end
- def config_map?
- false
- end
-
def pod_name
"install-#{name}"
end
+ def config_map_resource
+ Gitlab::Kubernetes::ConfigMap.new(name, files).generate
+ end
+
+ def file_names
+ files.keys
+ end
+
+ def name
+ raise "Not implemented"
+ end
+
+ def files
+ raise "Not implemented"
+ end
+
private
+ def files_dir
+ "/data/helm/#{name}/config"
+ end
+
def namespace
Gitlab::Kubernetes::Helm::NAMESPACE
end
diff --git a/lib/gitlab/kubernetes/helm/certificate.rb b/lib/gitlab/kubernetes/helm/certificate.rb
new file mode 100644
index 00000000000..598714e0874
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/certificate.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+module Gitlab
+ module Kubernetes
+ module Helm
+ class Certificate
+ INFINITE_EXPIRY = 1000.years
+ SHORT_EXPIRY = 30.minutes
+
+ attr_reader :key, :cert
+
+ def key_string
+ @key.to_s
+ end
+
+ def cert_string
+ @cert.to_pem
+ end
+
+ def self.from_strings(key_string, cert_string)
+ key = OpenSSL::PKey::RSA.new(key_string)
+ cert = OpenSSL::X509::Certificate.new(cert_string)
+ new(key, cert)
+ end
+
+ def self.generate_root
+ _issue(signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true)
+ end
+
+ def issue(expires_in: SHORT_EXPIRY)
+ self.class._issue(signed_by: self, expires_in: expires_in, certificate_authority: false)
+ end
+
+ private
+
+ def self._issue(signed_by:, expires_in:, certificate_authority:)
+ key = OpenSSL::PKey::RSA.new(4096)
+ public_key = key.public_key
+
+ subject = OpenSSL::X509::Name.parse("/C=US")
+
+ cert = OpenSSL::X509::Certificate.new
+ cert.subject = subject
+
+ cert.issuer = signed_by&.cert&.subject || subject
+
+ cert.not_before = Time.now
+ cert.not_after = expires_in.from_now
+ cert.public_key = public_key
+ cert.serial = 0x0
+ cert.version = 2
+
+ if certificate_authority
+ extension_factory = OpenSSL::X509::ExtensionFactory.new
+ extension_factory.subject_certificate = cert
+ extension_factory.issuer_certificate = cert
+ cert.add_extension(extension_factory.create_extension('subjectKeyIdentifier', 'hash'))
+ cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:TRUE', true))
+ cert.add_extension(extension_factory.create_extension('keyUsage', 'cRLSign,keyCertSign', true))
+ end
+
+ cert.sign(signed_by&.key || key, OpenSSL::Digest::SHA256.new)
+
+ new(key, cert)
+ end
+
+ def initialize(key, cert)
+ @key = key
+ @cert = cert
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/init_command.rb b/lib/gitlab/kubernetes/helm/init_command.rb
index a02e64561f6..a4546509515 100644
--- a/lib/gitlab/kubernetes/helm/init_command.rb
+++ b/lib/gitlab/kubernetes/helm/init_command.rb
@@ -1,7 +1,16 @@
module Gitlab
module Kubernetes
module Helm
- class InitCommand < BaseCommand
+ class InitCommand
+ include BaseCommand
+
+ attr_reader :name, :files
+
+ def initialize(name:, files:)
+ @name = name
+ @files = files
+ end
+
def generate_script
super + [
init_helm_command
@@ -11,7 +20,12 @@ module Gitlab
private
def init_helm_command
- "helm init >/dev/null"
+ tls_flags = "--tiller-tls" \
+ " --tiller-tls-verify --tls-ca-cert #{files_dir}/ca.pem" \
+ " --tiller-tls-cert #{files_dir}/cert.pem" \
+ " --tiller-tls-key #{files_dir}/key.pem"
+
+ "helm init #{tls_flags} >/dev/null"
end
end
end
diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb
index d2133a6d65b..9672f80687e 100644
--- a/lib/gitlab/kubernetes/helm/install_command.rb
+++ b/lib/gitlab/kubernetes/helm/install_command.rb
@@ -1,14 +1,16 @@
module Gitlab
module Kubernetes
module Helm
- class InstallCommand < BaseCommand
- attr_reader :name, :chart, :version, :repository, :values
+ class InstallCommand
+ include BaseCommand
- def initialize(name, chart:, values:, version: nil, repository: nil)
+ attr_reader :name, :files, :chart, :version, :repository
+
+ def initialize(name:, chart:, files:, version: nil, repository: nil)
@name = name
@chart = chart
@version = version
- @values = values
+ @files = files
@repository = repository
end
@@ -20,14 +22,6 @@ module Gitlab
].compact.join("\n")
end
- def config_map?
- true
- end
-
- def config_map_resource
- Gitlab::Kubernetes::ConfigMap.new(name, values).generate
- end
-
private
def init_command
@@ -39,14 +33,25 @@ module Gitlab
end
def script_command
- <<~HEREDOC
- helm install #{chart} --name #{name}#{optional_version_flag} --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE} -f /data/helm/#{name}/config/values.yaml >/dev/null
- HEREDOC
+ init_flags = "--name #{name}#{optional_tls_flags}#{optional_version_flag}" \
+ " --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE}" \
+ " -f /data/helm/#{name}/config/values.yaml"
+
+ "helm install #{chart} #{init_flags} >/dev/null\n"
end
def optional_version_flag
" --version #{version}" if version
end
+
+ def optional_tls_flags
+ return unless files.key?(:'ca.pem')
+
+ " --tls" \
+ " --tls-ca-cert #{files_dir}/ca.pem" \
+ " --tls-cert #{files_dir}/cert.pem" \
+ " --tls-key #{files_dir}/key.pem"
+ end
end
end
end
diff --git a/lib/gitlab/kubernetes/helm/pod.rb b/lib/gitlab/kubernetes/helm/pod.rb
index 1e12299eefd..6e5d3388405 100644
--- a/lib/gitlab/kubernetes/helm/pod.rb
+++ b/lib/gitlab/kubernetes/helm/pod.rb
@@ -10,10 +10,8 @@ module Gitlab
def generate
spec = { containers: [container_specification], restartPolicy: 'Never' }
- if command.config_map?
- spec[:volumes] = volumes_specification
- spec[:containers][0][:volumeMounts] = volume_mounts_specification
- end
+ spec[:volumes] = volumes_specification
+ spec[:containers][0][:volumeMounts] = volume_mounts_specification
::Kubeclient::Resource.new(metadata: metadata, spec: spec)
end
@@ -61,7 +59,7 @@ module Gitlab
name: 'configuration-volume',
configMap: {
name: "values-content-configuration-#{command.name}",
- items: [{ key: 'values', path: 'values.yaml' }]
+ items: command.file_names.map { |name| { key: name, path: name } }
}
}
]
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a414f0a90cc..ea33c603e8b 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16,6 +16,9 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+msgid " Status"
+msgstr ""
+
msgid "%d changed file"
msgid_plural "%d changed files"
msgstr[0] ""
@@ -808,6 +811,9 @@ msgstr ""
msgid "Below you will find all the groups that are public."
msgstr ""
+msgid "Bitbucket Server Import"
+msgstr ""
+
msgid "Bitbucket import"
msgstr ""
@@ -984,9 +990,6 @@ msgstr ""
msgid "CI/CD settings"
msgstr ""
-msgid "CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery."
-msgstr ""
-
msgid "CICD|Auto DevOps"
msgstr ""
@@ -999,22 +1002,13 @@ msgstr ""
msgid "CICD|Continuous deployment to production"
msgstr ""
-msgid "CICD|Deployment strategy"
+msgid "CICD|Default to Auto DevOps pipeline"
msgstr ""
-msgid "CICD|Deployment strategy needs a domain name to work correctly."
-msgstr ""
-
-msgid "CICD|Disable Auto DevOps"
-msgstr ""
-
-msgid "CICD|Enable Auto DevOps"
-msgstr ""
-
-msgid "CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}."
+msgid "CICD|Deployment strategy"
msgstr ""
-msgid "CICD|Instance default (%{state})"
+msgid "CICD|Deployment strategy needs a domain name to work correctly."
msgstr ""
msgid "CICD|Jobs"
@@ -1023,12 +1017,15 @@ msgstr ""
msgid "CICD|Learn more about Auto DevOps"
msgstr ""
-msgid "CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project."
+msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found."
msgstr ""
msgid "CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages."
msgstr ""
+msgid "CICD|instance enabled"
+msgstr ""
+
msgid "Callback URL"
msgstr ""
@@ -2316,6 +2313,9 @@ msgstr ""
msgid "Ends at (UTC)"
msgstr ""
+msgid "Enter in your Bitbucket Server URL and personal access token below"
+msgstr ""
+
msgid "Environments"
msgstr ""
@@ -2618,6 +2618,9 @@ msgstr ""
msgid "From Bitbucket"
msgstr ""
+msgid "From Bitbucket Server"
+msgstr ""
+
msgid "From FogBugz"
msgstr ""
@@ -2974,6 +2977,9 @@ msgstr ""
msgid "Import projects from Bitbucket"
msgstr ""
+msgid "Import projects from Bitbucket Server"
+msgstr ""
+
msgid "Import projects from FogBugz"
msgstr ""
@@ -2983,6 +2989,9 @@ msgstr ""
msgid "Import projects from Google Code"
msgstr ""
+msgid "Import repositories from Bitbucket Server"
+msgstr ""
+
msgid "Import repositories from GitHub"
msgstr ""
@@ -3219,6 +3228,9 @@ msgstr ""
msgid "List available repositories"
msgstr ""
+msgid "List your Bitbucket Server repositories"
+msgstr ""
+
msgid "List your GitHub repositories"
msgstr ""
@@ -3644,6 +3656,9 @@ msgstr ""
msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
msgstr ""
+msgid "Notes|Are you sure you want to cancel creating this comment?"
+msgstr ""
+
msgid "Notification events"
msgstr ""
@@ -4594,12 +4609,39 @@ msgstr ""
msgid "Search milestones"
msgstr ""
+msgid "Search or jump to…"
+msgstr ""
+
msgid "Search project"
msgstr ""
msgid "Search users"
msgstr ""
+msgid "SearchAutocomplete|All GitLab"
+msgstr ""
+
+msgid "SearchAutocomplete|Issues I've created"
+msgstr ""
+
+msgid "SearchAutocomplete|Issues assigned to me"
+msgstr ""
+
+msgid "SearchAutocomplete|Merge requests I've created"
+msgstr ""
+
+msgid "SearchAutocomplete|Merge requests assigned to me"
+msgstr ""
+
+msgid "SearchAutocomplete|in all GitLab"
+msgstr ""
+
+msgid "SearchAutocomplete|in this group"
+msgstr ""
+
+msgid "SearchAutocomplete|in this project"
+msgstr ""
+
msgid "Seconds before reseting failure information"
msgstr ""
@@ -6131,6 +6173,9 @@ msgstr ""
msgid "here"
msgstr ""
+msgid "https://your-bitbucket-server"
+msgstr ""
+
msgid "import flow"
msgstr ""
diff --git a/qa/qa/factory/resource/kubernetes_cluster.rb b/qa/qa/factory/resource/kubernetes_cluster.rb
index 1c9e5f94b22..ef2ea72b170 100644
--- a/qa/qa/factory/resource/kubernetes_cluster.rb
+++ b/qa/qa/factory/resource/kubernetes_cluster.rb
@@ -44,10 +44,11 @@ module QA
page.await_installed(:helm)
page.install!(:ingress) if @install_ingress
- page.await_installed(:ingress) if @install_ingress
page.install!(:prometheus) if @install_prometheus
- page.await_installed(:prometheus) if @install_prometheus
page.install!(:runner) if @install_runner
+
+ page.await_installed(:ingress) if @install_ingress
+ page.await_installed(:prometheus) if @install_prometheus
page.await_installed(:runner) if @install_runner
end
end
diff --git a/qa/qa/page/project/operations/kubernetes/show.rb b/qa/qa/page/project/operations/kubernetes/show.rb
index 4923304133e..e831edeb89e 100644
--- a/qa/qa/page/project/operations/kubernetes/show.rb
+++ b/qa/qa/page/project/operations/kubernetes/show.rb
@@ -16,6 +16,7 @@ module QA
def install!(application_name)
within(".js-cluster-application-row-#{application_name}") do
+ page.has_button?('Install', wait: 30)
click_on 'Install'
end
end
diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb
index 0f739f61db9..752d3d93407 100644
--- a/qa/qa/page/project/settings/ci_cd.rb
+++ b/qa/qa/page/project/settings/ci_cd.rb
@@ -12,9 +12,9 @@ module QA # rubocop:disable Naming/FileName
end
view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do
- element :enable_auto_devops_field, 'radio_button :enabled'
+ element :enable_auto_devops_field, 'check_box :enabled'
element :domain_field, 'text_field :domain'
- element :enable_auto_devops_button, "%strong= s_('CICD|Enable Auto DevOps')"
+ element :enable_auto_devops_button, "%strong= s_('CICD|Default to Auto DevOps pipeline')"
element :domain_input, "%strong= _('Domain')"
element :save_changes_button, "submit _('Save changes')"
end
@@ -33,7 +33,7 @@ module QA # rubocop:disable Naming/FileName
def enable_auto_devops_with_domain(domain)
expand_section(:autodevops_settings) do
- choose 'Enable Auto DevOps'
+ check 'Default to Auto DevOps pipeline'
fill_in 'Domain', with: domain
click_on 'Save changes'
end
diff --git a/spec/controllers/import/bitbucket_server_controller_spec.rb b/spec/controllers/import/bitbucket_server_controller_spec.rb
new file mode 100644
index 00000000000..5024ef71771
--- /dev/null
+++ b/spec/controllers/import/bitbucket_server_controller_spec.rb
@@ -0,0 +1,154 @@
+require 'spec_helper'
+
+describe Import::BitbucketServerController do
+ let(:user) { create(:user) }
+ let(:project_key) { 'test-project' }
+ let(:repo_slug) { 'some-repo' }
+ let(:client) { instance_double(BitbucketServer::Client) }
+
+ def assign_session_tokens
+ session[:bitbucket_server_url] = 'http://localhost:7990'
+ session[:bitbucket_server_username] = 'bitbucket'
+ session[:bitbucket_server_personal_access_token] = 'some-token'
+ end
+
+ before do
+ sign_in(user)
+ allow(controller).to receive(:bitbucket_server_import_enabled?).and_return(true)
+ end
+
+ describe 'GET new' do
+ render_views
+
+ it 'shows the input form' do
+ get :new
+
+ expect(response.body).to have_text('Bitbucket Server URL')
+ end
+ end
+
+ describe 'POST create' do
+ before do
+ allow(controller).to receive(:bitbucket_client).and_return(client)
+ repo = double(name: 'my-project')
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(repo)
+ assign_session_tokens
+ end
+
+ set(:project) { create(:project) }
+
+ it 'returns the new project' do
+ allow(Gitlab::BitbucketServerImport::ProjectCreator)
+ .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything)
+ .and_return(double(execute: project))
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns an error when an invalid project key is used' do
+ post :create, project: 'some&project'
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when an invalid repository slug is used' do
+ post :create, project: 'some-project', repository: 'try*this'
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when the project cannot be found' do
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(nil)
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it 'returns an error when the project cannot be saved' do
+ allow(Gitlab::BitbucketServerImport::ProjectCreator)
+ .to receive(:new).with(project_key, repo_slug, anything, 'my-project', user.namespace, user, anything)
+ .and_return(double(execute: build(:project)))
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
+ it "returns an error when the server can't be contacted" do
+ expect(client).to receive(:repo).with(project_key, repo_slug).and_raise(BitbucketServer::Client::ServerError)
+
+ post :create, project: project_key, repository: repo_slug, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+ end
+
+ describe 'POST configure' do
+ let(:token) { 'token' }
+ let(:username) { 'bitbucket-user' }
+ let(:url) { 'http://localhost:7990/bitbucket' }
+
+ it 'clears out existing session' do
+ post :configure
+
+ expect(session[:bitbucket_server_url]).to be_nil
+ expect(session[:bitbucket_server_username]).to be_nil
+ expect(session[:bitbucket_server_personal_access_token]).to be_nil
+
+ expect(response).to have_gitlab_http_status(302)
+ expect(response).to redirect_to(status_import_bitbucket_server_path)
+ end
+
+ it 'sets the session variables' do
+ post :configure, personal_access_token: token, bitbucket_username: username, bitbucket_server_url: url
+
+ expect(session[:bitbucket_server_url]).to eq(url)
+ expect(session[:bitbucket_server_username]).to eq(username)
+ expect(session[:bitbucket_server_personal_access_token]).to eq(token)
+ expect(response).to have_gitlab_http_status(302)
+ expect(response).to redirect_to(status_import_bitbucket_server_path)
+ end
+ end
+
+ describe 'GET status' do
+ render_views
+
+ before do
+ allow(controller).to receive(:bitbucket_client).and_return(client)
+
+ @repo = double(slug: 'vim', project_key: 'asd', full_name: 'asd/vim', "valid?" => true, project_name: 'asd', browse_url: 'http://test', name: 'vim')
+ @invalid_repo = double(slug: 'invalid', project_key: 'foobar', full_name: 'asd/foobar', "valid?" => false, browse_url: 'http://bad-repo')
+ assign_session_tokens
+ end
+
+ it 'assigns repository categories' do
+ created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id, import_source: 'foo/bar', import_status: 'finished')
+ expect(client).to receive(:repos).and_return([@repo, @invalid_repo])
+
+ get :status
+
+ expect(assigns(:already_added_projects)).to eq([created_project])
+ expect(assigns(:repos)).to eq([@repo])
+ expect(assigns(:incompatible_repos)).to eq([@invalid_repo])
+ end
+ end
+
+ describe 'GET jobs' do
+ before do
+ assign_session_tokens
+ end
+
+ it 'returns a list of imported projects' do
+ created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id)
+
+ get :jobs
+
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['id']).to eq(created_project.id)
+ expect(json_response.first['import_status']).to eq('none')
+ end
+ end
+end
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
index 1ce7e84bef9..58f2817c7cc 100644
--- a/spec/controllers/projects/todos_controller_spec.rb
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -5,10 +5,29 @@ describe Projects::TodosController do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:parent) { project }
+
+ shared_examples 'project todos actions' do
+ it_behaves_like 'todos actions'
+
+ context 'when not authorized for resource' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+ project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
+ sign_in(user)
+ end
+
+ it "doesn't create todo" do
+ expect { post_create }.not_to change { user.todos.count }
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
context 'Issues' do
describe 'POST create' do
- def go
+ def post_create
post :create,
namespace_id: project.namespace,
project_id: project,
@@ -17,66 +36,13 @@ describe Projects::TodosController do
format: 'html'
end
- context 'when authorized' do
- before do
- sign_in(user)
- project.add_developer(user)
- end
-
- it 'creates todo for issue' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_gitlab_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for issue that user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'does not create todo for issue when user not logged in' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(302)
- end
- end
-
- context 'when not authorized for issue' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect { go }.not_to change { user.todos.count }
- expect(response).to have_gitlab_http_status(404)
- end
- end
+ it_behaves_like 'project todos actions'
end
end
context 'Merge Requests' do
describe 'POST create' do
- def go
+ def post_create
post :create,
namespace_id: project.namespace,
project_id: project,
@@ -85,60 +51,7 @@ describe Projects::TodosController do
format: 'html'
end
- context 'when authorized' do
- before do
- sign_in(user)
- project.add_developer(user)
- end
-
- it 'creates todo for merge request' do
- expect do
- go
- end.to change { user.todos.count }.by(1)
-
- expect(response).to have_gitlab_http_status(200)
- end
-
- it 'returns todo path and pending count' do
- go
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
- end
- end
-
- context 'when not authorized for project' do
- it 'does not create todo for merge request user has no access to' do
- sign_in(user)
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'does not create todo for merge request user has no access to' do
- expect do
- go
- end.to change { user.todos.count }.by(0)
-
- expect(response).to have_gitlab_http_status(302)
- end
- end
-
- context 'when not authorized for merge_request' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
- sign_in(user)
- end
-
- it "doesn't create todo" do
- expect { go }.not_to change { user.todos.count }
- expect(response).to have_gitlab_http_status(404)
- end
- end
+ it_behaves_like 'project todos actions'
end
end
end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index 3e4277e4ba6..7c4a440b9a9 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -32,11 +32,21 @@ FactoryBot.define do
updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago
end
- factory :clusters_applications_ingress, class: Clusters::Applications::Ingress
- factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus
- factory :clusters_applications_runner, class: Clusters::Applications::Runner
+ factory :clusters_applications_ingress, class: Clusters::Applications::Ingress do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
+ factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
+ factory :clusters_applications_runner, class: Clusters::Applications::Runner do
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
+ end
+
factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter do
oauth_application factory: :oauth_application
+ cluster factory: %i(cluster with_installed_helm provided_by_gcp)
end
end
end
diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb
index 0430762c1ff..bbeba8ce8b9 100644
--- a/spec/factories/clusters/clusters.rb
+++ b/spec/factories/clusters/clusters.rb
@@ -36,5 +36,9 @@ FactoryBot.define do
trait :production_environment do
sequence(:environment_scope) { |n| "production#{n}/*" }
end
+
+ trait :with_installed_helm do
+ application_helm factory: %i(clusters_applications_helm installed)
+ end
end
end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index 94f8caedfa6..14486c80341 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -1,8 +1,8 @@
FactoryBot.define do
factory :todo do
project
- author { project.creator }
- user { project.creator }
+ author { project&.creator || user }
+ user { project&.creator || user }
target factory: :issue
action { Todo::ASSIGNED }
diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb
index de406d7d966..238ea2a25bd 100644
--- a/spec/features/admin/admin_labels_spec.rb
+++ b/spec/features/admin/admin_labels_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe 'admin issues labels' do
it 'deletes all labels', :js do
page.within '.labels' do
- page.all('.btn-remove').each do |remove|
+ page.all('.remove-row').each do |remove|
accept_confirm { remove.click }
wait_for_requests
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index a852ca689e7..af1c153dec8 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -175,7 +175,7 @@ describe 'Admin updates settings' do
it 'Change CI/CD settings' do
page.within('.as-ci-cd') do
- check 'Enabled Auto DevOps for projects by default'
+ check 'Default to Auto DevOps pipeline for all projects'
fill_in 'Auto devops domain', with: 'domain.com'
click_button 'Save changes'
end
diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb
index a7d86bd4560..f4d0f82d248 100644
--- a/spec/features/dashboard/active_tab_spec.rb
+++ b/spec/features/dashboard/active_tab_spec.rb
@@ -35,10 +35,6 @@ RSpec.describe 'Dashboard Active Tab', :js do
context 'on instance statistics' do
subject { visit instance_statistics_root_path }
- before do
- stub_application_setting(instance_statistics_visibility_private: false)
- end
-
it 'shows Instance Statistics` as active' do
subject
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index bf4d5396df9..2d268ecab58 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -342,8 +342,9 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
- it 'shows jump to next discussion button' do
- expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
+ it 'shows jump to next discussion button, apart from the last one' do
+ expect(page).to have_selector('.discussion-reply-holder', count: 2)
+ expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1)
end
it 'displays next discussion even if hidden' do
diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb
index a65ca662350..71d715237f5 100644
--- a/spec/features/projects/clusters/applications_spec.rb
+++ b/spec/features/projects/clusters/applications_spec.rb
@@ -46,12 +46,14 @@ describe 'Clusters Applications', :js do
end
end
- it 'he sees status transition' do
+ it 'they see status transition' do
page.within('.js-cluster-application-row-helm') do
# FE sends request and gets the response, then the buttons is "Install"
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
+ wait_until_helm_created!
+
Clusters::Cluster.last.application_helm.make_installing!
# FE starts polling and update the buttons to "Installing"
@@ -83,7 +85,7 @@ describe 'Clusters Applications', :js do
end
end
- it 'he sees status transition' do
+ it 'they see status transition' do
page.within('.js-cluster-application-row-ingress') do
# FE sends request and gets the response, then the buttons is "Install"
expect(page).to have_css('.js-cluster-application-install-button[disabled]')
@@ -116,4 +118,14 @@ describe 'Clusters Applications', :js do
end
end
end
+
+ def wait_until_helm_created!
+ retries = 0
+
+ while Clusters::Cluster.last.application_helm.nil?
+ raise "Timed out waiting for helm application to be created in DB" if (retries += 1) > 3
+
+ sleep(1)
+ end
+ end
end
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 742ecf82c38..30b0a5578ea 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -8,7 +8,6 @@ describe "Projects > Settings > Pipelines settings" do
before do
sign_in(user)
project.add_role(user, role)
- create(:project_auto_devops, project: project)
end
context 'for developer' do
@@ -61,19 +60,58 @@ describe "Projects > Settings > Pipelines settings" do
end
describe 'Auto DevOps' do
- it 'update auto devops settings' do
- visit project_settings_ci_cd_path(project)
+ context 'when auto devops is turned on instance-wide' do
+ before do
+ stub_application_setting(auto_devops_enabled: true)
+ end
+
+ it 'auto devops is on by default and can be manually turned off' do
+ visit project_settings_ci_cd_path(project)
- page.within '#autodevops-settings' do
- fill_in('project_auto_devops_attributes_domain', with: 'test.com')
- page.choose('project_auto_devops_attributes_enabled_false')
- click_on 'Save changes'
+ page.within '#autodevops-settings' do
+ expect(find_field('project_auto_devops_attributes_enabled')).to be_checked
+ expect(page).to have_content('instance enabled')
+ uncheck 'Default to Auto DevOps pipeline'
+ click_on 'Save changes'
+ end
+
+ expect(page.status_code).to eq(200)
+ expect(project.auto_devops).to be_present
+ expect(project.auto_devops).not_to be_enabled
+
+ page.within '#autodevops-settings' do
+ expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked
+ expect(page).not_to have_content('instance enabled')
+ end
end
+ end
- expect(page.status_code).to eq(200)
- expect(project.auto_devops).to be_present
- expect(project.auto_devops).not_to be_enabled
- expect(project.auto_devops.domain).to eq('test.com')
+ context 'when auto devops is not turned on instance-wide' do
+ before do
+ stub_application_setting(auto_devops_enabled: false)
+ end
+
+ it 'auto devops is off by default and can be manually turned on' do
+ visit project_settings_ci_cd_path(project)
+
+ page.within '#autodevops-settings' do
+ expect(page).not_to have_content('instance enabled')
+ expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked
+ check 'Default to Auto DevOps pipeline'
+ fill_in('project_auto_devops_attributes_domain', with: 'test.com')
+ click_on 'Save changes'
+ end
+
+ expect(page.status_code).to eq(200)
+ expect(project.auto_devops).to be_present
+ expect(project.auto_devops).to be_enabled
+ expect(project.auto_devops.domain).to eq('test.com')
+
+ page.within '#autodevops-settings' do
+ expect(find_field('project_auto_devops_attributes_enabled')).to be_checked
+ expect(page).not_to have_content('instance enabled')
+ end
+ end
end
context 'when there is a cluster with ingress and external_ip' do
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index a9128104b87..af38f77c0c6 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -62,10 +62,6 @@ describe 'User uses header search field' do
end
end
- it 'contains location badge' do
- expect(page).to have_selector('.has-location-badge')
- end
-
context 'when clicking the search field', :js do
before do
page.find('#search').click
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 4db73fccfb6..48f8b8bf77e 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do
visit user_path(user)
- expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
+ expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=90"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index 9747b9402a7..7f7cfb2cb98 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -5,12 +5,50 @@ describe TodosFinder do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
+ let(:issue) { create(:issue, project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
let(:finder) { described_class }
before do
group.add_developer(user)
end
+ describe '#execute' do
+ context 'filtering' do
+ let!(:todo1) { create(:todo, user: user, project: project, target: issue) }
+ let!(:todo2) { create(:todo, user: user, group: group, target: merge_request) }
+
+ it 'returns correct todos when filtered by a project' do
+ todos = finder.new(user, { project_id: project.id }).execute
+
+ expect(todos).to match_array([todo1])
+ end
+
+ it 'returns correct todos when filtered by a group' do
+ todos = finder.new(user, { group_id: group.id }).execute
+
+ expect(todos).to match_array([todo1, todo2])
+ end
+
+ it 'returns correct todos when filtered by a type' do
+ todos = finder.new(user, { type: 'Issue' }).execute
+
+ expect(todos).to match_array([todo1])
+ end
+
+ context 'with subgroups', :nested_groups do
+ let(:subgroup) { create(:group, parent: group) }
+ let!(:todo3) { create(:todo, user: user, group: subgroup, target: issue) }
+
+ it 'returns todos from subgroups when filtered by a group' do
+ todos = finder.new(user, { group_id: group.id }).execute
+
+ expect(todos).to match_array([todo1, todo2, todo3])
+ end
+ end
+ end
+ end
+
describe '#sort' do
context 'by date' do
let!(:todo1) { create(:todo, user: user, project: project) }
diff --git a/spec/fixtures/importers/bitbucket_server/activities.json b/spec/fixtures/importers/bitbucket_server/activities.json
new file mode 100644
index 00000000000..09adfca9f31
--- /dev/null
+++ b/spec/fixtures/importers/bitbucket_server/activities.json
@@ -0,0 +1,1121 @@
+{
+ "isLastPage": true,
+ "limit": 25,
+ "size": 8,
+ "start": 0,
+ "values": [
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530164016725,
+ "id": 11,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [
+ {
+ "anchor": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "createdDate": 1530164016725,
+ "id": 11,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "text": "Ok",
+ "type": "COMMENT",
+ "updatedDate": 1530164016725,
+ "version": 0
+ },
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "createdDate": 1530164026000,
+ "id": 1,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true,
+ "transitionable": true
+ },
+ "state": "OPEN",
+ "text": "here's a task"
+ }
+ ],
+ "text": "Ok",
+ "updatedDate": 1530164016725,
+ "version": 0
+ },
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530165543990,
+ "id": 12,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "hi",
+ "updatedDate": 1530165543990,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530164013718,
+ "id": 10,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Hello world",
+ "updatedDate": 1530164013718,
+ "version": 0
+ },
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530165549932,
+ "id": 13,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "hello",
+ "updatedDate": 1530165549932,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530161499144,
+ "id": 9,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "is this a new line?",
+ "updatedDate": 1530161499144,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "TO",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 1,
+ "lineType": "ADDED",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530161499144,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 11,
+ "segments": [
+ {
+ "lines": [
+ {
+ "commentIds": [
+ 9
+ ],
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 9,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 19,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530053198463,
+ "id": 7,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "What about this line?",
+ "updatedDate": 1530053198463,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "FROM",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 9,
+ "lineType": "CONTEXT",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530053198463,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 12,
+ "segments": [
+ {
+ "lines": [
+ {
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "commentIds": [
+ 7
+ ],
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ },
+ {
+ "destination": 12,
+ "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)",
+ "source": 10,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 10,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 14,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1530143330513,
+ "id": 8,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "How about this?",
+ "updatedDate": 1530143330513,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530053193795,
+ "id": 6,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "It does.",
+ "updatedDate": 1530053193795,
+ "version": 0
+ }
+ ],
+ "createdDate": 1530053187904,
+ "id": 5,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Does this line make sense?",
+ "updatedDate": 1530053187904,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "commentAnchor": {
+ "diffType": "EFFECTIVE",
+ "fileType": "FROM",
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "line": 3,
+ "lineType": "CONTEXT",
+ "orphaned": false,
+ "path": "CHANGELOG.md",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "createdDate": 1530053187904,
+ "diff": {
+ "destination": {
+ "components": [
+ "CHANGELOG.md"
+ ],
+ "extension": "md",
+ "name": "CHANGELOG.md",
+ "parent": "",
+ "toString": "CHANGELOG.md"
+ },
+ "hunks": [
+ {
+ "destinationLine": 1,
+ "destinationSpan": 12,
+ "segments": [
+ {
+ "lines": [
+ {
+ "destination": 1,
+ "line": "# Edit 1",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 2,
+ "line": "",
+ "source": 1,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "ADDED"
+ },
+ {
+ "lines": [
+ {
+ "destination": 3,
+ "line": "# ChangeLog",
+ "source": 1,
+ "truncated": false
+ },
+ {
+ "destination": 4,
+ "line": "",
+ "source": 2,
+ "truncated": false
+ },
+ {
+ "commentIds": [
+ 5
+ ],
+ "destination": 5,
+ "line": "This log summarizes the changes in each released version of rouge. The versioning scheme",
+ "source": 3,
+ "truncated": false
+ },
+ {
+ "destination": 6,
+ "line": "we use is semver, although we will often release new lexers in minor versions, as a",
+ "source": 4,
+ "truncated": false
+ },
+ {
+ "destination": 7,
+ "line": "practical matter.",
+ "source": 5,
+ "truncated": false
+ },
+ {
+ "destination": 8,
+ "line": "",
+ "source": 6,
+ "truncated": false
+ },
+ {
+ "destination": 9,
+ "line": "## version TBD: (unreleased)",
+ "source": 7,
+ "truncated": false
+ },
+ {
+ "destination": 10,
+ "line": "",
+ "source": 8,
+ "truncated": false
+ },
+ {
+ "destination": 11,
+ "line": "* General",
+ "source": 9,
+ "truncated": false
+ },
+ {
+ "destination": 12,
+ "line": " * Load pastie theme ([#809](https://github.com/jneen/rouge/pull/809) by rramsden)",
+ "source": 10,
+ "truncated": false
+ }
+ ],
+ "truncated": false,
+ "type": "CONTEXT"
+ }
+ ],
+ "sourceLine": 1,
+ "sourceSpan": 10,
+ "truncated": false
+ }
+ ],
+ "properties": {
+ "current": true,
+ "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be"
+ },
+ "source": null,
+ "truncated": false
+ },
+ "id": 12,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529813304164,
+ "id": 4,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "Hello world",
+ "updatedDate": 1529813304164,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529813304164,
+ "id": 11,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "MERGED",
+ "commit": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "authorTimestamp": 1529727872000,
+ "committer": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "committerTimestamp": 1529727872000,
+ "displayId": "839fa9a2d43",
+ "id": "839fa9a2d434eb697815b8fcafaecc51accfdbbc",
+ "message": "Merge pull request #1 in TEST/rouge from root/CHANGELOGmd-1529725646923 to master\n\n* commit '66fbe6a097803f0acb7342b19563f710657ce5a2':\n CHANGELOG.md edited online with Bitbucket",
+ "parents": [
+ {
+ "author": {
+ "emailAddress": "dblessing@users.noreply.github.com",
+ "name": "Drew Blessing"
+ },
+ "authorTimestamp": 1529604583000,
+ "committer": {
+ "emailAddress": "noreply@github.com",
+ "name": "GitHub"
+ },
+ "committerTimestamp": 1529604583000,
+ "displayId": "c5f4288162e",
+ "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab",
+ "message": "Merge pull request #949 from jneen/dblessing-patch-1\n\nAdd 'obj-c', 'obj_c' as ObjectiveC aliases",
+ "parents": [
+ {
+ "displayId": "ea7675f741e",
+ "id": "ea7675f741ee28f3f177ff32a9bde192742ffc59"
+ },
+ {
+ "displayId": "386b95a977b",
+ "id": "386b95a977b331e267497aa5206861774656f0c5"
+ }
+ ]
+ },
+ {
+ "author": {
+ "emailAddress": "test.user@example.com",
+ "name": "root"
+ },
+ "authorTimestamp": 1529725651000,
+ "committer": {
+ "emailAddress": "test.user@example.com",
+ "name": "root"
+ },
+ "committerTimestamp": 1529725651000,
+ "displayId": "66fbe6a0978",
+ "id": "66fbe6a097803f0acb7342b19563f710657ce5a2",
+ "message": "CHANGELOG.md edited online with Bitbucket",
+ "parents": [
+ {
+ "displayId": "c5f4288162e",
+ "id": "c5f4288162e2e6218180779c7f6ac1735bb56eab"
+ }
+ ]
+ }
+ ]
+ },
+ "createdDate": 1529727872302,
+ "id": 7,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [
+ {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529813297478,
+ "id": 3,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "This is a thread",
+ "updatedDate": 1529813297478,
+ "version": 0
+ }
+ ],
+ "createdDate": 1529725692591,
+ "id": 2,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "What about this?",
+ "updatedDate": 1529725692591,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529725692591,
+ "id": 6,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "COMMENTED",
+ "comment": {
+ "author": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ },
+ "comments": [],
+ "createdDate": 1529725685910,
+ "id": 1,
+ "permittedOperations": {
+ "deletable": true,
+ "editable": true
+ },
+ "properties": {
+ "repositoryId": 1
+ },
+ "tasks": [],
+ "text": "This is a test.\n\n[analyze.json](attachment:1/1f32f09d97%2Fanalyze.json)\n",
+ "updatedDate": 1529725685910,
+ "version": 0
+ },
+ "commentAction": "ADDED",
+ "createdDate": 1529725685910,
+ "id": 5,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ },
+ {
+ "action": "OPENED",
+ "createdDate": 1529725657542,
+ "id": 4,
+ "user": {
+ "active": true,
+ "displayName": "root",
+ "emailAddress": "test.user@example.com",
+ "id": 1,
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name": "root",
+ "slug": "root",
+ "type": "NORMAL"
+ }
+ }
+ ]
+}
diff --git a/spec/fixtures/importers/bitbucket_server/pull_request.json b/spec/fixtures/importers/bitbucket_server/pull_request.json
new file mode 100644
index 00000000000..6c7fcf3b04c
--- /dev/null
+++ b/spec/fixtures/importers/bitbucket_server/pull_request.json
@@ -0,0 +1,146 @@
+{
+ "author":{
+ "approved":false,
+ "role":"AUTHOR",
+ "status":"UNAPPROVED",
+ "user":{
+ "active":true,
+ "displayName":"root",
+ "emailAddress":"joe.montana@49ers.com",
+ "id":1,
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/users/root"
+ }
+ ]
+ },
+ "name":"root",
+ "slug":"root",
+ "type":"NORMAL"
+ }
+ },
+ "closed":true,
+ "closedDate":1530600648850,
+ "createdDate":1530600635690,
+ "description":"Test",
+ "fromRef":{
+ "displayId":"root/CODE_OF_CONDUCTmd-1530600625006",
+ "id":"refs/heads/root/CODE_OF_CONDUCTmd-1530600625006",
+ "latestCommit":"074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8",
+ "repository":{
+ "forkable":true,
+ "id":1,
+ "links":{
+ "clone":[
+ {
+ "href":"http://root@localhost:7990/scm/test/rouge.git",
+ "name":"http"
+ },
+ {
+ "href":"ssh://git@localhost:7999/test/rouge.git",
+ "name":"ssh"
+ }
+ ],
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ },
+ "name":"rouge",
+ "project":{
+ "description":"Test",
+ "id":1,
+ "key":"TEST",
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST"
+ }
+ ]
+ },
+ "name":"test",
+ "public":false,
+ "type":"NORMAL"
+ },
+ "public":false,
+ "scmId":"git",
+ "slug":"rouge",
+ "state":"AVAILABLE",
+ "statusMessage":"Available"
+ }
+ },
+ "id":7,
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/pull-requests/7"
+ }
+ ]
+ },
+ "locked":false,
+ "open":false,
+ "participants":[
+
+ ],
+ "properties":{
+ "commentCount":1,
+ "openTaskCount":0,
+ "resolvedTaskCount":0
+ },
+ "reviewers":[
+
+ ],
+ "state":"MERGED",
+ "title":"Added a new line",
+ "toRef":{
+ "displayId":"master",
+ "id":"refs/heads/master",
+ "latestCommit":"839fa9a2d434eb697815b8fcafaecc51accfdbbc",
+ "repository":{
+ "forkable":true,
+ "id":1,
+ "links":{
+ "clone":[
+ {
+ "href":"http://root@localhost:7990/scm/test/rouge.git",
+ "name":"http"
+ },
+ {
+ "href":"ssh://git@localhost:7999/test/rouge.git",
+ "name":"ssh"
+ }
+ ],
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ },
+ "name":"rouge",
+ "project":{
+ "description":"Test",
+ "id":1,
+ "key":"TEST",
+ "links":{
+ "self":[
+ {
+ "href":"http://localhost:7990/projects/TEST"
+ }
+ ]
+ },
+ "name":"test",
+ "public":false,
+ "type":"NORMAL"
+ },
+ "public":false,
+ "scmId":"git",
+ "slug":"rouge",
+ "state":"AVAILABLE",
+ "statusMessage":"Available"
+ }
+ },
+ "updatedDate":1530600648850,
+ "version":2
+}
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 77410e0070c..f76ed4bfda4 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -21,6 +21,27 @@ describe IssuablesHelper do
end
end
+ describe '#group_dropdown_label' do
+ let(:group) { create(:group) }
+ let(:default) { 'default label' }
+
+ it 'returns default group label when group_id is nil' do
+ expect(group_dropdown_label(nil, default)).to eq('default label')
+ end
+
+ it 'returns "any group" when group_id is 0' do
+ expect(group_dropdown_label('0', default)).to eq('Any group')
+ end
+
+ it 'returns group full path when a group was found for the provided id' do
+ expect(group_dropdown_label(group.id, default)).to eq(group.full_name)
+ end
+
+ it 'returns default label when a group was not found for the provided id' do
+ expect(group_dropdown_label(9999, default)).to eq('default label')
+ end
+ end
+
describe '#issuable_labels_tooltip' do
it 'returns label text with no labels' do
expect(issuable_labels_tooltip([])).to eq("Labels")
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 343e140f5fb..234690e742b 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -31,6 +31,44 @@ describe NamespacesHelper do
expect(options).to include(user.name)
end
+ it 'avoids duplicate groups when extra_group is used' do
+ allow(helper).to receive(:current_user).and_return(admin)
+
+ options = helper.namespaces_options(user_group.id, display_path: true, extra_group: build(:group, name: admin_group.name))
+
+ expect(options.scan("data-name=\"#{admin_group.name}\"").count).to eq(1)
+ expect(options).to include(admin_group.name)
+ end
+
+ it 'selects existing group' do
+ allow(helper).to receive(:current_user).and_return(admin)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group)
+
+ expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"")
+ expect(options).to include(admin_group.name)
+ end
+
+ it 'selects the new group by default' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: 'new-group'))
+
+ expect(options).to include(user_group.name)
+ expect(options).not_to include(admin_group.name)
+ expect(options).to include("selected=\"selected\" value=\"-1\"")
+ end
+
+ it 'falls back to current user selection' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: build(:group, name: admin_group.name))
+
+ expect(options).to include(user_group.name)
+ expect(options).not_to include(admin_group.name)
+ expect(options).to include("selected=\"selected\" value=\"#{user.namespace.id}\"")
+ end
+
it 'returns only groups if groups_only option is true' do
allow(helper).to receive(:current_user).and_return(user)
diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js
index 38ae5b7e00c..dcb1c781591 100644
--- a/spec/javascripts/autosave_spec.js
+++ b/spec/javascripts/autosave_spec.js
@@ -59,12 +59,10 @@ describe('Autosave', () => {
Autosave.prototype.restore.call(autosave);
- expect(
- field.trigger,
- ).toHaveBeenCalled();
+ expect(field.trigger).toHaveBeenCalled();
});
- it('triggers native event', (done) => {
+ it('triggers native event', done => {
autosave.field.get(0).addEventListener('change', () => {
done();
});
@@ -81,9 +79,7 @@ describe('Autosave', () => {
it('does not trigger event', () => {
spyOn(field, 'trigger').and.callThrough();
- expect(
- field.trigger,
- ).not.toHaveBeenCalled();
+ expect(field.trigger).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js
index 7a32e84bced..b6c61e7bad7 100644
--- a/spec/javascripts/boards/issue_card_spec.js
+++ b/spec/javascripts/boards/issue_card_spec.js
@@ -69,109 +69,100 @@ describe('Issue card component', () => {
});
it('renders issue title', () => {
- expect(
- component.$el.querySelector('.board-card-title').textContent,
- ).toContain(issue.title);
+ expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title);
});
it('includes issue base in link', () => {
- expect(
- component.$el.querySelector('.board-card-title a').getAttribute('href'),
- ).toContain('/test');
+ expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain(
+ '/test',
+ );
});
it('includes issue title on link', () => {
- expect(
- component.$el.querySelector('.board-card-title a').getAttribute('title'),
- ).toBe(issue.title);
+ expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe(
+ issue.title,
+ );
});
it('does not render confidential icon', () => {
- expect(
- component.$el.querySelector('.fa-eye-flash'),
- ).toBeNull();
+ expect(component.$el.querySelector('.fa-eye-flash')).toBeNull();
});
- it('renders confidential icon', (done) => {
+ it('renders confidential icon', done => {
component.issue.confidential = true;
Vue.nextTick(() => {
- expect(
- component.$el.querySelector('.confidential-icon'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.confidential-icon')).not.toBeNull();
done();
});
});
it('renders issue ID with #', () => {
- expect(
- component.$el.querySelector('.board-card-number').textContent,
- ).toContain(`#${issue.id}`);
+ expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`);
});
describe('assignee', () => {
it('does not render assignee', () => {
- expect(
- component.$el.querySelector('.board-card-assignee .avatar'),
- ).toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull();
});
describe('exists', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.assignees = [user];
Vue.nextTick(() => done());
});
it('renders assignee', () => {
- expect(
- component.$el.querySelector('.board-card-assignee .avatar'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull();
});
it('sets title', () => {
expect(
- component.$el.querySelector('.board-card-assignee img').getAttribute('data-original-title'),
+ component.$el
+ .querySelector('.board-card-assignee img')
+ .getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
});
it('sets users path', () => {
- expect(
- component.$el.querySelector('.board-card-assignee a').getAttribute('href'),
- ).toBe('/test');
+ expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe(
+ '/test',
+ );
});
it('renders avatar', () => {
- expect(
- component.$el.querySelector('.board-card-assignee img'),
- ).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
});
});
describe('assignee default avatar', () => {
- beforeEach((done) => {
- component.issue.assignees = [new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- }, 'default_avatar')];
+ beforeEach(done => {
+ component.issue.assignees = [
+ new ListAssignee(
+ {
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ },
+ 'default_avatar',
+ ),
+ ];
Vue.nextTick(done);
});
it('displays defaults avatar if users avatar is null', () => {
- expect(
- component.$el.querySelector('.board-card-assignee img'),
- ).not.toBeNull();
- expect(
- component.$el.querySelector('.board-card-assignee img').getAttribute('src'),
- ).toBe('default_avatar');
+ expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
+ expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe(
+ 'default_avatar?width=20',
+ );
});
});
});
describe('multiple assignees', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.assignees = [
user,
new ListAssignee({
@@ -191,7 +182,8 @@ describe('Issue card component', () => {
name: 'user4',
username: 'user4',
avatar: 'test_image',
- })];
+ }),
+ ];
Vue.nextTick(() => done());
});
@@ -201,26 +193,30 @@ describe('Issue card component', () => {
});
describe('more than four assignees', () => {
- beforeEach((done) => {
- component.issue.assignees.push(new ListAssignee({
- id: 5,
- name: 'user5',
- username: 'user5',
- avatar: 'test_image',
- }));
+ beforeEach(done => {
+ component.issue.assignees.push(
+ new ListAssignee({
+ id: 5,
+ name: 'user5',
+ username: 'user5',
+ avatar: 'test_image',
+ }),
+ );
Vue.nextTick(() => done());
});
it('renders more avatar counter', () => {
- expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('+2');
+ expect(
+ component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
+ ).toEqual('+2');
});
it('renders three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
});
- it('renders 99+ avatar counter', (done) => {
+ it('renders 99+ avatar counter', done => {
for (let i = 5; i < 104; i += 1) {
const u = new ListAssignee({
id: i,
@@ -232,7 +228,9 @@ describe('Issue card component', () => {
}
Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('99+');
+ expect(
+ component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
+ ).toEqual('99+');
done();
});
});
@@ -240,59 +238,51 @@ describe('Issue card component', () => {
});
describe('labels', () => {
- beforeEach((done) => {
+ beforeEach(done => {
component.issue.addLabel(label1);
Vue.nextTick(() => done());
});
it('renders list label', () => {
- expect(
- component.$el.querySelectorAll('.badge').length,
- ).toBe(2);
+ expect(component.$el.querySelectorAll('.badge').length).toBe(2);
});
it('renders label', () => {
const nodes = [];
- component.$el.querySelectorAll('.badge').forEach((label) => {
+ component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.getAttribute('data-original-title'));
});
- expect(
- nodes.includes(label1.description),
- ).toBe(true);
+ expect(nodes.includes(label1.description)).toBe(true);
});
it('sets label description as title', () => {
- expect(
- component.$el.querySelector('.badge').getAttribute('data-original-title'),
- ).toContain(label1.description);
+ expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain(
+ label1.description,
+ );
});
it('sets background color of button', () => {
const nodes = [];
- component.$el.querySelectorAll('.badge').forEach((label) => {
+ component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.style.backgroundColor);
});
- expect(
- nodes.includes(label1.color),
- ).toBe(true);
+ expect(nodes.includes(label1.color)).toBe(true);
});
- it('does not render label if label does not have an ID', (done) => {
- component.issue.addLabel(new ListLabel({
- title: 'closed',
- }));
+ it('does not render label if label does not have an ID', done => {
+ component.issue.addLabel(
+ new ListLabel({
+ title: 'closed',
+ }),
+ );
Vue.nextTick()
.then(() => {
- expect(
- component.$el.querySelectorAll('.badge').length,
- ).toBe(2);
- expect(
- component.$el.textContent,
- ).not.toContain('closed');
+ expect(component.$el.querySelectorAll('.badge').length).toBe(2);
+ expect(component.$el.textContent).not.toContain('closed');
done();
})
diff --git a/spec/javascripts/diffs/components/diff_line_note_form_spec.js b/spec/javascripts/diffs/components/diff_line_note_form_spec.js
index 4600aaea70b..6fe5fdaf7f9 100644
--- a/spec/javascripts/diffs/components/diff_line_note_form_spec.js
+++ b/spec/javascripts/diffs/components/diff_line_note_form_spec.js
@@ -3,6 +3,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
import store from '~/mr_notes/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
+import { noteableDataMock } from '../../notes/mock_data';
describe('DiffLineNoteForm', () => {
let component;
@@ -21,10 +22,9 @@ describe('DiffLineNoteForm', () => {
noteTargetLine: diffLines[0],
});
- Object.defineProperty(component, 'isLoggedIn', {
- get() {
- return true;
- },
+ Object.defineProperties(component, {
+ noteableData: { value: noteableDataMock },
+ isLoggedIn: { value: true },
});
component.$mount();
@@ -32,12 +32,37 @@ describe('DiffLineNoteForm', () => {
describe('methods', () => {
describe('handleCancelCommentForm', () => {
- it('should call cancelCommentForm with lineCode', () => {
+ it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+
+ component.handleCancelCommentForm(true, true);
+ expect(window.confirm).toHaveBeenCalled();
+ });
+
+ it('should ask for confirmation when one of the params false', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+
+ component.handleCancelCommentForm(true, false);
+ expect(window.confirm).not.toHaveBeenCalled();
+
+ component.handleCancelCommentForm(false, true);
+ expect(window.confirm).not.toHaveBeenCalled();
+ });
+
+ it('should call cancelCommentForm with lineCode', done => {
+ spyOn(window, 'confirm');
spyOn(component, 'cancelCommentForm');
+ spyOn(component, 'resetAutoSave');
component.handleCancelCommentForm();
- expect(component.cancelCommentForm).toHaveBeenCalledWith({
- lineCode: diffLines[0].lineCode,
+ expect(window.confirm).not.toHaveBeenCalled();
+ component.$nextTick(() => {
+ expect(component.cancelCommentForm).toHaveBeenCalledWith({
+ lineCode: diffLines[0].lineCode,
+ });
+ expect(component.resetAutoSave).toHaveBeenCalled();
+
+ done();
});
});
});
@@ -66,7 +91,7 @@ describe('DiffLineNoteForm', () => {
describe('mounted', () => {
it('should init autosave', () => {
- const key = 'autosave/Note/issue///DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1';
+ const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1';
expect(component.autosave).toBeDefined();
expect(component.autosave.key).toEqual(key);
diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml
index 0421ed2182f..4aa54da9411 100644
--- a/spec/javascripts/fixtures/search_autocomplete.html.haml
+++ b/spec/javascripts/fixtures/search_autocomplete.html.haml
@@ -1,8 +1,6 @@
-.search.search-form.has-location-badge
- %form.navbar-form
+.search.search-form
+ %form.form-inline
.search-input-container
- %div.location-badge
- This project
.search-input-wrap
.dropdown
%input#search.search-input.dropdown-menu-toggle
diff --git a/spec/javascripts/notes/components/discussion_counter_spec.js b/spec/javascripts/notes/components/discussion_counter_spec.js
index a3869cc6498..d09bc5037ef 100644
--- a/spec/javascripts/notes/components/discussion_counter_spec.js
+++ b/spec/javascripts/notes/components/discussion_counter_spec.js
@@ -46,7 +46,7 @@ describe('DiscussionCounter component', () => {
discussions,
});
setFixtures(`
- <div data-discussion-id="${firstDiscussionId}"></div>
+ <div class="discussion" data-discussion-id="${firstDiscussionId}"></div>
`);
vm.jumpToFirstUnresolvedDiscussion();
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index 7da931fd9cb..2a01bd85520 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -14,6 +14,7 @@ describe('noteable_discussion component', () => {
preloadFixtures(discussionWithTwoUnresolvedNotes);
beforeEach(() => {
+ window.mrTabs = {};
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
@@ -46,10 +47,15 @@ describe('noteable_discussion component', () => {
it('should toggle reply form', done => {
vm.$el.querySelector('.js-vue-discussion-reply').click();
+
Vue.nextTick(() => {
- expect(vm.$refs.noteForm).not.toBeNull();
expect(vm.isReplying).toEqual(true);
- done();
+
+ // There is a watcher for `isReplying` which will init autosave in the next tick
+ Vue.nextTick(() => {
+ expect(vm.$refs.noteForm).not.toBeNull();
+ done();
+ });
});
});
@@ -101,33 +107,29 @@ describe('noteable_discussion component', () => {
describe('methods', () => {
describe('jumpToNextDiscussion', () => {
- it('expands next unresolved discussion', () => {
- spyOn(vm, 'expandDiscussion').and.stub();
- const discussions = [
- discussionMock,
- {
- ...discussionMock,
- id: discussionMock.id + 1,
- notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }],
- },
- {
- ...discussionMock,
- id: discussionMock.id + 2,
- notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }],
- },
- ];
- const nextDiscussionId = discussionMock.id + 2;
- store.replaceState({
- ...store.state,
- discussions,
- });
- setFixtures(`
- <div data-discussion-id="${nextDiscussionId}"></div>
- `);
+ it('expands next unresolved discussion', done => {
+ const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
+ discussion2.resolved = false;
+ discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
+ vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
+ window.mrTabs.currentAction = 'show';
+
+ Vue.nextTick()
+ .then(() => {
+ spyOn(vm, 'expandDiscussion').and.stub();
+
+ const nextDiscussionId = discussion2.id;
+
+ setFixtures(`
+ <div class="discussion" data-discussion-id="${nextDiscussionId}"></div>
+ `);
- vm.jumpToNextDiscussion();
+ vm.jumpToNextDiscussion();
- expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId });
+ expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId });
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index be2a8ba67fe..67f6a9629d9 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -1168,3 +1168,87 @@ export const collapsedSystemNotes = [
diff_discussion: false,
},
];
+
+export const discussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ formatter: {
+ new_line: 50,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const resolvedDiscussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: true,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ formatter: {
+ new_line: 50,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const discussion2 = {
+ id: 'abc2',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ formatter: {
+ new_line: null,
+ old_line: 20,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T12:05:41.749Z',
+ },
+ ],
+};
+
+export const discussion3 = {
+ id: 'abc3',
+ resolvable: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ formatter: {
+ new_line: 21,
+ old_line: null,
+ },
+ },
+ notes: [
+ {
+ created_at: '2018-07-05T17:25:41.749Z',
+ },
+ ],
+};
+
+export const unresolvableDiscussion = {
+ resolvable: false,
+};
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
index 41599e00122..7f8ede51508 100644
--- a/spec/javascripts/notes/stores/getters_spec.js
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -5,6 +5,11 @@ import {
noteableDataMock,
individualNote,
collapseNotesMock,
+ discussion1,
+ discussion2,
+ discussion3,
+ resolvedDiscussion1,
+ unresolvableDiscussion,
} from '../mock_data';
const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
@@ -109,4 +114,154 @@ describe('Getters Notes Store', () => {
expect(getters.isNotesFetched(state)).toBeFalsy();
});
});
+
+ describe('allResolvableDiscussions', () => {
+ it('should return only resolvable discussions in same order', () => {
+ const localGetters = {
+ allDiscussions: [
+ discussion3,
+ unresolvableDiscussion,
+ discussion1,
+ unresolvableDiscussion,
+ discussion2,
+ ],
+ };
+
+ expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([
+ discussion3,
+ discussion1,
+ discussion2,
+ ]);
+ });
+
+ it('should return empty array if there are no resolvable discussions', () => {
+ const localGetters = {
+ allDiscussions: [unresolvableDiscussion, unresolvableDiscussion],
+ };
+
+ expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsByDiff', () => {
+ it('should return all discussions IDs in diff order', () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([
+ 'abc1',
+ 'abc2',
+ 'abc3',
+ ]);
+ });
+
+ it('should return empty array if all discussions have been resolved', () => {
+ const localGetters = {
+ allResolvableDiscussions: [resolvedDiscussion1],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsByDate', () => {
+ it('should return all discussions in date ascending order', () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([
+ 'abc2',
+ 'abc1',
+ 'abc3',
+ ]);
+ });
+
+ it('should return empty array if all discussions have been resolved', () => {
+ const localGetters = {
+ allResolvableDiscussions: [resolvedDiscussion1],
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([]);
+ });
+ });
+
+ describe('unresolvedDiscussionsIdsOrdered', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsByDate: ['123', '456'],
+ unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
+ };
+
+ it('should return IDs ordered by diff when diffOrder param is true', () => {
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(true)).toEqual([
+ 'abc',
+ 'def',
+ ]);
+ });
+
+ it('should return IDs ordered by date when diffOrder param is not true', () => {
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(false)).toEqual([
+ '123',
+ '456',
+ ]);
+ expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(undefined)).toEqual([
+ '123',
+ '456',
+ ]);
+ });
+ });
+
+ describe('isLastUnresolvedDiscussion', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
+ };
+
+ it('should return true if the discussion id provided is the last', () => {
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('789')).toBe(true);
+ });
+
+ it('should return false if the discussion id provided is not the last', () => {
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('123')).toBe(false);
+ expect(getters.isLastUnresolvedDiscussion(state, localGetters)('456')).toBe(false);
+ });
+ });
+
+ describe('nextUnresolvedDiscussionId', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
+ };
+
+ it('should return the ID of the discussion after the ID provided', () => {
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456');
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789');
+ expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined);
+ });
+ });
+
+ describe('firstUnresolvedDiscussionId', () => {
+ const localGetters = {
+ unresolvedDiscussionsIdsByDate: ['123', '456'],
+ unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
+ };
+
+ it('should return the first discussion id by diff when diffOrder param is true', () => {
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(true)).toBe('abc');
+ });
+
+ it('should return the first discussion id by date when diffOrder param is not true', () => {
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(false)).toBe('123');
+ expect(getters.firstUnresolvedDiscussionId(state, localGetters)(undefined)).toBe('123');
+ });
+
+ it('should be falsy if all discussions are resolved', () => {
+ const localGettersFalsy = {
+ unresolvedDiscussionsIdsByDiff: [],
+ unresolvedDiscussionsIdsByDate: [],
+ };
+
+ expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(true)).toBeFalsy();
+ expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy();
+ });
+ });
});
diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js
index 4a4f2259d23..ddd580ae8b7 100644
--- a/spec/javascripts/pipelines/pipeline_url_spec.js
+++ b/spec/javascripts/pipelines/pipeline_url_spec.js
@@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
+ expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual(
+ 'foo',
+ );
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
});
@@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => {
const image = component.$el.querySelector('.js-pipeline-url-user img');
- expect(
- component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
- ).toEqual(mockData.pipeline.user.web_url);
+ expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
+ mockData.pipeline.user.web_url,
+ );
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
- expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
+ expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
});
it('should render "API" when no user is provided', () => {
@@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest');
- expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
+ expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain(
+ 'yaml invalid',
+ );
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
});
@@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
- expect(
- component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim(),
- ).toEqual('Auto DevOps');
+ expect(component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim()).toEqual(
+ 'Auto DevOps',
+ );
});
it('should render error badge when pipeline has a failure reason set', () => {
@@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error');
- expect(component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title')).toContain('some reason');
+ expect(
+ component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title'),
+ ).toContain('some reason');
});
});
diff --git a/spec/javascripts/sidebar/todo_spec.js b/spec/javascripts/sidebar/todo_spec.js
new file mode 100644
index 00000000000..a929b804a29
--- /dev/null
+++ b/spec/javascripts/sidebar/todo_spec.js
@@ -0,0 +1,158 @@
+import Vue from 'vue';
+
+import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = ({
+ issuableId = 1,
+ issuableType = 'epic',
+ isTodo,
+ isActionActive,
+ collapsed,
+}) => {
+ const Component = Vue.extend(SidebarTodos);
+
+ return mountComponent(Component, {
+ issuableId,
+ issuableType,
+ isTodo,
+ isActionActive,
+ collapsed,
+ });
+};
+
+describe('SidebarTodo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent({});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('buttonClasses', () => {
+ it('returns todo button classes for when `collapsed` prop is `false`', () => {
+ expect(vm.buttonClasses).toBe('btn btn-default btn-todo issuable-header-btn float-right');
+ });
+
+ it('returns todo button classes for when `collapsed` prop is `true`', done => {
+ vm.collapsed = true;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.buttonClasses).toBe('btn-blank btn-todo sidebar-collapsed-icon dont-change-state');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('buttonLabel', () => {
+ it('returns todo button text for marking todo as done when `isTodo` prop is `true`', () => {
+ expect(vm.buttonLabel).toBe('Mark todo as done');
+ });
+
+ it('returns todo button text for add todo when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.buttonLabel).toBe('Add todo');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('collapsedButtonIconClasses', () => {
+ it('returns collapsed button icon class when `isTodo` prop is `true`', () => {
+ expect(vm.collapsedButtonIconClasses).toBe('todo-undone');
+ });
+
+ it('returns empty string when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.collapsedButtonIconClasses).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('collapsedButtonIcon', () => {
+ it('returns button icon name when `isTodo` prop is `true`', () => {
+ expect(vm.collapsedButtonIcon).toBe('todo-done');
+ });
+
+ it('returns button icon name when `isTodo` prop is `false`', done => {
+ vm.isTodo = false;
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.collapsedButtonIcon).toBe('todo-add');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it('emits `toggleTodo` event on component', () => {
+ spyOn(vm, '$emit');
+ vm.handleButtonClick();
+ expect(vm.$emit).toHaveBeenCalledWith('toggleTodo');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ const dataAttributes = {
+ issuableId: '1',
+ issuableType: 'epic',
+ originalTitle: 'Mark todo as done',
+ placement: 'left',
+ container: 'body',
+ boundary: 'viewport',
+ };
+ expect(vm.$el.nodeName).toBe('BUTTON');
+
+ const elDataAttrs = vm.$el.dataset;
+ Object.keys(elDataAttrs).forEach((attr) => {
+ expect(elDataAttrs[attr]).toBe(dataAttributes[attr]);
+ });
+ });
+
+ it('renders button label element when `collapsed` prop is `false`', () => {
+ const buttonLabelEl = vm.$el.querySelector('span.issuable-todo-inner');
+ expect(buttonLabelEl).not.toBeNull();
+ expect(buttonLabelEl.innerText.trim()).toBe('Mark todo as done');
+ });
+
+ it('renders button icon when `collapsed` prop is `true`', done => {
+ vm.collapsed = true;
+ Vue.nextTick()
+ .then(() => {
+ const buttonIconEl = vm.$el.querySelector('svg');
+ expect(buttonIconEl).not.toBeNull();
+ expect(buttonIconEl.querySelector('use').getAttribute('xlink:href')).toContain('todo-done');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders loading icon when `isActionActive` prop is true', done => {
+ vm.isActionActive = true;
+ Vue.nextTick()
+ .then(() => {
+ const loadingEl = vm.$el.querySelector('span.loading-container');
+ expect(loadingEl).not.toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
index 7e57c51bf29..db665fdaad3 100644
--- a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js
@@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => {
userDataMock.path,
);
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
- userDataMock.avatar_url,
+ `${userDataMock.avatar_url}?width=40`,
);
});
});
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
index 656b57d764e..dc7652c77f7 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -12,7 +12,7 @@ const DEFAULT_PROPS = {
tooltipPlacement: 'bottom',
};
-describe('User Avatar Image Component', function () {
+describe('User Avatar Image Component', function() {
let vm;
let UserAvatarImage;
@@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () {
UserAvatarImage = Vue.extend(userAvatarImage);
});
- describe('Initialization', function () {
- beforeEach(function () {
+ describe('Initialization', function() {
+ beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
}).$mount();
});
- it('should return a defined Vue component', function () {
+ it('should return a defined Vue component', function() {
expect(vm).toBeDefined();
});
- it('should have <img> as a child element', function () {
+ it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG');
- expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc);
- expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
+ expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
+ expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
});
- it('should properly compute tooltipContainer', function () {
+ it('should properly compute tooltipContainer', function() {
expect(vm.tooltipContainer).toBe('body');
});
- it('should properly render tooltipContainer', function () {
+ it('should properly render tooltipContainer', function() {
expect(vm.$el.getAttribute('data-container')).toBe('body');
});
- it('should properly compute avatarSizeClass', function () {
+ it('should properly compute avatarSizeClass', function() {
expect(vm.avatarSizeClass).toBe('s99');
});
- it('should properly render img css', function () {
+ it('should properly render img css', function() {
const { classList } = vm.$el;
const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99');
@@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () {
});
});
- describe('Initialization when lazy', function () {
- beforeEach(function () {
+ describe('Initialization when lazy', function() {
+ beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
lazy: true,
}).$mount();
});
- it('should add lazy attributes', function () {
+ it('should add lazy attributes', function() {
const { classList } = vm.$el;
const lazyClass = classList.contains('lazy');
expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage);
- expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
+ expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
});
});
});
diff --git a/spec/lib/bitbucket_server/client_spec.rb b/spec/lib/bitbucket_server/client_spec.rb
new file mode 100644
index 00000000000..f926ae963a4
--- /dev/null
+++ b/spec/lib/bitbucket_server/client_spec.rb
@@ -0,0 +1,88 @@
+require 'spec_helper'
+
+describe BitbucketServer::Client do
+ let(:base_uri) { 'https://test:7990/stash/' }
+ let(:options) { { base_uri: base_uri, user: 'bitbucket', password: 'mypassword' } }
+ let(:project) { 'SOME-PROJECT' }
+ let(:repo_slug) { 'my-repo' }
+ let(:headers) { { "Content-Type" => "application/json" } }
+
+ subject { described_class.new(options) }
+
+ describe '#pull_requests' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests?state=ALL" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :pull_request)
+
+ subject.pull_requests(project, repo_slug)
+ end
+
+ it 'throws an exception when connection fails' do
+ allow(BitbucketServer::Collection).to receive(:new).and_raise(OpenSSL::SSL::SSLError)
+
+ expect { subject.pull_requests(project, repo_slug) }.to raise_error(described_class::ServerError)
+ end
+ end
+
+ describe '#activities' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}/pull-requests/1/activities" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :activity)
+
+ subject.activities(project, repo_slug, 1)
+ end
+ end
+
+ describe '#repo' do
+ let(:path) { "/projects/#{project}/repos/#{repo_slug}" }
+ let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo" }
+
+ it 'requests a specific repository' do
+ stub_request(:get, url).to_return(status: 200, headers: headers, body: '{}')
+
+ subject.repo(project, repo_slug)
+
+ expect(WebMock).to have_requested(:get, url)
+ end
+ end
+
+ describe '#repos' do
+ let(:path) { "/repos" }
+
+ it 'requests a collection' do
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :repo)
+
+ subject.repos
+ end
+ end
+
+ describe '#create_branch' do
+ let(:branch) { 'test-branch' }
+ let(:sha) { '12345678' }
+ let(:url) { "#{base_uri}rest/api/1.0/projects/SOME-PROJECT/repos/my-repo/branches" }
+
+ it 'requests Bitbucket to create a branch' do
+ stub_request(:post, url).to_return(status: 204, headers: headers, body: '{}')
+
+ subject.create_branch(project, repo_slug, branch, sha)
+
+ expect(WebMock).to have_requested(:post, url)
+ end
+ end
+
+ describe '#delete_branch' do
+ let(:branch) { 'test-branch' }
+ let(:sha) { '12345678' }
+ let(:url) { "#{base_uri}rest/branch-utils/1.0/projects/SOME-PROJECT/repos/my-repo/branches" }
+
+ it 'requests Bitbucket to create a branch' do
+ stub_request(:delete, url).to_return(status: 204, headers: headers, body: '{}')
+
+ subject.delete_branch(project, repo_slug, branch, sha)
+
+ expect(WebMock).to have_requested(:delete, url)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/connection_spec.rb b/spec/lib/bitbucket_server/connection_spec.rb
new file mode 100644
index 00000000000..b5da4cb1a49
--- /dev/null
+++ b/spec/lib/bitbucket_server/connection_spec.rb
@@ -0,0 +1,68 @@
+require 'spec_helper'
+
+describe BitbucketServer::Connection do
+ let(:options) { { base_uri: 'https://test:7990', user: 'bitbucket', password: 'mypassword' } }
+ let(:payload) { { 'test' => 1 } }
+ let(:headers) { { "Content-Type" => "application/json" } }
+ let(:url) { 'https://test:7990/rest/api/1.0/test?something=1' }
+
+ subject { described_class.new(options) }
+
+ describe '#get' do
+ it 'returns JSON body' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.get(url, { something: 1 })).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.get(url) }.to raise_error(described_class::ConnectionError)
+ end
+
+ it 'throws an exception if the response is not JSON' do
+ WebMock.stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(body: 'bad data', status: 200, headers: headers)
+
+ expect { subject.get(url) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+
+ describe '#post' do
+ let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } }
+
+ it 'returns JSON body' do
+ WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.post(url, payload)).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:post, url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.post(url, payload) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+
+ describe '#delete' do
+ let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } }
+
+ context 'branch API' do
+ let(:branch_path) { '/projects/foo/repos/bar/branches' }
+ let(:branch_url) { 'https://test:7990/rest/branch-utils/1.0/projects/foo/repos/bar/branches' }
+ let(:path) { }
+
+ it 'returns JSON body' do
+ WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 200, headers: headers)
+
+ expect(subject.delete(:branches, branch_path, payload)).to eq(payload)
+ end
+
+ it 'throws an exception if the response is not 200' do
+ WebMock.stub_request(:delete, branch_url).with(headers: headers).to_return(body: payload.to_json, status: 500, headers: headers)
+
+ expect { subject.delete(:branches, branch_path, payload) }.to raise_error(described_class::ConnectionError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/page_spec.rb b/spec/lib/bitbucket_server/page_spec.rb
new file mode 100644
index 00000000000..cf419a9045b
--- /dev/null
+++ b/spec/lib/bitbucket_server/page_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe BitbucketServer::Page do
+ let(:response) { { 'values' => [{ 'description' => 'Test' }], 'isLastPage' => false, 'nextPageStart' => 2 } }
+
+ before do
+ # Autoloading hack
+ BitbucketServer::Representation::PullRequest.new({})
+ end
+
+ describe '#items' do
+ it 'returns collection of needed objects' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.items.first).to be_a(BitbucketServer::Representation::PullRequest)
+ expect(page.items.count).to eq(1)
+ end
+ end
+
+ describe '#attrs' do
+ it 'returns attributes' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.attrs.keys).to include(:isLastPage, :nextPageStart)
+ end
+ end
+
+ describe '#next?' do
+ it 'returns true' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next?).to be_truthy
+ end
+
+ it 'returns false' do
+ response['isLastPage'] = true
+ response.delete('nextPageStart')
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next?).to be_falsey
+ end
+ end
+
+ describe '#next' do
+ it 'returns next attribute' do
+ page = described_class.new(response, :pull_request)
+
+ expect(page.next).to eq(2)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/paginator_spec.rb b/spec/lib/bitbucket_server/paginator_spec.rb
new file mode 100644
index 00000000000..2de50eba3c4
--- /dev/null
+++ b/spec/lib/bitbucket_server/paginator_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe BitbucketServer::Paginator do
+ let(:last_page) { double(:page, next?: false, items: ['item_2']) }
+ let(:first_page) { double(:page, next?: true, next: last_page, items: ['item_1']) }
+ let(:connection) { instance_double(BitbucketServer::Connection) }
+
+ describe '#items' do
+ let(:paginator) { described_class.new(connection, 'http://more-data', :pull_request) }
+ let(:page_attrs) { { 'isLastPage' => false, 'nextPageStart' => 1 } }
+
+ it 'returns items and raises StopIteration in the end' do
+ allow(paginator).to receive(:fetch_next_page).and_return(first_page)
+ expect(paginator.items).to match(['item_1'])
+
+ allow(paginator).to receive(:fetch_next_page).and_return(last_page)
+ expect(paginator.items).to match(['item_2'])
+
+ allow(paginator).to receive(:fetch_next_page).and_return(nil)
+ expect { paginator.items }.to raise_error(StopIteration)
+ end
+
+ it 'calls the connection with different offsets' do
+ expect(connection).to receive(:get).with('http://more-data', start: 0, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return(page_attrs)
+
+ expect(paginator.items).to eq([])
+
+ expect(connection).to receive(:get).with('http://more-data', start: 1, limit: BitbucketServer::Paginator::PAGE_LENGTH).and_return({})
+
+ expect(paginator.items).to eq([])
+
+ expect { paginator.items }.to raise_error(StopIteration)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/activity_spec.rb b/spec/lib/bitbucket_server/representation/activity_spec.rb
new file mode 100644
index 00000000000..15c50e40472
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/activity_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Activity do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:inline_comment) { activities.first }
+ let(:comment) { activities[3] }
+ let(:merge_event) { activities[4] }
+
+ describe 'regular comment' do
+ subject { described_class.new(comment) }
+
+ it { expect(subject.comment?).to be_truthy }
+ it { expect(subject.inline_comment?).to be_falsey }
+ it { expect(subject.comment).to be_a(BitbucketServer::Representation::Comment) }
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe 'inline comment' do
+ subject { described_class.new(inline_comment) }
+
+ it { expect(subject.comment?).to be_truthy }
+ it { expect(subject.inline_comment?).to be_truthy }
+ it { expect(subject.comment).to be_a(BitbucketServer::Representation::PullRequestComment) }
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe 'merge event' do
+ subject { described_class.new(merge_event) }
+
+ it { expect(subject.comment?).to be_falsey }
+ it { expect(subject.inline_comment?).to be_falsey }
+ it { expect(subject.committer_user).to eq('root') }
+ it { expect(subject.committer_email).to eq('test.user@example.com') }
+ it { expect(subject.merge_timestamp).to be_a(Time) }
+ it { expect(subject.created_at).to be_a(Time) }
+ it { expect(subject.merge_commit).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/comment_spec.rb b/spec/lib/bitbucket_server/representation/comment_spec.rb
new file mode 100644
index 00000000000..53a20a1d80a
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/comment_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Comment do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:comment) { activities.first }
+
+ subject { described_class.new(comment) }
+
+ describe '#id' do
+ it { expect(subject.id).to eq(9) }
+ end
+
+ describe '#author_username' do
+ it { expect(subject.author_username).to eq('root' ) }
+ end
+
+ describe '#author_email' do
+ it { expect(subject.author_email).to eq('test.user@example.com' ) }
+ end
+
+ describe '#note' do
+ it { expect(subject.note).to eq('is this a new line?') }
+ end
+
+ describe '#created_at' do
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe '#updated_at' do
+ it { expect(subject.created_at).to be_a(Time) }
+ end
+
+ describe '#comments' do
+ it { expect(subject.comments.count).to eq(4) }
+ it { expect(subject.comments).to all( be_a(described_class) ) }
+ it { expect(subject.comments.map(&:note)).to match_array(["Hello world", "Ok", "hello", "hi"]) }
+
+ # The thread should look like:
+ #
+ # is this a new line? (subject)
+ # -> Hello world (first)
+ # -> Ok (third)
+ # -> Hi (fourth)
+ # -> hello (second)
+ it 'comments have the right parent' do
+ first, second, third, fourth = subject.comments[0..4]
+
+ expect(subject.parent_comment).to be_nil
+ expect(first.parent_comment).to eq(subject)
+ expect(second.parent_comment).to eq(subject)
+ expect(third.parent_comment).to eq(first)
+ expect(fourth.parent_comment).to eq(first)
+ end
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
new file mode 100644
index 00000000000..bd7e3597486
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::PullRequestComment do
+ let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:comment) { activities.second }
+
+ subject { described_class.new(comment) }
+
+ describe '#id' do
+ it { expect(subject.id).to eq(7) }
+ end
+
+ describe '#from_sha' do
+ it { expect(subject.from_sha).to eq('c5f4288162e2e6218180779c7f6ac1735bb56eab') }
+ end
+
+ describe '#to_sha' do
+ it { expect(subject.to_sha).to eq('a4c2164330f2549f67c13f36a93884cf66e976be') }
+ end
+
+ describe '#to?' do
+ it { expect(subject.to?).to be_falsey }
+ end
+
+ describe '#from?' do
+ it { expect(subject.from?).to be_truthy }
+ end
+
+ describe '#added?' do
+ it { expect(subject.added?).to be_falsey }
+ end
+
+ describe '#removed?' do
+ it { expect(subject.removed?).to be_falsey }
+ end
+
+ describe '#new_pos' do
+ it { expect(subject.new_pos).to eq(11) }
+ end
+
+ describe '#old_pos' do
+ it { expect(subject.old_pos).to eq(9) }
+ end
+
+ describe '#file_path' do
+ it { expect(subject.file_path).to eq('CHANGELOG.md') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
new file mode 100644
index 00000000000..4b8afdb006b
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::PullRequest do
+ let(:sample_data) { JSON.parse(fixture_file('importers/bitbucket_server/pull_request.json')) }
+
+ subject { described_class.new(sample_data) }
+
+ describe '#author' do
+ it { expect(subject.author).to eq('root') }
+ end
+
+ describe '#author_email' do
+ it { expect(subject.author_email).to eq('joe.montana@49ers.com') }
+ end
+
+ describe '#description' do
+ it { expect(subject.description).to eq('Test') }
+ end
+
+ describe '#iid' do
+ it { expect(subject.iid).to eq(7) }
+ end
+
+ describe '#state' do
+ it { expect(subject.state).to eq('merged') }
+
+ context 'declined pull requests' do
+ before do
+ sample_data['state'] = 'DECLINED'
+ end
+
+ it 'returns closed' do
+ expect(subject.state).to eq('closed')
+ end
+ end
+
+ context 'open pull requests' do
+ before do
+ sample_data['state'] = 'OPEN'
+ end
+
+ it 'returns open' do
+ expect(subject.state).to eq('opened')
+ end
+ end
+ end
+
+ describe '#merged?' do
+ it { expect(subject.merged?).to be_truthy }
+ end
+
+ describe '#created_at' do
+ it { expect(subject.created_at.to_i).to eq(sample_data['createdDate'] / 1000) }
+ end
+
+ describe '#updated_at' do
+ it { expect(subject.updated_at.to_i).to eq(sample_data['updatedDate'] / 1000) }
+ end
+
+ describe '#title' do
+ it { expect(subject.title).to eq('Added a new line') }
+ end
+
+ describe '#source_branch_name' do
+ it { expect(subject.source_branch_name).to eq('refs/heads/root/CODE_OF_CONDUCTmd-1530600625006') }
+ end
+
+ describe '#source_branch_sha' do
+ it { expect(subject.source_branch_sha).to eq('074e2b4dddc5b99df1bf9d4a3f66cfc15481fdc8') }
+ end
+
+ describe '#target_branch_name' do
+ it { expect(subject.target_branch_name).to eq('refs/heads/master') }
+ end
+
+ describe '#target_branch_sha' do
+ it { expect(subject.target_branch_sha).to eq('839fa9a2d434eb697815b8fcafaecc51accfdbbc') }
+ end
+end
diff --git a/spec/lib/bitbucket_server/representation/repo_spec.rb b/spec/lib/bitbucket_server/representation/repo_spec.rb
new file mode 100644
index 00000000000..3ac1030fbb0
--- /dev/null
+++ b/spec/lib/bitbucket_server/representation/repo_spec.rb
@@ -0,0 +1,80 @@
+require 'spec_helper'
+
+describe BitbucketServer::Representation::Repo do
+ let(:sample_data) do
+ <<~DATA
+ {
+ "slug": "rouge",
+ "id": 1,
+ "name": "rouge",
+ "scmId": "git",
+ "state": "AVAILABLE",
+ "statusMessage": "Available",
+ "forkable": true,
+ "project": {
+ "key": "TEST",
+ "id": 1,
+ "name": "test",
+ "description": "Test",
+ "public": false,
+ "type": "NORMAL",
+ "links": {
+ "self": [
+ {
+ "href": "http://localhost:7990/projects/TEST"
+ }
+ ]
+ }
+ },
+ "public": false,
+ "links": {
+ "clone": [
+ {
+ "href": "http://root@localhost:7990/scm/test/rouge.git",
+ "name": "http"
+ },
+ {
+ "href": "ssh://git@localhost:7999/test/rouge.git",
+ "name": "ssh"
+ }
+ ],
+ "self": [
+ {
+ "href": "http://localhost:7990/projects/TEST/repos/rouge/browse"
+ }
+ ]
+ }
+ }
+ DATA
+ end
+
+ subject { described_class.new(JSON.parse(sample_data)) }
+
+ describe '#project_key' do
+ it { expect(subject.project_key).to eq('TEST') }
+ end
+
+ describe '#project_name' do
+ it { expect(subject.project_name).to eq('test') }
+ end
+
+ describe '#slug' do
+ it { expect(subject.slug).to eq('rouge') }
+ end
+
+ describe '#browse_url' do
+ it { expect(subject.browse_url).to eq('http://localhost:7990/projects/TEST/repos/rouge/browse') }
+ end
+
+ describe '#clone_url' do
+ it { expect(subject.clone_url).to eq('http://root@localhost:7990/scm/test/rouge.git') }
+ end
+
+ describe '#description' do
+ it { expect(subject.description).to eq('Test') }
+ end
+
+ describe '#full_name' do
+ it { expect(subject.full_name).to eq('test/rouge') }
+ end
+end
diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
new file mode 100644
index 00000000000..70423823b89
--- /dev/null
+++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
@@ -0,0 +1,291 @@
+require 'spec_helper'
+
+describe Gitlab::BitbucketServerImport::Importer do
+ include ImportSpecHelper
+
+ let(:project) { create(:project, :repository, import_url: 'http://my-bitbucket') }
+ let(:now) { Time.now.utc.change(usec: 0) }
+ let(:project_key) { 'TEST' }
+ let(:repo_slug) { 'rouge' }
+ let(:sample) { RepoHelpers.sample_compare }
+
+ subject { described_class.new(project, recover_missing_commits: true) }
+
+ before do
+ data = project.create_or_update_import_data(
+ data: { project_key: project_key, repo_slug: repo_slug },
+ credentials: { base_uri: 'http://my-bitbucket', user: 'bitbucket', password: 'test' }
+ )
+ data.save
+ project.save
+ end
+
+ describe '#import_repository' do
+ before do
+ expect(subject).to receive(:import_pull_requests)
+ expect(subject).to receive(:delete_temp_branches)
+ end
+
+ it 'adds a remote' do
+ expect(project.repository).to receive(:fetch_as_mirror)
+ .with('http://bitbucket:test@my-bitbucket',
+ refmap: [:heads, :tags, '+refs/pull-requests/*/to:refs/merge-requests/*/head'],
+ remote_name: 'bitbucket_server')
+
+ subject.execute
+ end
+ end
+
+ describe '#import_pull_requests' do
+ before do
+ allow(subject).to receive(:import_repository)
+ allow(subject).to receive(:delete_temp_branches)
+ allow(subject).to receive(:restore_branches)
+
+ pull_request = instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: 10,
+ source_branch_sha: sample.commits.last,
+ source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch,
+ target_branch_sha: sample.commits.first,
+ target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch,
+ title: 'This is a title',
+ description: 'This is a test pull request',
+ state: 'merged',
+ author: 'Test Author',
+ author_email: project.owner.email,
+ created_at: Time.now,
+ updated_at: Time.now,
+ merged?: true)
+
+ allow(subject.client).to receive(:pull_requests).and_return([pull_request])
+
+ @merge_event = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: false,
+ merge_event?: true,
+ committer_email: project.owner.email,
+ merge_timestamp: now,
+ merge_commit: '12345678'
+ )
+
+ @pr_note = instance_double(
+ BitbucketServer::Representation::Comment,
+ note: 'Hello world',
+ author_email: 'unknown@gmail.com',
+ author_username: 'The Flash',
+ comments: [],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ @pr_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: false,
+ merge_event?: false,
+ comment: @pr_note)
+ end
+
+ it 'imports merge event' do
+ expect(subject.client).to receive(:activities).and_return([@merge_event])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.metrics.merged_by).to eq(project.owner)
+ expect(merge_request.metrics.merged_at).to eq(@merge_event.merge_timestamp)
+ expect(merge_request.merge_commit_sha).to eq('12345678')
+ end
+
+ it 'imports comments' do
+ expect(subject.client).to receive(:activities).and_return([@pr_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(1)
+ note = merge_request.notes.first
+ expect(note.note).to end_with(@pr_note.note)
+ expect(note.author).to eq(project.owner)
+ expect(note.created_at).to eq(@pr_note.created_at)
+ expect(note.updated_at).to eq(@pr_note.created_at)
+ end
+
+ it 'imports threaded discussions' do
+ reply = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ author_email: 'someuser@gitlab.com',
+ author_username: 'Batman',
+ note: 'I agree',
+ created_at: now,
+ updated_at: now)
+
+ # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad
+ inline_note = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ file_type: 'ADDED',
+ from_sha: sample.commits.first,
+ to_sha: sample.commits.last,
+ file_path: '.gitmodules',
+ old_pos: nil,
+ new_pos: 4,
+ note: 'Hello world',
+ author_email: 'unknown@gmail.com',
+ author_username: 'Superman',
+ comments: [reply],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ allow(reply).to receive(:parent_comment).and_return(inline_note)
+
+ inline_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: true,
+ merge_event?: false,
+ comment: inline_note)
+
+ expect(subject.client).to receive(:activities).and_return([inline_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(2)
+ expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1)
+
+ notes = merge_request.notes.order(:id).to_a
+ start_note = notes.first
+ expect(start_note.type).to eq('DiffNote')
+ expect(start_note.note).to end_with(inline_note.note)
+ expect(start_note.created_at).to eq(inline_note.created_at)
+ expect(start_note.updated_at).to eq(inline_note.updated_at)
+ expect(start_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(start_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(start_note.position.old_line).to be_nil
+ expect(start_note.position.new_line).to eq(inline_note.new_pos)
+
+ reply_note = notes.last
+ # Make sure author and reply context is included
+ expect(reply_note.note).to start_with("*By #{reply.author_username} (#{reply.author_email})*\n\n")
+ expect(reply_note.note).to end_with("> #{inline_note.note}\n\n#{reply.note}")
+ expect(reply_note.author).to eq(project.owner)
+ expect(reply_note.created_at).to eq(reply.created_at)
+ expect(reply_note.updated_at).to eq(reply.created_at)
+ expect(reply_note.position.base_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.start_sha).to eq(inline_note.from_sha)
+ expect(reply_note.position.head_sha).to eq(inline_note.to_sha)
+ expect(reply_note.position.old_line).to be_nil
+ expect(reply_note.position.new_line).to eq(inline_note.new_pos)
+ end
+
+ it 'falls back to comments if diff comments fail to validate' do
+ reply = instance_double(
+ BitbucketServer::Representation::Comment,
+ author_email: 'someuser@gitlab.com',
+ author_username: 'Aquaman',
+ note: 'I agree',
+ created_at: now,
+ updated_at: now)
+
+ # https://gitlab.com/gitlab-org/gitlab-test/compare/c1acaa58bbcbc3eafe538cb8274ba387047b69f8...5937ac0a7beb003549fc5fd26fc247ad
+ inline_note = instance_double(
+ BitbucketServer::Representation::PullRequestComment,
+ file_type: 'REMOVED',
+ from_sha: sample.commits.first,
+ to_sha: sample.commits.last,
+ file_path: '.gitmodules',
+ old_pos: 8,
+ new_pos: 9,
+ note: 'This is a note with an invalid line position.',
+ author_email: project.owner.email,
+ author_username: 'Owner',
+ comments: [reply],
+ created_at: now,
+ updated_at: now,
+ parent_comment: nil)
+
+ inline_comment = instance_double(
+ BitbucketServer::Representation::Activity,
+ comment?: true,
+ inline_comment?: true,
+ merge_event?: false,
+ comment: inline_note)
+
+ allow(reply).to receive(:parent_comment).and_return(inline_note)
+
+ expect(subject.client).to receive(:activities).and_return([inline_comment])
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ merge_request = MergeRequest.first
+ expect(merge_request.notes.count).to eq(2)
+ notes = merge_request.notes
+
+ expect(notes.first.note).to start_with('*Comment on .gitmodules')
+ expect(notes.second.note).to start_with('*Comment on .gitmodules')
+ end
+ end
+
+ describe 'inaccessible branches' do
+ let(:id) { 10 }
+ let(:temp_branch_from) { "gitlab/import/pull-request/#{id}/from" }
+ let(:temp_branch_to) { "gitlab/import/pull-request/#{id}/to" }
+
+ before do
+ pull_request = instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: id,
+ source_branch_sha: '12345678',
+ source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch,
+ target_branch_sha: '98765432',
+ target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch,
+ title: 'This is a title',
+ description: 'This is a test pull request',
+ state: 'merged',
+ author: 'Test Author',
+ author_email: project.owner.email,
+ created_at: Time.now,
+ updated_at: Time.now,
+ merged?: true)
+
+ expect(subject.client).to receive(:pull_requests).and_return([pull_request])
+ expect(subject.client).to receive(:activities).and_return([])
+ expect(subject).to receive(:import_repository).twice
+ end
+
+ it '#restore_branches' do
+ expect(subject).to receive(:restore_branches).and_call_original
+ expect(subject).to receive(:delete_temp_branches)
+ expect(subject.client).to receive(:create_branch)
+ .with(project_key, repo_slug,
+ temp_branch_from,
+ '12345678')
+ expect(subject.client).to receive(:create_branch)
+ .with(project_key, repo_slug,
+ temp_branch_to,
+ '98765432')
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ end
+
+ it '#delete_temp_branches' do
+ expect(subject.client).to receive(:create_branch).twice
+ expect(subject).to receive(:delete_temp_branches).and_call_original
+ expect(subject.client).to receive(:delete_branch)
+ .with(project_key, repo_slug,
+ temp_branch_from,
+ '12345678')
+ expect(subject.client).to receive(:delete_branch)
+ .with(project_key, repo_slug,
+ temp_branch_to,
+ '98765432')
+ expect(project.repository).to receive(:delete_branch).with(temp_branch_from)
+ expect(project.repository).to receive(:delete_branch).with(temp_branch_to)
+
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb
index 25827423914..94abf9679c4 100644
--- a/spec/lib/gitlab/import_sources_spec.rb
+++ b/spec/lib/gitlab/import_sources_spec.rb
@@ -5,15 +5,16 @@ describe Gitlab::ImportSources do
it 'returns a hash' do
expected =
{
- 'GitHub' => 'github',
- 'Bitbucket' => 'bitbucket',
- 'GitLab.com' => 'gitlab',
- 'Google Code' => 'google_code',
- 'FogBugz' => 'fogbugz',
- 'Repo by URL' => 'git',
- 'GitLab export' => 'gitlab_project',
- 'Gitea' => 'gitea',
- 'Manifest file' => 'manifest'
+ 'GitHub' => 'github',
+ 'Bitbucket Cloud' => 'bitbucket',
+ 'Bitbucket Server' => 'bitbucket_server',
+ 'GitLab.com' => 'gitlab',
+ 'Google Code' => 'google_code',
+ 'FogBugz' => 'fogbugz',
+ 'Repo by URL' => 'git',
+ 'GitLab export' => 'gitlab_project',
+ 'Gitea' => 'gitea',
+ 'Manifest file' => 'manifest'
}
expect(described_class.options).to eq(expected)
@@ -26,6 +27,7 @@ describe Gitlab::ImportSources do
%w(
github
bitbucket
+ bitbucket_server
gitlab
google_code
fogbugz
@@ -45,6 +47,7 @@ describe Gitlab::ImportSources do
%w(
github
bitbucket
+ bitbucket_server
gitlab
google_code
fogbugz
@@ -60,6 +63,7 @@ describe Gitlab::ImportSources do
import_sources = {
'github' => Gitlab::GithubImport::ParallelImporter,
'bitbucket' => Gitlab::BitbucketImport::Importer,
+ 'bitbucket_server' => Gitlab::BitbucketServerImport::Importer,
'gitlab' => Gitlab::GitlabImport::Importer,
'google_code' => Gitlab::GoogleCodeImport::Importer,
'fogbugz' => Gitlab::FogbugzImport::Importer,
@@ -79,7 +83,8 @@ describe Gitlab::ImportSources do
describe '.title' do
import_sources = {
'github' => 'GitHub',
- 'bitbucket' => 'Bitbucket',
+ 'bitbucket' => 'Bitbucket Cloud',
+ 'bitbucket_server' => 'Bitbucket Server',
'gitlab' => 'GitLab.com',
'google_code' => 'Google Code',
'fogbugz' => 'FogBugz',
@@ -97,7 +102,7 @@ describe Gitlab::ImportSources do
end
describe 'imports_repository? checker' do
- let(:allowed_importers) { %w[github gitlab_project] }
+ let(:allowed_importers) { %w[github gitlab_project bitbucket_server] }
it 'fails if any importer other than the allowed ones implements this method' do
current_importers = described_class.values.select { |kind| described_class.importer(kind).try(:imports_repository?) }
diff --git a/spec/lib/gitlab/kubernetes/config_map_spec.rb b/spec/lib/gitlab/kubernetes/config_map_spec.rb
index e253b291277..fe65d03875f 100644
--- a/spec/lib/gitlab/kubernetes/config_map_spec.rb
+++ b/spec/lib/gitlab/kubernetes/config_map_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::Kubernetes::ConfigMap do
let(:kubeclient) { double('kubernetes client') }
let(:application) { create(:clusters_applications_prometheus) }
- let(:config_map) { described_class.new(application.name, application.values) }
+ let(:config_map) { described_class.new(application.name, application.files) }
let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
let(:metadata) do
@@ -15,7 +15,7 @@ describe Gitlab::Kubernetes::ConfigMap do
end
describe '#generate' do
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: application.files) }
subject { config_map.generate }
it 'should build a Kubeclient Resource' do
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
index 6e9b4ca0869..341f71a3e49 100644
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -39,7 +39,7 @@ describe Gitlab::Kubernetes::Helm::Api do
end
context 'with a ConfigMap' do
- let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.values).generate }
+ let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.files).generate }
it 'creates a ConfigMap on kubeclient' do
expect(client).to receive(:create_config_map).with(resource).once
diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
index 7be8be54d5e..d50616e95e8 100644
--- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
@@ -2,7 +2,25 @@ require 'spec_helper'
describe Gitlab::Kubernetes::Helm::BaseCommand do
let(:application) { create(:clusters_applications_helm) }
- let(:base_command) { described_class.new(application.name) }
+ let(:test_class) do
+ Class.new do
+ include Gitlab::Kubernetes::Helm::BaseCommand
+
+ def name
+ "test-class-name"
+ end
+
+ def files
+ {
+ some: 'value'
+ }
+ end
+ end
+ end
+
+ let(:base_command) do
+ test_class.new
+ end
subject { base_command }
@@ -18,15 +36,9 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do
end
end
- describe '#config_map?' do
- subject { base_command.config_map? }
-
- it { is_expected.to be_falsy }
- end
-
describe '#pod_name' do
subject { base_command.pod_name }
- it { is_expected.to eq('install-helm') }
+ it { is_expected.to eq('install-test-class-name') }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb
new file mode 100644
index 00000000000..167bee22fc3
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm::Certificate do
+ describe '.generate_root' do
+ subject { described_class.generate_root }
+
+ it 'should generate a root CA that expires a long way in the future' do
+ expect(subject.cert.not_after).to be > 999.years.from_now
+ end
+ end
+
+ describe '#issue' do
+ subject { described_class.generate_root.issue }
+
+ it 'should generate a cert that expires soon' do
+ expect(subject.cert.not_after).to be < 60.minutes.from_now
+ end
+
+ context 'passing in INFINITE_EXPIRY' do
+ subject { described_class.generate_root.issue(expires_in: described_class::INFINITE_EXPIRY) }
+
+ it 'should generate a cert that expires a long way in the future' do
+ expect(subject.cert.not_after).to be > 999.years.from_now
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
index 89e36a298f8..dcbc046cf00 100644
--- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
@@ -2,9 +2,9 @@ require 'spec_helper'
describe Gitlab::Kubernetes::Helm::InitCommand do
let(:application) { create(:clusters_applications_helm) }
- let(:commands) { 'helm init >/dev/null' }
+ let(:commands) { 'helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem >/dev/null' }
- subject { described_class.new(application.name) }
+ subject { described_class.new(name: application.name, files: {}) }
it_behaves_like 'helm commands'
end
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
index cd456a45287..982e2f41043 100644
--- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
@@ -1,83 +1,82 @@
require 'rails_helper'
describe Gitlab::Kubernetes::Helm::InstallCommand do
- let(:application) { create(:clusters_applications_prometheus) }
- let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:install_command) { application.install_command }
+ let(:files) { { 'ca.pem': 'some file content' } }
+ let(:repository) { 'https://repository.example.com' }
+ let(:version) { '1.2.3' }
+
+ let(:install_command) do
+ described_class.new(
+ name: 'app-name',
+ chart: 'chart-name',
+ files: files,
+ version: version, repository: repository
+ )
+ end
subject { install_command }
- context 'for ingress' do
- let(:application) { create(:clusters_applications_ingress) }
-
- it_behaves_like 'helm commands' do
- let(:commands) do
- <<~EOS
- helm init --client-only >/dev/null
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
- EOS
- end
+ it_behaves_like 'helm commands' do
+ let(:commands) do
+ <<~EOS
+ helm init --client-only >/dev/null
+ helm repo add app-name https://repository.example.com
+ helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
+ EOS
end
end
- context 'for prometheus' do
- let(:application) { create(:clusters_applications_prometheus) }
+ context 'when there is no repository' do
+ let(:repository) { nil }
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- context 'for runner' do
- let(:ci_runner) { create(:ci_runner) }
- let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
+ context 'when there is no ca.pem file' do
+ let(:files) { { 'file.txt': 'some content' } }
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
- helm repo add #{application.name} #{application.repository}
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ helm repo add app-name https://repository.example.com
+ helm install chart-name --name app-name --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- context 'for jupyter' do
- let(:application) { create(:clusters_applications_jupyter) }
+ context 'when there is no version' do
+ let(:version) { nil }
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
- helm repo add #{application.name} #{application.repository}
- helm install #{application.chart} --name #{application.name} --version #{application.version} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ helm repo add app-name https://repository.example.com
+ helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null
EOS
end
end
end
- describe '#config_map?' do
- subject { install_command.config_map? }
-
- it { is_expected.to be_truthy }
- end
-
describe '#config_map_resource' do
let(:metadata) do
{
- name: "values-content-configuration-#{application.name}",
- namespace: namespace,
- labels: { name: "values-content-configuration-#{application.name}" }
+ name: "values-content-configuration-app-name",
+ namespace: 'gitlab-managed-apps',
+ labels: { name: "values-content-configuration-app-name" }
}
end
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
subject { install_command.config_map_resource }
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
index 43adc80d576..ec64193c0b2 100644
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -2,14 +2,13 @@ require 'rails_helper'
describe Gitlab::Kubernetes::Helm::Pod do
describe '#generate' do
- let(:cluster) { create(:cluster) }
- let(:app) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let(:app) { create(:clusters_applications_prometheus) }
let(:command) { app.install_command }
let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
subject { described_class.new(command, namespace) }
- shared_examples 'helm pod' do
+ context 'with a command' do
it 'should generate a Kubeclient::Resource' do
expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
end
@@ -41,10 +40,6 @@ describe Gitlab::Kubernetes::Helm::Pod do
spec = subject.generate.spec
expect(spec.restartPolicy).to eq('Never')
end
- end
-
- context 'with a install command' do
- it_behaves_like 'helm pod'
it 'should include volumes for the container' do
container = subject.generate.spec.containers.first
@@ -60,24 +55,8 @@ describe Gitlab::Kubernetes::Helm::Pod do
it 'should mount configMap specification in the volume' do
volume = subject.generate.spec.volumes.first
expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
- expect(volume.configMap['items'].first['key']).to eq('values')
- expect(volume.configMap['items'].first['path']).to eq('values.yaml')
- end
- end
-
- context 'with a init command' do
- let(:app) { create(:clusters_applications_helm, cluster: cluster) }
-
- it_behaves_like 'helm pod'
-
- it 'should not include volumeMounts inside the container' do
- container = subject.generate.spec.containers.first
- expect(container.volumeMounts).to be_nil
- end
-
- it 'should not a volume inside the specification' do
- spec = subject.generate.spec
- expect(spec.volumes).to be_nil
+ expect(volume.configMap['items'].first['key']).to eq(:'values.yaml')
+ expect(volume.configMap['items'].first['path']).to eq(:'values.yaml')
end
end
end
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
index 0eb1e3876e2..e5b2bdc8a4e 100644
--- a/spec/models/clusters/applications/helm_spec.rb
+++ b/spec/models/clusters/applications/helm_spec.rb
@@ -6,13 +6,24 @@ describe Clusters::Applications::Helm do
describe '.installed' do
subject { described_class.installed }
- let!(:cluster) { create(:clusters_applications_helm, :installed) }
+ let!(:installed_cluster) { create(:clusters_applications_helm, :installed) }
before do
create(:clusters_applications_helm, :errored)
end
- it { is_expected.to contain_exactly(cluster) }
+ it { is_expected.to contain_exactly(installed_cluster) }
+ end
+
+ describe '#issue_client_cert' do
+ let(:application) { create(:clusters_applications_helm) }
+ subject { application.issue_client_cert }
+
+ it 'returns a new cert' do
+ is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::Certificate)
+ expect(subject.cert_string).not_to eq(application.ca_cert)
+ expect(subject.key_string).not_to eq(application.ca_key)
+ end
end
describe '#install_command' do
@@ -25,5 +36,16 @@ describe Clusters::Applications::Helm do
it 'should be initialized with 1 arguments' do
expect(subject.name).to eq('helm')
end
+
+ it 'should have cert files' do
+ expect(subject.files[:'ca.pem']).to be_present
+ expect(subject.files[:'ca.pem']).to eq(helm.ca_cert)
+
+ expect(subject.files[:'cert.pem']).to be_present
+ expect(subject.files[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem'])
+ expect(cert.not_after).to be > 999.years.from_now
+ end
end
end
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index d378248d5d6..21f75ced8c3 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -88,7 +88,7 @@ describe Clusters::Applications::Ingress do
expect(subject.name).to eq('ingress')
expect(subject.chart).to eq('stable/nginx-ingress')
expect(subject.version).to eq('0.23.0')
- expect(subject.values).to eq(ingress.values)
+ expect(subject.files).to eq(ingress.files)
end
context 'application failed to install previously' do
@@ -100,14 +100,40 @@ describe Clusters::Applications::Ingress do
end
end
- describe '#values' do
- subject { ingress.values }
+ describe '#files' do
+ let(:application) { ingress }
+ let(:values) { subject[:'values.yaml'] }
- it 'should include ingress valid keys' do
- is_expected.to include('image')
- is_expected.to include('repository')
- is_expected.to include('stats')
- is_expected.to include('podAnnotations')
+ subject { application.files }
+
+ it 'should include ingress valid keys in values' do
+ expect(values).to include('image')
+ expect(values).to include('repository')
+ expect(values).to include('stats')
+ expect(values).to include('podAnnotations')
+ end
+
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
end
end
end
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index e0d57ac65f7..027b732681b 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -52,7 +52,7 @@ describe Clusters::Applications::Jupyter do
expect(subject.chart).to eq('jupyter/jupyterhub')
expect(subject.version).to eq('v0.6')
expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/')
- expect(subject.values).to eq(jupyter.values)
+ expect(subject.files).to eq(jupyter.files)
end
context 'application failed to install previously' do
@@ -64,19 +64,43 @@ describe Clusters::Applications::Jupyter do
end
end
- describe '#values' do
- let(:jupyter) { create(:clusters_applications_jupyter) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_jupyter) }
+ let(:values) { subject[:'values.yaml'] }
- subject { jupyter.values }
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
+
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include valid values' do
- is_expected.to include('ingress')
- is_expected.to include('hub')
- is_expected.to include('rbac')
- is_expected.to include('proxy')
- is_expected.to include('auth')
- is_expected.to include("clientId: #{jupyter.oauth_application.uid}")
- is_expected.to include("callbackUrl: #{jupyter.callback_url}")
+ expect(values).to include('ingress')
+ expect(values).to include('hub')
+ expect(values).to include('rbac')
+ expect(values).to include('proxy')
+ expect(values).to include('auth')
+ expect(values).to match(/clientId: '?#{application.oauth_application.uid}/)
+ expect(values).to match(/callbackUrl: '?#{application.callback_url}/)
end
end
end
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index 3812c65b3b6..7454be3ab2f 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -167,7 +167,7 @@ describe Clusters::Applications::Prometheus do
expect(command.name).to eq('prometheus')
expect(command.chart).to eq('stable/prometheus')
expect(command.version).to eq('6.7.3')
- expect(command.values).to eq(prometheus.values)
+ expect(command.files).to eq(prometheus.files)
end
context 'application failed to install previously' do
@@ -179,17 +179,41 @@ describe Clusters::Applications::Prometheus do
end
end
- describe '#values' do
- let(:prometheus) { create(:clusters_applications_prometheus) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_prometheus) }
+ let(:values) { subject[:'values.yaml'] }
+
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
- subject { prometheus.values }
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include prometheus valid values' do
- is_expected.to include('alertmanager')
- is_expected.to include('kubeStateMetrics')
- is_expected.to include('nodeExporter')
- is_expected.to include('pushgateway')
- is_expected.to include('serverFiles')
+ expect(values).to include('alertmanager')
+ expect(values).to include('kubeStateMetrics')
+ expect(values).to include('nodeExporter')
+ expect(values).to include('pushgateway')
+ expect(values).to include('serverFiles')
end
end
end
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index 526300755b5..d84f125e246 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -47,7 +47,7 @@ describe Clusters::Applications::Runner do
expect(subject.chart).to eq('runner/gitlab-runner')
expect(subject.version).to eq('0.1.31')
expect(subject.repository).to eq('https://charts.gitlab.io')
- expect(subject.values).to eq(gitlab_runner.values)
+ expect(subject.files).to eq(gitlab_runner.files)
end
context 'application failed to install previously' do
@@ -59,27 +59,51 @@ describe Clusters::Applications::Runner do
end
end
- describe '#values' do
- let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
+ describe '#files' do
+ let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
+ let(:values) { subject[:'values.yaml'] }
+
+ subject { application.files }
+
+ it 'should include cert files' do
+ expect(subject[:'ca.pem']).to be_present
+ expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert)
+
+ expect(subject[:'cert.pem']).to be_present
+ expect(subject[:'key.pem']).to be_present
+
+ cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem'])
+ expect(cert.not_after).to be < 60.minutes.from_now
+ end
- subject { gitlab_runner.values }
+ context 'when the helm application does not have a ca_cert' do
+ before do
+ application.cluster.application_helm.ca_cert = nil
+ end
+
+ it 'should not include cert files' do
+ expect(subject[:'ca.pem']).not_to be_present
+ expect(subject[:'cert.pem']).not_to be_present
+ expect(subject[:'key.pem']).not_to be_present
+ end
+ end
it 'should include runner valid values' do
- is_expected.to include('concurrent')
- is_expected.to include('checkInterval')
- is_expected.to include('rbac')
- is_expected.to include('runners')
- is_expected.to include('privileged: true')
- is_expected.to include('image: ubuntu:16.04')
- is_expected.to include('resources')
- is_expected.to include("runnerToken: #{ci_runner.token}")
- is_expected.to include("gitlabUrl: #{Gitlab::Routing.url_helpers.root_url}")
+ expect(values).to include('concurrent')
+ expect(values).to include('checkInterval')
+ expect(values).to include('rbac')
+ expect(values).to include('runners')
+ expect(values).to include('privileged: true')
+ expect(values).to include('image: ubuntu:16.04')
+ expect(values).to include('resources')
+ expect(values).to match(/runnerToken: '?#{ci_runner.token}/)
+ expect(values).to match(/gitlabUrl: '?#{Gitlab::Routing.url_helpers.root_url}/)
end
context 'without a runner' do
let(:project) { create(:project) }
- let(:cluster) { create(:cluster, projects: [project]) }
- let(:gitlab_runner) { create(:clusters_applications_runner, cluster: cluster) }
+ let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) }
+ let(:application) { create(:clusters_applications_runner, cluster: cluster) }
it 'creates a runner' do
expect do
@@ -88,18 +112,18 @@ describe Clusters::Applications::Runner do
end
it 'uses the new runner token' do
- expect(subject).to include("runnerToken: #{gitlab_runner.reload.runner.token}")
+ expect(values).to match(/runnerToken: '?#{application.reload.runner.token}/)
end
it 'assigns the new runner to runner' do
subject
- expect(gitlab_runner.reload.runner).to be_project_type
+ expect(application.reload.runner).to be_project_type
end
end
context 'with duplicated values on vendor/runner/values.yaml' do
- let(:values) do
+ let(:stub_values) do
{
"concurrent" => 4,
"checkInterval" => 3,
@@ -118,11 +142,11 @@ describe Clusters::Applications::Runner do
end
before do
- allow(gitlab_runner).to receive(:chart_values).and_return(values)
+ allow(application).to receive(:chart_values).and_return(stub_values)
end
it 'should overwrite values.yaml' do
- is_expected.to include("privileged: #{gitlab_runner.privileged}")
+ expect(values).to match(/privileged: '?#{application.privileged}/)
end
end
end
diff --git a/spec/models/concerns/avatarable_spec.rb b/spec/models/concerns/avatarable_spec.rb
index 9faf21bfbbd..76f734079b7 100644
--- a/spec/models/concerns/avatarable_spec.rb
+++ b/spec/models/concerns/avatarable_spec.rb
@@ -43,6 +43,10 @@ describe Avatarable do
expect(project.avatar_path(only_path: only_path)).to eq(avatar_path)
end
+ it 'returns the expected avatar path with width parameter' do
+ expect(project.avatar_path(only_path: only_path, size: 128)).to eq(avatar_path + "?width=128")
+ end
+
context "when avatar is stored remotely" do
before do
stub_uploads_object_storage(AvatarUploader)
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 52c52517cca..3ab6a20cd55 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1354,6 +1354,16 @@ describe MergeRequest do
project.default_branch == branch)
end
+ context 'but merged at timestamp cannot be found' do
+ before do
+ allow(subject).to receive(:merged_at) { nil }
+ end
+
+ it 'returns false' do
+ expect(subject.can_be_reverted?(current_user)).to be_falsey
+ end
+ end
+
context 'when the revert commit is mentioned in a note after the MR was merged' do
it 'returns false' do
expect(subject.can_be_reverted?(current_user)).to be_falsey
@@ -1393,6 +1403,63 @@ describe MergeRequest do
end
end
+ describe '#merged_at' do
+ context 'when MR is not merged' do
+ let(:merge_request) { create(:merge_request, :closed) }
+
+ it 'returns nil' do
+ expect(merge_request.merged_at).to be_nil
+ end
+ end
+
+ context 'when metrics has merged_at data' do
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ merge_request.metrics.update!(merged_at: 1.day.ago)
+ end
+
+ it 'returns metrics merged_at' do
+ expect(merge_request.merged_at).to eq(merge_request.metrics.merged_at)
+ end
+ end
+
+ context 'when merged event is persisted, but no metrics merged_at is persisted' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ EventCreateService.new.merge_mr(merge_request, user)
+ end
+
+ it 'returns merged event creation date' do
+ expect(merge_request.merge_event).to be_persisted
+ expect(merge_request.merged_at).to eq(merge_request.merge_event.created_at)
+ end
+ end
+
+ context 'when merging note is persisted, but no metrics or merge event exists' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :merged) }
+
+ before do
+ merge_request.metrics.destroy!
+
+ SystemNoteService.change_status(merge_request,
+ merge_request.target_project,
+ user,
+ merge_request.state, nil)
+ end
+
+ it 'returns merging note creation date' do
+ expect(merge_request.reload.metrics).to be_nil
+ expect(merge_request.merge_event).to be_nil
+ expect(merge_request.notes.count).to eq(1)
+ expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at)
+ end
+ end
+ end
+
describe '#participants' do
let(:project) { create(:project, :public) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 4313d52d60a..03beb9187ed 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -3307,6 +3307,50 @@ describe Project do
end
end
+ describe '#has_auto_devops_implicitly_enabled?' do
+ set(:project) { create(:project) }
+
+ context 'when disabled in settings' do
+ before do
+ stub_application_setting(auto_devops_enabled: false)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+
+ context 'when enabled in settings' do
+ before do
+ stub_application_setting(auto_devops_enabled: true)
+ end
+
+ it 'auto devops is implicitly disabled' do
+ expect(project).to have_auto_devops_implicitly_enabled
+ end
+
+ context 'when explicitly disabled' do
+ before do
+ create(:project_auto_devops, project: project, enabled: false)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+
+ context 'when explicitly enabled' do
+ before do
+ create(:project_auto_devops, project: project, enabled: true)
+ end
+
+ it 'does not have auto devops implicitly disabled' do
+ expect(project).not_to have_auto_devops_implicitly_enabled
+ end
+ end
+ end
+ end
+
describe '#has_auto_devops_implicitly_disabled?' do
set(:project) { create(:project) }
@@ -3341,7 +3385,7 @@ describe Project do
context 'when explicitly enabled' do
before do
- create(:project_auto_devops, project: project)
+ create(:project_auto_devops, project: project, enabled: true)
end
it 'does not have auto devops implicitly disabled' do
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index bd498269798..f29abcf536e 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -7,6 +7,7 @@ describe Todo do
it { is_expected.to belong_to(:author).class_name("User") }
it { is_expected.to belong_to(:note) }
it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:target).touch(true) }
it { is_expected.to belong_to(:user) }
end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 35f1912c1c8..30d68e7dc9d 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -183,13 +183,7 @@ describe GlobalPolicy do
describe 'read instance statistics' do
context 'regular user' do
- context 'when instance statistics are publicly available' do
- before do
- stub_application_setting(instance_statistics_visibility_private: false)
- end
-
- it { is_expected.to be_allowed(:read_instance_statistics) }
- end
+ it { is_expected.to be_allowed(:read_instance_statistics) }
context 'when instance statistics are set to private' do
before do
@@ -203,13 +197,7 @@ describe GlobalPolicy do
context 'admin' do
let(:current_user) { create(:admin) }
- context 'when instance statistics are publicly available' do
- before do
- stub_application_setting(instance_statistics_visibility_private: false)
- end
-
- it { is_expected.to be_allowed(:read_instance_statistics) }
- end
+ it { is_expected.to be_allowed(:read_instance_statistics) }
context 'when instance statistics are set to private' do
before do
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 9a662c21354..3e0f47b84a1 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -25,7 +25,7 @@ describe API::Settings, 'Settings' do
expect(json_response['ed25519_key_restriction']).to eq(0)
expect(json_response['circuitbreaker_failure_count_threshold']).not_to be_nil
expect(json_response['performance_bar_allowed_group_id']).to be_nil
- expect(json_response['instance_statistics_visibility_private']).to be(true)
+ expect(json_response['instance_statistics_visibility_private']).to be(false)
expect(json_response).not_to have_key('performance_bar_allowed_group_path')
expect(json_response).not_to have_key('performance_bar_enabled')
end
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index 2ee8d150dc8..b5cf04e7f22 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
describe API::Todos do
- let(:project_1) { create(:project, :repository) }
+ let(:group) { create(:group) }
+ let(:project_1) { create(:project, :repository, group: group) }
let(:project_2) { create(:project) }
let(:author_1) { create(:user) }
let(:author_2) { create(:user) }
@@ -92,6 +93,17 @@ describe API::Todos do
end
end
+ context 'and using the group filter' do
+ it 'filters based on project_id param' do
+ get api('/todos', john_doe), { group_id: group.id, sort: :target_id }
+
+ expect(response.status).to eq(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ end
+ end
+
context 'and using the action filter' do
it 'filters based on action param' do
get api('/todos', john_doe), { action: 'mentioned' }
diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb
index 93199964a0e..a744ec30b65 100644
--- a/spec/services/clusters/applications/install_service_spec.rb
+++ b/spec/services/clusters/applications/install_service_spec.rb
@@ -47,7 +47,7 @@ describe Clusters::Applications::InstallService do
end
context 'when application cannot be persisted' do
- let(:application) { build(:clusters_applications_helm, :scheduled) }
+ let(:application) { create(:clusters_applications_helm, :scheduled) }
it 'make the application errored' do
expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid)
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index 48d689e11d4..7c5c7409cc1 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -12,13 +12,17 @@ describe Groups::UpdateService do
let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) }
before do
- public_group.add_user(user, Gitlab::Access::MAINTAINER)
+ public_group.add_user(user, Gitlab::Access::OWNER)
create(:project, :public, group: public_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
it "does not change permission level" do
service.execute
expect(public_group.errors.count).to eq(1)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
end
@@ -26,8 +30,10 @@ describe Groups::UpdateService do
let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
before do
- internal_group.add_user(user, Gitlab::Access::MAINTAINER)
+ internal_group.add_user(user, Gitlab::Access::OWNER)
create(:project, :internal, group: internal_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).not_to receive(:perform_in)
end
it "does not change permission level" do
@@ -35,6 +41,24 @@ describe Groups::UpdateService do
expect(internal_group.errors.count).to eq(1)
end
end
+
+ context "internal group with private project" do
+ let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+
+ before do
+ internal_group.add_user(user, Gitlab::Access::OWNER)
+ create(:project, :private, group: internal_group)
+
+ expect(TodosDestroyer::GroupPrivateWorker).to receive(:perform_in)
+ .with(1.hour, internal_group.id)
+ end
+
+ it "changes permission level to private" do
+ service.execute
+ expect(internal_group.visibility_level)
+ .to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
end
context "with parent_id user doesn't have permissions for" do
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index fd69fe04053..bb3f1501f0e 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -114,6 +114,17 @@ describe Projects::CreateService, '#execute' do
end
end
+ context 'import data' do
+ it 'stores import data and URL' do
+ import_data = { data: { 'test' => 'some data' } }
+ project = create_project(user, { name: 'test', import_url: 'http://import-url', import_data: import_data })
+
+ expect(project.import_data).to be_persisted
+ expect(project.import_data.data).to eq(import_data[:data])
+ expect(project.import_url).to eq('http://import-url')
+ end
+ end
+
context 'builds_enabled global setting' do
let(:project) { create_project(user, opts) }
diff --git a/spec/services/todos/destroy/confidential_issue_service_spec.rb b/spec/services/todos/destroy/confidential_issue_service_spec.rb
index 54d1d7e83f1..3294f7509aa 100644
--- a/spec/services/todos/destroy/confidential_issue_service_spec.rb
+++ b/spec/services/todos/destroy/confidential_issue_service_spec.rb
@@ -29,12 +29,8 @@ describe Todos::Destroy::ConfidentialIssueService do
issue.update!(confidential: true)
end
- it 'removes issue todos for a user who is not a project member' do
+ it 'removes issue todos for users who can not access the confidential issue' do
expect { subject }.to change { Todo.count }.from(6).to(4)
-
- expect(user.todos).to match_array([todo_another_non_member])
- expect(author.todos).to match_array([todo_issue_author])
- expect(project_member.todos).to match_array([todo_issue_member])
end
end
diff --git a/spec/services/todos/destroy/entity_leave_service_spec.rb b/spec/services/todos/destroy/entity_leave_service_spec.rb
index bad408a314e..8cb91e7c1b9 100644
--- a/spec/services/todos/destroy/entity_leave_service_spec.rb
+++ b/spec/services/todos/destroy/entity_leave_service_spec.rb
@@ -5,60 +5,120 @@ describe Todos::Destroy::EntityLeaveService do
let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
- let(:issue) { create(:issue, project: project) }
+ let(:issue) { create(:issue, project: project, confidential: true) }
let(:mr) { create(:merge_request, source_project: project) }
let!(:todo_mr_user) { create(:todo, user: user, target: mr, project: project) }
let!(:todo_issue_user) { create(:todo, user: user, target: issue, project: project) }
+ let!(:todo_group_user) { create(:todo, user: user, group: group) }
let!(:todo_issue_user2) { create(:todo, user: user2, target: issue, project: project) }
+ let!(:todo_group_user2) { create(:todo, user: user2, group: group) }
describe '#execute' do
context 'when a user leaves a project' do
subject { described_class.new(user.id, project.id, 'Project').execute }
context 'when project is private' do
- it 'removes todos for the provided user' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ it 'removes project todos for the provided user' do
+ expect { subject }.to change { Todo.count }.from(5).to(3)
- expect(user.todos).to be_empty
- expect(user2.todos).to match_array([todo_issue_user2])
+ expect(user.todos).to match_array([todo_group_user])
+ expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2])
end
- end
- context 'when project is not private' do
- before do
- group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ context 'when the user is member of the project' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
- context 'when a user is not an author of confidential issue' do
+ context 'when the user is a project guest' do
before do
- issue.update!(confidential: true)
+ project.add_guest(user)
end
it 'removes only confidential issues todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ expect { subject }.to change { Todo.count }.from(5).to(4)
end
end
- context 'when a user is an author of confidential issue' do
+ context 'when the user is member of a parent group' do
before do
- issue.update!(author: user, confidential: true)
+ group.add_developer(user)
end
- it 'removes only confidential issues todos' do
+ it 'does not remove any todos' do
expect { subject }.not_to change { Todo.count }
end
end
- context 'when a user is an assignee of confidential issue' do
+ context 'when the user is guest of a parent group' do
before do
- issue.update!(confidential: true)
- issue.assignees << user
+ project.add_guest(user)
end
it 'removes only confidential issues todos' do
- expect { subject }.not_to change { Todo.count }
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+ end
+
+ context 'when project is not private' do
+ before do
+ group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ end
+
+ context 'confidential issues' do
+ context 'when a user is not an author of confidential issue' do
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when a user is an author of confidential issue' do
+ before do
+ issue.update!(author: user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when a user is an assignee of confidential issue' do
+ before do
+ issue.assignees << user
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when a user is a project guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when a user is a project guest but group developer' do
+ before do
+ project.add_guest(user)
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
end
@@ -69,7 +129,7 @@ describe Todos::Destroy::EntityLeaveService do
end
it 'removes only users issue todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ expect { subject }.to change { Todo.count }.from(5).to(4)
end
end
end
@@ -80,40 +140,135 @@ describe Todos::Destroy::EntityLeaveService do
subject { described_class.new(user.id, group.id, 'Group').execute }
context 'when group is private' do
- it 'removes todos for the user' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ it 'removes group and subproject todos for the user' do
+ expect { subject }.to change { Todo.count }.from(5).to(2)
expect(user.todos).to be_empty
- expect(user2.todos).to match_array([todo_issue_user2])
+ expect(user2.todos).to match_array([todo_issue_user2, todo_group_user2])
+ end
+
+ context 'when the user is member of the group' do
+ before do
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when the user is member of the group project but not the group' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
context 'with nested groups', :nested_groups do
let(:subgroup) { create(:group, :private, parent: group) }
+ let(:subgroup2) { create(:group, :private, parent: group) }
let(:subproject) { create(:project, group: subgroup) }
+ let(:subproject2) { create(:project, group: subgroup2) }
- let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) }
+ let!(:todo_subproject_user) { create(:todo, user: user, project: subproject) }
+ let!(:todo_subproject2_user) { create(:todo, user: user, project: subproject2) }
+ let!(:todo_subgroup_user) { create(:todo, user: user, group: subgroup) }
+ let!(:todo_subgroup2_user) { create(:todo, user: user, group: subgroup2) }
let!(:todo_subproject_user2) { create(:todo, user: user2, project: subproject) }
+ let!(:todo_subpgroup_user2) { create(:todo, user: user2, group: subgroup) }
+
+ context 'when the user is not a member of any groups/projects' do
+ it 'removes todos for the user including subprojects todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(4)
+
+ expect(user.todos).to be_empty
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
+ end
+
+ context 'when the user is member of a parent group' do
+ before do
+ parent_group = create(:group)
+ group.update!(parent: parent_group)
+ parent_group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when the user is member of a subgroup' do
+ before do
+ subgroup.add_developer(user)
+ end
- it 'removes todos for the user including subprojects todos' do
- expect { subject }.to change { Todo.count }.from(5).to(2)
+ it 'does not remove group and subproject todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(7)
- expect(user.todos).to be_empty
- expect(user2.todos)
- .to match_array([todo_issue_user2, todo_subproject_user2])
+ expect(user.todos).to match_array([todo_group_user, todo_subgroup_user, todo_subproject_user])
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
+ end
+
+ context 'when the user is member of a child project' do
+ before do
+ subproject.add_developer(user)
+ end
+
+ it 'does not remove subproject and group todos' do
+ expect { subject }.to change { Todo.count }.from(11).to(7)
+
+ expect(user.todos).to match_array([todo_subgroup_user, todo_group_user, todo_subproject_user])
+ expect(user2.todos)
+ .to match_array(
+ [todo_issue_user2, todo_group_user2, todo_subproject_user2, todo_subpgroup_user2]
+ )
+ end
end
end
end
context 'when group is not private' do
before do
- issue.update!(confidential: true)
-
group.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
- it 'removes only confidential issues todos' do
- expect { subject }.to change { Todo.count }.from(3).to(2)
+ context 'when user is not member' do
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when user is a project guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'removes only confidential issues todos' do
+ expect { subject }.to change { Todo.count }.from(5).to(4)
+ end
+ end
+
+ context 'when user is a project guest & group developer' do
+ before do
+ project.add_guest(user)
+ group.add_developer(user)
+ end
+
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
end
end
end
diff --git a/spec/services/todos/destroy/group_private_service_spec.rb b/spec/services/todos/destroy/group_private_service_spec.rb
new file mode 100644
index 00000000000..2f49b68f544
--- /dev/null
+++ b/spec/services/todos/destroy/group_private_service_spec.rb
@@ -0,0 +1,69 @@
+require 'spec_helper'
+
+describe Todos::Destroy::GroupPrivateService do
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, group: group) }
+ let(:user) { create(:user) }
+ let(:group_member) { create(:user) }
+ let(:project_member) { create(:user) }
+
+ let!(:todo_non_member) { create(:todo, user: user, group: group) }
+ let!(:todo_another_non_member) { create(:todo, user: user, group: group) }
+ let!(:todo_group_member) { create(:todo, user: group_member, group: group) }
+ let!(:todo_project_member) { create(:todo, user: project_member, group: group) }
+
+ describe '#execute' do
+ before do
+ group.add_developer(group_member)
+ project.add_developer(project_member)
+ end
+
+ subject { described_class.new(group.id).execute }
+
+ context 'when a group set to private' do
+ before do
+ group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'removes todos only for users who are not group users' do
+ expect { subject }.to change { Todo.count }.from(4).to(2)
+
+ expect(user.todos).to be_empty
+ expect(group_member.todos).to match_array([todo_group_member])
+ expect(project_member.todos).to match_array([todo_project_member])
+ end
+
+ context 'with nested groups', :nested_groups do
+ let(:parent_group) { create(:group) }
+ let(:subgroup) { create(:group, :private, parent: group) }
+ let(:subproject) { create(:project, group: subgroup) }
+
+ let(:parent_member) { create(:user) }
+ let(:subgroup_member) { create(:user) }
+ let(:subgproject_member) { create(:user) }
+
+ let!(:todo_parent_member) { create(:todo, user: parent_member, group: group) }
+ let!(:todo_subgroup_member) { create(:todo, user: subgroup_member, group: group) }
+ let!(:todo_subproject_member) { create(:todo, user: subgproject_member, group: group) }
+
+ before do
+ group.update!(parent: parent_group)
+
+ parent_group.add_developer(parent_member)
+ subgroup.add_developer(subgroup_member)
+ subproject.add_developer(subgproject_member)
+ end
+
+ it 'removes todos only for users who are not group users' do
+ expect { subject }.to change { Todo.count }.from(7).to(5)
+ end
+ end
+ end
+
+ context 'when group is not private' do
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/todos/destroy/project_private_service_spec.rb b/spec/services/todos/destroy/project_private_service_spec.rb
index badf3f913a5..128d3487514 100644
--- a/spec/services/todos/destroy/project_private_service_spec.rb
+++ b/spec/services/todos/destroy/project_private_service_spec.rb
@@ -1,17 +1,21 @@
require 'spec_helper'
describe Todos::Destroy::ProjectPrivateService do
- let(:project) { create(:project, :public) }
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:project, :public, group: group) }
let(:user) { create(:user) }
let(:project_member) { create(:user) }
+ let(:group_member) { create(:user) }
- let!(:todo_issue_non_member) { create(:todo, user: user, project: project) }
- let!(:todo_issue_member) { create(:todo, user: project_member, project: project) }
- let!(:todo_another_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo2_non_member) { create(:todo, user: user, project: project) }
+ let!(:todo_member) { create(:todo, user: project_member, project: project) }
+ let!(:todo_group_member) { create(:todo, user: group_member, project: project) }
describe '#execute' do
before do
project.add_developer(project_member)
+ group.add_developer(group_member)
end
subject { described_class.new(project.id).execute }
@@ -22,10 +26,11 @@ describe Todos::Destroy::ProjectPrivateService do
end
it 'removes issue todos for a user who is not a member' do
- expect { subject }.to change { Todo.count }.from(3).to(1)
+ expect { subject }.to change { Todo.count }.from(4).to(2)
expect(user.todos).to be_empty
- expect(project_member.todos).to match_array([todo_issue_member])
+ expect(project_member.todos).to match_array([todo_member])
+ expect(group_member.todos).to match_array([todo_group_member])
end
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 8e1d4cfe269..9c6486a35c4 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -51,7 +51,8 @@ module TestEnv
'add-pdf-text-binary' => '79faa7b',
'add_images_and_changes' => '010d106',
'update-gitlab-shell-v-6-0-1' => '2f61d70',
- 'update-gitlab-shell-v-6-0-3' => 'de78448'
+ 'update-gitlab-shell-v-6-0-3' => 'de78448',
+ '2-mb-file' => 'bf12d25'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
diff --git a/spec/support/shared_examples/controllers/todos_shared_examples.rb b/spec/support/shared_examples/controllers/todos_shared_examples.rb
new file mode 100644
index 00000000000..bafd9bac8d0
--- /dev/null
+++ b/spec/support/shared_examples/controllers/todos_shared_examples.rb
@@ -0,0 +1,43 @@
+shared_examples 'todos actions' do
+ context 'when authorized' do
+ before do
+ sign_in(user)
+ parent.add_developer(user)
+ end
+
+ it 'creates todo' do
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns todo path and pending count' do
+ post_create
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['count']).to eq 1
+ expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
+ end
+ end
+
+ context 'when not authorized for project/group' do
+ it 'does not create todo for resource that user has no access to' do
+ sign_in(user)
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'does not create todo when user is not logged in' do
+ expect do
+ post_create
+ end.to change { user.todos.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(parent.is_a?(Group) ? 401 : 302)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/instance_statistics_controllers_shared_examples.rb b/spec/support/shared_examples/instance_statistics_controllers_shared_examples.rb
index 9f0604b5f8e..5334af841e1 100644
--- a/spec/support/shared_examples/instance_statistics_controllers_shared_examples.rb
+++ b/spec/support/shared_examples/instance_statistics_controllers_shared_examples.rb
@@ -9,8 +9,6 @@ shared_examples 'instance statistics availability' do
describe 'GET #index' do
it 'is available when the feature is available publicly' do
- stub_application_setting(instance_statistics_visibility_private: false)
-
get :index
expect(response).to have_gitlab_http_status(:success)
diff --git a/spec/workers/todos_destroyer/group_private_worker_spec.rb b/spec/workers/todos_destroyer/group_private_worker_spec.rb
new file mode 100644
index 00000000000..fcc38989ced
--- /dev/null
+++ b/spec/workers/todos_destroyer/group_private_worker_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe TodosDestroyer::GroupPrivateWorker do
+ it "calls the Todos::Destroy::GroupPrivateService with the params it was given" do
+ service = double
+
+ expect(::Todos::Destroy::GroupPrivateService).to receive(:new).with(100).and_return(service)
+ expect(service).to receive(:execute)
+
+ described_class.new.perform(100)
+ end
+end