summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/boards/services/board_service.js4
-rw-r--r--app/assets/javascripts/clusters.js5
-rw-r--r--app/assets/javascripts/dispatcher.js18
-rw-r--r--app/assets/javascripts/filterable_list.js7
-rw-r--r--app/assets/javascripts/groups/components/app.vue194
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue38
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue228
-rw-r--r--app/assets/javascripts/groups/components/groups.vue26
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue93
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue25
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue98
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue34
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js64
-rw-r--r--app/assets/javascripts/groups/index.js195
-rw-r--r--app/assets/javascripts/groups/new_group_child.js62
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js (renamed from app/assets/javascripts/groups/services/groups_service.js)8
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js105
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js167
-rw-r--r--app/assets/javascripts/jobs/components/header.vue10
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js6
-rw-r--r--app/assets/javascripts/milestone_select.js2
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue3
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/banner.scss25
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss1
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/lists.scss89
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/pages/clusters.scss2
-rw-r--r--app/assets/stylesheets/pages/editor.scss2
-rw-r--r--app/assets/stylesheets/pages/groups.scss115
-rw-r--r--app/assets/stylesheets/pages/issuable.scss4
-rw-r--r--app/controllers/concerns/group_tree.rb24
-rw-r--r--app/controllers/dashboard/groups_controller.rb33
-rw-r--r--app/controllers/explore/groups_controller.rb16
-rw-r--r--app/controllers/groups/children_controller.rb39
-rw-r--r--app/controllers/groups_controller.rb33
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb2
-rw-r--r--app/finders/group_descendants_finder.rb153
-rw-r--r--app/finders/group_projects_finder.rb1
-rw-r--r--app/helpers/application_settings_helper.rb32
-rw-r--r--app/helpers/sorting_helper.rb11
-rw-r--r--app/models/application_setting.rb7
-rw-r--r--app/models/concerns/group_descendant.rb56
-rw-r--r--app/models/concerns/loaded_in_group_list.rb72
-rw-r--r--app/models/gcp/cluster.rb3
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/project.rb14
-rw-r--r--app/models/repository.rb14
-rw-r--r--app/serializers/base_serializer.rb7
-rw-r--r--app/serializers/concerns/with_pagination.rb22
-rw-r--r--app/serializers/environment_serializer.rb12
-rw-r--r--app/serializers/group_child_entity.rb77
-rw-r--r--app/serializers/group_child_serializer.rb51
-rw-r--r--app/serializers/group_serializer.rb18
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb2
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb4
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb48
-rw-r--r--app/views/admin/application_settings/_form.html.haml26
-rw-r--r--app/views/dashboard/_groups_head.html.haml6
-rw-r--r--app/views/dashboard/groups/_empty_state.html.haml7
-rw-r--r--app/views/dashboard/groups/_groups.html.haml9
-rw-r--r--app/views/dashboard/groups/index.html.haml4
-rw-r--r--app/views/explore/groups/_groups.html.haml6
-rw-r--r--app/views/explore/groups/index.html.haml9
-rw-r--r--app/views/groups/_children.html.haml5
-rw-r--r--app/views/groups/_show_nav.html.haml8
-rw-r--r--app/views/groups/show.html.haml42
-rw-r--r--app/views/groups/subgroups.html.haml21
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/clusters/_advanced_settings.html.haml14
-rw-r--r--app/views/projects/clusters/show.html.haml90
-rw-r--r--app/views/projects/empty.html.haml9
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml4
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/new.html.haml8
-rw-r--r--app/views/projects/pipelines/index.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml29
-rw-r--r--app/views/shared/groups/_dropdown.html.haml44
-rw-r--r--app/views/shared/groups/_empty_state.html.haml7
-rw-r--r--app/views/shared/groups/_group.html.haml2
-rw-r--r--app/views/shared/groups/_list.html.haml2
-rw-r--r--app/views/shared/groups/_search_form.html.haml4
-rw-r--r--app/views/shared/icons/_icon_autodevops.svg2
-rw-r--r--app/views/shared/projects/_dropdown.html.haml2
-rwxr-xr-xbin/changelog1
-rw-r--r--changelogs/unreleased/27654-retry-button.yml5
-rw-r--r--changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml6
-rw-r--r--changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml5
-rw-r--r--changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml5
-rw-r--r--changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml5
-rw-r--r--changelogs/unreleased/an-popen-deadline.yml5
-rw-r--r--changelogs/unreleased/bvl-circuitbreaker-improvements.yml5
-rw-r--r--changelogs/unreleased/bvl-group-trees.yml5
-rw-r--r--changelogs/unreleased/fix_diff_parsing.yml5
-rw-r--r--changelogs/unreleased/fix_global_board_routes_39073.yml5
-rw-r--r--changelogs/unreleased/fl-autodevops-fix.yml5
-rw-r--r--changelogs/unreleased/sha-handling.yml5
-rw-r--r--changelogs/unreleased/winh-indeterminate-dropdown.yml5
-rw-r--r--changelogs/unreleased/zj-add-performance-changelog-cat.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/gitlab.yml.example8
-rw-r--r--config/initializers/1_settings.rb23
-rw-r--r--config/routes.rb26
-rw-r--r--config/routes/group.rb3
-rw-r--r--config/webpack.config.js2
-rw-r--r--db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb27
-rw-r--r--db/schema.rb6
-rw-r--r--doc/administration/img/circuitbreaker_config.pngbin0 -> 213210 bytes
-rw-r--r--doc/administration/raketasks/github_import.md1
-rw-r--r--doc/administration/repository_storage_paths.md55
-rw-r--r--doc/api/repositories.md2
-rw-r--r--doc/api/settings.md4
-rw-r--r--doc/ci/README.md2
-rw-r--r--doc/development/gitaly.md24
-rw-r--r--doc/gitlab-basics/create-project.md2
-rw-r--r--doc/gitlab-basics/img/create_new_project_info.pngbin82725 -> 75470 bytes
-rw-r--r--doc/install/installation.md2
-rw-r--r--doc/install/requirements.md4
-rw-r--r--doc/user/project/issues/automatic_issue_closing.md3
-rw-r--r--features/explore/groups.feature12
-rw-r--r--lib/api/api.rb7
-rw-r--r--lib/api/commits.rb14
-rw-r--r--lib/api/repositories.rb4
-rw-r--r--lib/api/v3/builds.rb2
-rw-r--r--lib/api/v3/commits.rb14
-rw-r--r--lib/api/v3/repositories.rb6
-rw-r--r--lib/github/representation/comment.rb2
-rw-r--r--lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb1
-rw-r--r--lib/gitlab/bitbucket_import/importer.rb2
-rw-r--r--lib/gitlab/conflict/file.rb88
-rw-r--r--lib/gitlab/conflict/file_collection.rb68
-rw-r--r--lib/gitlab/conflict/parser.rb74
-rw-r--r--lib/gitlab/conflict/resolution_error.rb5
-rw-r--r--lib/gitlab/diff/file.rb2
-rw-r--r--lib/gitlab/diff/line_code.rb9
-rw-r--r--lib/gitlab/diff/parser.rb4
-rw-r--r--lib/gitlab/git.rb4
-rw-r--r--lib/gitlab/git/conflict/file.rb86
-rw-r--r--lib/gitlab/git/conflict/parser.rb91
-rw-r--r--lib/gitlab/git/conflict/resolver.rb91
-rw-r--r--lib/gitlab/git/env.rb11
-rw-r--r--lib/gitlab/git/popen.rb63
-rw-r--r--lib/gitlab/git/repository.rb25
-rw-r--r--lib/gitlab/git/rev_list.rb2
-rw-r--r--lib/gitlab/git/storage/circuit_breaker.rb12
-rw-r--r--lib/gitlab/git/storage/circuit_breaker_settings.rb29
-rw-r--r--lib/gitlab/git/storage/null_circuit_breaker.rb13
-rw-r--r--lib/gitlab/gitaly_client.rb20
-rw-r--r--lib/gitlab/github_import/comment_formatter.rb2
-rw-r--r--lib/gitlab/group_hierarchy.rb30
-rw-r--r--lib/gitlab/multi_collection_paginator.rb61
-rw-r--r--lib/gitlab/path_regex.rb2
-rw-r--r--lib/gitlab/sql/union.rb6
-rw-r--r--lib/gitlab/usage_data.rb2
-rw-r--r--lib/gitlab/utils/merge_hash.rb117
-rw-r--r--locale/gitlab.pot217
-rw-r--r--package.json2
-rw-r--r--qa/qa.rb5
-rw-r--r--qa/qa/scenario/entrypoint.rb36
-rw-r--r--qa/qa/scenario/test/instance.rb17
-rw-r--r--qa/qa/scenario/test/integration/mattermost.rb15
-rw-r--r--qa/qa/specs/features/login/standard_spec.rb2
-rw-r--r--qa/qa/specs/features/mattermost/group_create_spec.rb16
-rw-r--r--qa/qa/specs/features/project/create_spec.rb2
-rw-r--r--qa/qa/specs/features/repository/clone_spec.rb2
-rw-r--r--qa/qa/specs/features/repository/push_spec.rb4
-rw-r--r--qa/qa/specs/runner.rb9
-rw-r--r--spec/bin/changelog_spec.rb2
-rw-r--r--spec/controllers/concerns/group_tree_spec.rb89
-rw-r--r--spec/controllers/dashboard/groups_controller_spec.rb23
-rw-r--r--spec/controllers/explore/groups_controller_spec.rb23
-rw-r--r--spec/controllers/groups/children_controller_spec.rb286
-rw-r--r--spec/controllers/groups_controller_spec.rb108
-rw-r--r--spec/controllers/projects/merge_requests/conflicts_controller_spec.rb8
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb32
-rw-r--r--spec/features/admin/admin_health_check_spec.rb4
-rw-r--r--spec/features/dashboard/groups_list_spec.rb81
-rw-r--r--spec/features/explore/groups_list_spec.rb13
-rw-r--r--spec/features/groups/show_spec.rb31
-rw-r--r--spec/features/groups_spec.rb23
-rw-r--r--spec/finders/group_descendants_finder_spec.rb166
-rw-r--r--spec/fixtures/pages.tar.gzbin1795 -> 1884 bytes
-rw-r--r--spec/fixtures/pages.zipbin1851 -> 2338 bytes
-rw-r--r--spec/initializers/settings_spec.rb20
-rw-r--r--spec/javascripts/groups/components/app_spec.js443
-rw-r--r--spec/javascripts/groups/components/group_folder_spec.js66
-rw-r--r--spec/javascripts/groups/components/group_item_spec.js177
-rw-r--r--spec/javascripts/groups/components/groups_spec.js70
-rw-r--r--spec/javascripts/groups/components/item_actions_spec.js110
-rw-r--r--spec/javascripts/groups/components/item_caret_spec.js40
-rw-r--r--spec/javascripts/groups/components/item_stats_spec.js159
-rw-r--r--spec/javascripts/groups/components/item_type_icon_spec.js54
-rw-r--r--spec/javascripts/groups/group_item_spec.js102
-rw-r--r--spec/javascripts/groups/groups_spec.js99
-rw-r--r--spec/javascripts/groups/mock_data.js470
-rw-r--r--spec/javascripts/groups/service/groups_service_spec.js42
-rw-r--r--spec/javascripts/groups/store/groups_store_spec.js110
-rw-r--r--spec/javascripts/jobs/header_spec.js7
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js6
-rw-r--r--spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb21
-rw-r--r--spec/lib/gitlab/conflict/file_collection_spec.rb2
-rw-r--r--spec/lib/gitlab/conflict/file_spec.rb18
-rw-r--r--spec/lib/gitlab/diff/parser_spec.rb17
-rw-r--r--spec/lib/gitlab/diff/position_spec.rb20
-rw-r--r--spec/lib/gitlab/git/conflict/parser_spec.rb (renamed from spec/lib/gitlab/conflict/parser_spec.rb)68
-rw-r--r--spec/lib/gitlab/git/env_spec.rb42
-rw-r--r--spec/lib/gitlab/git/popen_spec.rb132
-rw-r--r--spec/lib/gitlab/git/storage/circuit_breaker_spec.rb54
-rw-r--r--spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb4
-rw-r--r--spec/lib/gitlab/group_hierarchy_spec.rb28
-rw-r--r--spec/lib/gitlab/multi_collection_paginator_spec.rb46
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb14
-rw-r--r--spec/lib/gitlab/sql/union_spec.rb7
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb2
-rw-r--r--spec/lib/gitlab/utils/merge_hash_spec.rb33
-rw-r--r--spec/models/application_setting_spec.rb13
-rw-r--r--spec/models/ci/build_spec.rb31
-rw-r--r--spec/models/concerns/group_descendant_spec.rb166
-rw-r--r--spec/models/concerns/loaded_in_group_list_spec.rb49
-rw-r--r--spec/models/diff_note_spec.rb2
-rw-r--r--spec/models/gcp/cluster_spec.rb24
-rw-r--r--spec/models/namespace_spec.rb14
-rw-r--r--spec/models/project_spec.rb21
-rw-r--r--spec/requests/api/settings_spec.rb5
-rw-r--r--spec/requests/api/v3/repositories_spec.rb11
-rw-r--r--spec/serializers/group_child_entity_spec.rb101
-rw-r--r--spec/serializers/group_child_serializer_spec.rb110
-rw-r--r--spec/services/ci/retry_build_service_spec.rb3
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb2
-rw-r--r--spec/services/merge_requests/conflicts/resolve_service_spec.rb33
-rw-r--r--spec/services/projects/update_pages_service_spec.rb5
-rw-r--r--spec/support/stub_configuration.rb4
-rw-r--r--yarn.lock6
239 files changed, 6483 insertions, 1875 deletions
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 38eea38f949..97e80afa3f8 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -7,7 +7,7 @@ class BoardService {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: {
method: 'GET',
- url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
+ url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`,
}
});
this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
@@ -16,7 +16,7 @@ class BoardService {
url: `${listsEndpoint}/generate.json`
}
});
- this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
+ this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: {
method: 'POST',
diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js
index 50dbeb06362..180aa30e98c 100644
--- a/app/assets/javascripts/clusters.js
+++ b/app/assets/javascripts/clusters.js
@@ -3,7 +3,8 @@ import Visibility from 'visibilityjs';
import axios from 'axios';
import Poll from './lib/utils/poll';
import { s__ } from './locale';
-import './flash';
+import initSettingsPanels from './settings_panels';
+import Flash from './flash';
/**
* Cluster page has 2 separate parts:
@@ -24,6 +25,8 @@ class ClusterService {
export default class Clusters {
constructor() {
+ initSettingsPanels();
+
const dataset = document.querySelector('.js-edit-cluster-form').dataset;
this.state = {
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index cd53bc1e2dd..b66652db33b 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown';
+import NewGroupChild from './groups/new_group_child';
import AbuseReports from './abuse_reports';
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
import AjaxLoadingSpinner from './ajax_loading_spinner';
@@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date';
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup();
}
- if (page === 'projects:merge_requests:index') {
- new UserCallout({ setCalloutPerProject: true });
- }
const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
IssuableIndex.init(pagePrefix);
@@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date';
case 'projects:show':
shortcut_handler = new ShortcutsNavigation();
new NotificationsForm();
- new UserCallout({ setCalloutPerProject: true });
+ new UserCallout({
+ setCalloutPerProject: true,
+ className: 'js-autodevops-banner',
+ });
if ($('#tree-slider').length) new TreeView();
if ($('.blob-viewer').length) new BlobViewer();
@@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date';
case 'projects:pipelines:new':
new NewBranchForm($('.js-new-pipeline-form'));
break;
- case 'projects:pipelines:index':
- new UserCallout({ setCalloutPerProject: true });
- break;
case 'projects:pipelines:builds':
case 'projects:pipelines:failures':
case 'projects:pipelines:show':
@@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
new gl.Activities();
break;
case 'groups:show':
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
shortcut_handler = new ShortcutsNavigation();
new NotificationsForm();
new NotificationsDropdown();
new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
break;
case 'groups:group_members:index':
memberExpirationDate();
@@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date';
new TreeView();
new BlobViewer();
new NewCommitForm($('.js-create-dir-form'));
- new UserCallout({ setCalloutPerProject: true });
$('#tree-slider').waitForImages(function() {
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
});
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 6d516a253bb..9e91f72b2ea 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -6,10 +6,11 @@ import _ from 'underscore';
*/
export default class FilterableList {
- constructor(form, filter, holder) {
+ constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
+ this.filterInputField = filterInputField;
this.isBusy = false;
}
@@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() {
const $form = $(this.filterForm);
const queryData = {};
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
}
this.filterResults(queryData);
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
new file mode 100644
index 00000000000..2c0b6ab4ea8
--- /dev/null
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -0,0 +1,194 @@
+<script>
+/* global Flash */
+
+import eventHub from '../event_hub';
+import { getParameterByName } from '../../lib/utils/common_utils';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import { COMMON_STR } from '../constants';
+
+import groupsComponent from './groups.vue';
+
+export default {
+ components: {
+ loadingIcon,
+ groupsComponent,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ hideProjects: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ isSearchEmpty: false,
+ searchEmptyMessage: '',
+ };
+ },
+ computed: {
+ groups() {
+ return this.store.getGroups();
+ },
+ pageInfo() {
+ return this.store.getPaginationInfo();
+ },
+ },
+ methods: {
+ fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
+ return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+ .then((res) => {
+ if (updatePagination) {
+ this.updatePagination(res.headers);
+ }
+
+ return res;
+ })
+ .then(res => res.json())
+ .catch(() => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ Flash(COMMON_STR.FAILURE);
+ });
+ },
+ fetchAllGroups() {
+ const page = getParameterByName('page') || null;
+ const sortBy = getParameterByName('sort') || null;
+ const archived = getParameterByName('archived') || null;
+ const filterGroupsBy = getParameterByName('filter') || null;
+
+ this.isLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then((res) => {
+ this.isLoading = false;
+ this.updateGroups(res, Boolean(filterGroupsBy));
+ });
+ },
+ fetchPage(page, filterGroupsBy, sortBy, archived) {
+ this.isLoading = true;
+
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination: true,
+ }).then((res) => {
+ this.isLoading = false;
+ $.scrollTo(0);
+
+ const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+
+ this.updateGroups(res);
+ });
+ },
+ toggleChildren(group) {
+ const parentGroup = group;
+ if (!parentGroup.isOpen) {
+ if (parentGroup.children.length === 0) {
+ parentGroup.isChildrenLoading = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchGroups({
+ parentId: parentGroup.id,
+ }).then((res) => {
+ this.store.setGroupChildren(parentGroup, res);
+ }).catch(() => {
+ parentGroup.isChildrenLoading = false;
+ });
+ } else {
+ parentGroup.isOpen = true;
+ }
+ } else {
+ parentGroup.isOpen = false;
+ }
+ },
+ leaveGroup(group, parentGroup) {
+ const targetGroup = group;
+ targetGroup.isBeingRemoved = true;
+ this.service.leaveGroup(targetGroup.leavePath)
+ .then(res => res.json())
+ .then((res) => {
+ $.scrollTo(0);
+ this.store.removeGroup(targetGroup, parentGroup);
+ Flash(res.notice, 'notice');
+ })
+ .catch((err) => {
+ let message = COMMON_STR.FAILURE;
+ if (err.status === 403) {
+ message = COMMON_STR.LEAVE_FORBIDDEN;
+ }
+ Flash(message);
+ targetGroup.isBeingRemoved = false;
+ });
+ },
+ updatePagination(headers) {
+ this.store.setPaginationInfo(headers);
+ },
+ updateGroups(groups, fromSearch) {
+ this.isSearchEmpty = groups ? groups.length === 0 : false;
+ if (fromSearch) {
+ this.store.setSearchedGroups(groups);
+ } else {
+ this.store.setGroups(groups);
+ }
+ },
+ },
+ created() {
+ this.searchEmptyMessage = this.hideProjects ?
+ COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
+
+ eventHub.$on('fetchPage', this.fetchPage);
+ eventHub.$on('toggleChildren', this.toggleChildren);
+ eventHub.$on('leaveGroup', this.leaveGroup);
+ eventHub.$on('updatePagination', this.updatePagination);
+ eventHub.$on('updateGroups', this.updateGroups);
+ },
+ mounted() {
+ this.fetchAllGroups();
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchPage', this.fetchPage);
+ eventHub.$off('toggleChildren', this.toggleChildren);
+ eventHub.$off('leaveGroup', this.leaveGroup);
+ eventHub.$off('updatePagination', this.updatePagination);
+ eventHub.$off('updateGroups', this.updateGroups);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoading"
+ :label="s__('GroupsTree|Loading groups')"
+ />
+ <groups-component
+ v-if="!isLoading"
+ :groups="groups"
+ :search-empty="isSearchEmpty"
+ :search-empty-message="searchEmptyMessage"
+ :page-info="pageInfo"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 7cc6c4b0359..e60221fa08d 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,15 +1,27 @@
<script>
+import { n__ } from '../../locale';
+import { MAX_CHILDREN_COUNT } from '../constants';
+
export default {
props: {
- groups: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
+ groups: {
+ type: Array,
+ required: false,
+ default: () => ([]),
+ },
+ },
+ computed: {
+ hasMoreChildren() {
+ return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
+ },
+ moreChildrenStats() {
+ return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
+ },
},
};
</script>
@@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups"
:key="index"
:group="group"
- :base-group="baseGroup"
- :collection="groups"
+ :parent-group="parentGroup"
/>
+ <li
+ v-if="hasMoreChildren"
+ class="group-row">
+ <a
+ :href="parentGroup.relativePath"
+ class="group-row-contents has-more-items">
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true"
+ />
+ {{moreChildrenStats}}
+ </a>
+ </li>
</ul>
</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2060410e991..356a95c05ca 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -2,49 +2,28 @@
import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
+import itemCaret from './item_caret.vue';
+import itemTypeIcon from './item_type_icon.vue';
+import itemStats from './item_stats.vue';
+import itemActions from './item_actions.vue';
+
export default {
components: {
identicon,
+ itemCaret,
+ itemTypeIcon,
+ itemStats,
+ itemActions,
},
props: {
- group: {
- type: Object,
- required: true,
- },
- baseGroup: {
+ parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
- collection: {
+ group: {
type: Object,
- required: false,
- default: () => ({}),
- },
- },
- methods: {
- onClickRowGroup(e) {
- e.stopPropagation();
-
- // Skip for buttons
- if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
- if (this.group.hasSubgroups) {
- eventHub.$emit('toggleSubGroups', this.group);
- } else {
- window.location.href = this.group.groupPath;
- }
- }
- },
- onLeaveGroup(e) {
- e.preventDefault();
-
- // eslint-disable-next-line no-alert
- if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
- this.leaveGroup();
- }
- },
- leaveGroup() {
- eventHub.$emit('leaveGroup', this.group, this.collection);
+ required: true,
},
},
computed: {
@@ -53,51 +32,33 @@ export default {
},
rowClass() {
return {
- 'group-row': true,
'is-open': this.group.isOpen,
- 'has-subgroups': this.group.hasSubgroups,
- 'no-description': !this.group.description,
+ 'has-children': this.hasChildren,
+ 'has-description': this.group.description,
+ 'being-removed': this.group.isBeingRemoved,
};
},
- visibilityIcon() {
- return {
- fa: true,
- 'fa-globe': this.group.visibility === 'public',
- 'fa-shield': this.group.visibility === 'internal',
- 'fa-lock': this.group.visibility === 'private',
- };
+ hasChildren() {
+ return this.group.childrenCount > 0;
},
- fullPath() {
- let fullPath = '';
-
- if (this.group.isOrphan) {
- // check if current group is baseGroup
- if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
- // Remove baseGroup prefix from our current group.fullName. e.g:
- // baseGroup.fullName: `level1`
- // group.fullName: `level1 / level2 / level3`
- // Result: `level2 / level3`
- const gfn = this.group.fullName;
- const bfn = this.baseGroup.fullName;
- const length = bfn.length;
- const start = gfn.indexOf(bfn);
- const extraPrefixChars = 3;
-
- fullPath = gfn.substr(start + length + extraPrefixChars);
+ hasAvatar() {
+ return this.group.avatarUrl !== null;
+ },
+ isGroup() {
+ return this.group.type === 'group';
+ },
+ },
+ methods: {
+ onClickRowGroup(e) {
+ const NO_EXPAND_CLS = 'no-expand';
+ if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
+ e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
+ if (this.hasChildren) {
+ eventHub.$emit('toggleChildren', this.group);
} else {
- fullPath = this.group.fullName;
+ gl.utils.visitUrl(this.group.relativePath);
}
- } else {
- fullPath = this.group.name;
}
-
- return fullPath;
- },
- hasGroups() {
- return Object.keys(this.group.subGroups).length > 0;
- },
- hasAvatar() {
- return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
},
},
};
@@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup"
:id="groupDomId"
:class="rowClass"
+ class="group-row"
>
<div
class="group-row-contents">
- <div
- class="controls">
- <a
- v-if="group.canEdit"
- class="edit-group btn"
- :href="group.editPath">
- <i
- class="fa fa-cogs"
- aria-hidden="true"
- >
- </i>
- </a>
- <a
- @click="onLeaveGroup"
- :href="group.leavePath"
- class="leave-group btn"
- title="Leave this group">
- <i
- class="fa fa-sign-out"
- aria-hidden="true"
- >
- </i>
- </a>
- </div>
- <div
- class="stats">
- <span
- class="number-projects">
- <i
- class="fa fa-bookmark"
- aria-hidden="true"
- >
- </i>
- {{group.numberProjects}}
- </span>
- <span
- class="number-users">
- <i
- class="fa fa-users"
- aria-hidden="true"
- >
- </i>
- {{group.numberUsers}}
- </span>
- <span
- class="group-visibility">
- <i
- :class="visibilityIcon"
- aria-hidden="true"
- >
- </i>
- </span>
- </div>
+ <item-actions
+ v-if="isGroup"
+ :group="group"
+ :parent-group="parentGroup"
+ />
+ <item-stats
+ :item="group"
+ />
<div
class="folder-toggle-wrap">
- <span
- class="folder-caret"
- v-if="group.hasSubgroups">
- <i
- v-if="group.isOpen"
- class="fa fa-caret-down"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-caret-right"
- aria-hidden="true"
- >
- </i>
- </span>
- <span class="folder-icon">
- <i
- v-if="group.isOpen"
- class="fa fa-folder-open"
- aria-hidden="true"
- >
- </i>
- <i
- v-if="!group.isOpen"
- class="fa fa-folder"
- aria-hidden="true">
- </i>
- </span>
+ <item-caret
+ :is-group-open="group.isOpen"
+ />
+ <item-type-icon
+ :item-type="group.type"
+ :is-group-open="group.isOpen"
+ />
</div>
<div
- class="avatar-container s40 hidden-xs">
+ class="avatar-container s40 hidden-xs"
+ :class="{ 'content-loading': group.isChildrenLoading }"
+ >
<a
- :href="group.groupPath">
+ :href="group.relativePath"
+ class="no-expand"
+ >
<img
v-if="hasAvatar"
class="avatar s40"
@@ -215,19 +114,22 @@ export default {
<div
class="title">
<a
- :href="group.groupPath">{{fullPath}}</a>
- <template v-if="group.permissions.humanGroupAccess">
- as
- <span class="access-type">{{group.permissions.humanGroupAccess}}</span>
- </template>
+ :href="group.relativePath"
+ class="no-expand">{{group.fullName}}</a>
+ <span
+ v-if="group.permission"
+ class="access-type"
+ >
+ {{s__('GroupsTreeRole|as')}} {{group.permission}}
+ </span>
</div>
<div
class="description">{{group.description}}</div>
</div>
<group-folder
- v-if="group.isOpen && hasGroups"
- :groups="group.subGroups"
- :baseGroup="group"
+ v-if="group.isOpen && hasChildren"
+ :parent-group="group"
+ :groups="group.children"
/>
</li>
</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index d17a43b048a..75a2bf34887 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -4,24 +4,33 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
export default {
+ components: {
+ tablePagination,
+ },
props: {
groups: {
- type: Object,
+ type: Array,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
- },
- components: {
- tablePagination,
+ searchEmpty: {
+ type: Boolean,
+ required: true,
+ },
+ searchEmptyMessage: {
+ type: String,
+ required: true,
+ },
},
methods: {
change(page) {
const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort');
- eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
+ const archivedParam = getParameterByName('archived');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
},
},
};
@@ -29,10 +38,17 @@ export default {
<template>
<div class="groups-list-tree-container">
+ <div
+ v-if="searchEmpty"
+ class="has-no-search-results">
+ {{searchEmptyMessage}}
+ </div>
<group-folder
+ v-if="!searchEmpty"
:groups="groups"
/>
<table-pagination
+ v-if="!searchEmpty"
:change="change"
:pageInfo="pageInfo"
/>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
new file mode 100644
index 00000000000..7eff19e2e5a
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -0,0 +1,93 @@
+<script>
+import { s__ } from '../../locale';
+import tooltip from '../../vue_shared/directives/tooltip';
+import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
+import eventHub from '../event_hub';
+import { COMMON_STR } from '../constants';
+
+export default {
+ components: {
+ PopupDialog,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ parentGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dialogStatus: false,
+ };
+ },
+ computed: {
+ leaveBtnTitle() {
+ return COMMON_STR.LEAVE_BTN_TITLE;
+ },
+ editBtnTitle() {
+ return COMMON_STR.EDIT_BTN_TITLE;
+ },
+ leaveConfirmationMessage() {
+ return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
+ },
+ },
+ methods: {
+ onLeaveGroup() {
+ this.dialogStatus = true;
+ },
+ leaveGroup(leaveConfirmed) {
+ this.dialogStatus = false;
+ if (leaveConfirmed) {
+ eventHub.$emit('leaveGroup', this.group, this.parentGroup);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="controls">
+ <a
+ v-tooltip
+ v-if="group.canEdit"
+ :href="group.editPath"
+ :title="editBtnTitle"
+ :aria-label="editBtnTitle"
+ data-container="body"
+ class="edit-group btn no-expand">
+ <i
+ class="fa fa-cogs"
+ aria-hidden="true"/>
+ </a>
+ <a
+ v-tooltip
+ v-if="group.canLeave"
+ @click.prevent="onLeaveGroup"
+ :href="group.leavePath"
+ :title="leaveBtnTitle"
+ :aria-label="leaveBtnTitle"
+ data-container="body"
+ class="leave-group btn no-expand">
+ <i
+ class="fa fa-sign-out"
+ aria-hidden="true"/>
+ </a>
+ <popup-dialog
+ v-show="dialogStatus"
+ :primary-button-label="__('Leave')"
+ kind="warning"
+ :title="__('Are you sure?')"
+ :text="__('Are you sure you want to leave this group?')"
+ :body="leaveConfirmationMessage"
+ @submit="leaveGroup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
new file mode 100644
index 00000000000..959b984816f
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ props: {
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="folder-caret">
+ <i
+ :class="iconClass"
+ class="fa"
+ aria-hidden="true"/>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
new file mode 100644
index 00000000000..9f8ac138fc3
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -0,0 +1,98 @@
+<script>
+import tooltip from '../../vue_shared/directives/tooltip';
+import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.item.visibility];
+ },
+ visibilityTooltip() {
+ if (this.item.type === ITEM_TYPE.GROUP) {
+ return GROUP_VISIBILITY_TYPE[this.item.visibility];
+ }
+ return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+ },
+ isProject() {
+ return this.item.type === ITEM_TYPE.PROJECT;
+ },
+ isGroup() {
+ return this.item.type === ITEM_TYPE.GROUP;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="stats">
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Subgroups')"
+ class="number-subgroups"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-folder"
+ aria-hidden="true"
+ />
+ {{item.subgroupCount}}
+ </span>
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Projects')"
+ class="number-projects"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-bookmark"
+ aria-hidden="true"
+ />
+ {{item.projectCount}}
+ </span>
+ <span
+ v-tooltip
+ v-if="isGroup"
+ :title="s__('Members')"
+ class="number-users"
+ data-placement="top"
+ data-container="body">
+ <i
+ class="fa fa-users"
+ aria-hidden="true"
+ />
+ {{item.memberCount}}
+ </span>
+ <span
+ v-if="isProject"
+ class="project-stars">
+ <i
+ class="fa fa-star"
+ aria-hidden="true"
+ />
+ {{item.starCount}}
+ </span>
+ <span
+ v-tooltip
+ :title="visibilityTooltip"
+ data-placement="left"
+ data-container="body"
+ class="item-visibility">
+ <i
+ :class="visibilityIcon"
+ class="fa"
+ aria-hidden="true"
+ />
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
new file mode 100644
index 00000000000..c02a8ad6d8c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -0,0 +1,34 @@
+<script>
+import { ITEM_TYPE } from '../constants';
+
+export default {
+ props: {
+ itemType: {
+ type: String,
+ required: true,
+ },
+ isGroupOpen: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ if (this.itemType === ITEM_TYPE.GROUP) {
+ return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
+ }
+ return 'fa-bookmark';
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="item-type-icon">
+ <i
+ :class="iconClass"
+ class="fa"
+ aria-hidden="true"/>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
new file mode 100644
index 00000000000..6fde41414b3
--- /dev/null
+++ b/app/assets/javascripts/groups/constants.js
@@ -0,0 +1,35 @@
+import { __, s__ } from '../locale';
+
+export const MAX_CHILDREN_COUNT = 20;
+
+export const COMMON_STR = {
+ FAILURE: __('An error occurred. Please try again.'),
+ LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
+ LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
+ EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+ GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
+ GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
+};
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+ public: __('Public - The group and any public projects can be viewed without any authentication.'),
+ internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
+ private: __('Private - The group and its projects can only be viewed by members.'),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ public: __('Public - The project can be accessed without any authentication.'),
+ internal: __('Internal - The project can be accessed by any logged in user.'),
+ private: __('Private - Project access must be granted explicitly to each user.'),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'fa-globe',
+ internal: 'fa-shield',
+ private: 'fa-lock',
+};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index 83b102764ba..2db233b09da 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
- constructor({ form, filter, holder, filterEndpoint, pagePath }) {
- super(form, filter, holder);
+ constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
+ super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
- this.$dropdown = $('.js-group-filter-dropdown-wrap');
+ this.filterInputField = filterInputField;
+ this.$dropdown = $(dropdownSel);
}
getFilterEndpoint() {
@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
bindEvents() {
super.bindEvents();
- this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
- this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
}
- onFormSubmit(e) {
- e.preventDefault();
-
- const $form = $(this.form);
- const filterGroupsParam = $form.find('[name="filter_groups"]').val();
+ onFilterInput() {
const queryData = {};
+ const $form = $(this.form);
+ const archivedParam = getParameterByName('archived', window.location.href);
+ const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
- queryData.filter_groups = filterGroupsParam;
+ queryData[this.filterInputField] = filterGroupsParam;
+ }
+
+ if (archivedParam) {
+ queryData.archived = archivedParam;
}
this.filterResults(queryData);
- this.setDefaultFilterOption();
+
+ if (this.setDefaultFilterOption) {
+ this.setDefaultFilterOption();
+ }
}
setDefaultFilterOption() {
- const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
+ const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
const queryData = {};
- const sortParam = getParameterByName('sort', e.currentTarget.href);
+
+ // Get type of option selected from dropdown
+ const currentTargetClassList = e.currentTarget.parentElement.classList;
+ const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
+ const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
+
+ // Get option query param, also preserve currently applied query param
+ const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
+ const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
if (sortParam) {
queryData.sort = sortParam;
}
+ if (archivedParam) {
+ queryData.archived = archivedParam;
+ }
+
this.filterResults(queryData);
// Active selected option
- this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ if (isOptionFilterBySort) {
+ this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
+ this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
+ } else if (isOptionFilterByArchivedProjects) {
+ this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
+ }
+
+ $(e.target).addClass('is-active');
// Clear current value on search form
- this.form.querySelector('[name="filter_groups"]').value = '';
+ this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
onFilterSuccess(data, xhr, queryData) {
- super.onFilterSuccess(data, xhr, queryData);
+ const currentPath = this.getPagePath(queryData);
const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
};
- eventHub.$emit('updateGroups', data);
+ window.history.replaceState({
+ page: currentPath,
+ }, document.title, currentPath);
+
+ eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', paginationData);
}
}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 600bae24b52..8b850765a1b 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
-import Flash from '../flash';
+import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
-import GroupsComponent from './components/groups.vue';
-import GroupFolder from './components/group_folder.vue';
-import GroupItem from './components/group_item.vue';
-import GroupsStore from './stores/groups_store';
-import GroupsService from './services/groups_service';
-import eventHub from './event_hub';
-import { getParameterByName } from '../lib/utils/common_utils';
+import GroupsStore from './store/groups_store';
+import GroupsService from './service/groups_service';
+
+import groupsApp from './components/app.vue';
+import groupFolderComponent from './components/group_folder.vue';
+import groupItemComponent from './components/group_item.vue';
+
+Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('dashboard-group-app');
+ const el = document.getElementById('js-groups-tree');
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- Vue.component('groups-component', GroupsComponent);
- Vue.component('group-folder', GroupFolder);
- Vue.component('group-item', GroupItem);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
// eslint-disable-next-line no-new
new Vue({
el,
+ components: {
+ groupsApp,
+ },
data() {
- this.store = new GroupsStore();
- this.service = new GroupsService(el.dataset.endpoint);
+ const dataset = this.$options.el.dataset;
+ const hideProjects = dataset.hideProjects === 'true';
+ const store = new GroupsStore(hideProjects);
+ const service = new GroupsService(dataset.endpoint);
return {
- store: this.store,
- isLoading: true,
- state: this.store.state,
+ store,
+ service,
+ hideProjects,
loading: true,
};
},
- computed: {
- isEmpty() {
- return Object.keys(this.state.groups).length === 0;
- },
- },
- methods: {
- fetchGroups(parentGroup) {
- let parentId = null;
- let getGroups = null;
- let page = null;
- let sort = null;
- let pageParam = null;
- let sortParam = null;
- let filterGroups = null;
- let filterGroupsParam = null;
-
- if (parentGroup) {
- parentId = parentGroup.id;
- } else {
- this.isLoading = true;
- }
-
- pageParam = getParameterByName('page');
- if (pageParam) {
- page = pageParam;
- }
-
- filterGroupsParam = getParameterByName('filter_groups');
- if (filterGroupsParam) {
- filterGroups = filterGroupsParam;
- }
-
- sortParam = getParameterByName('sort');
- if (sortParam) {
- sort = sortParam;
- }
-
- getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
- getGroups
- .then(response => response.json())
- .then((response) => {
- this.isLoading = false;
-
- this.updateGroups(response, parentGroup);
- })
- .catch(this.handleErrorResponse);
-
- return getGroups;
- },
- fetchPage(page, filterGroups, sort) {
- this.isLoading = true;
-
- return this.service
- .getGroups(null, page, filterGroups, sort)
- .then((response) => {
- this.isLoading = false;
- $.scrollTo(0);
-
- const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
- window.history.replaceState({
- page: currentPath,
- }, document.title, currentPath);
-
- return response.json().then((data) => {
- this.updateGroups(data);
- this.updatePagination(response.headers);
- });
- })
- .catch(this.handleErrorResponse);
- },
- toggleSubGroups(parentGroup = null) {
- if (!parentGroup.isOpen) {
- this.store.resetGroups(parentGroup);
- this.fetchGroups(parentGroup);
- }
-
- this.store.toggleSubGroups(parentGroup);
- },
- leaveGroup(group, collection) {
- this.service.leaveGroup(group.leavePath)
- .then(resp => resp.json())
- .then((response) => {
- $.scrollTo(0);
-
- this.store.removeGroup(group, collection);
-
- // eslint-disable-next-line no-new
- new Flash(response.notice, 'notice');
- })
- .catch((error) => {
- let message = 'An error occurred. Please try again.';
-
- if (error.status === 403) {
- message = 'Failed to leave the group. Please make sure you are not the only owner';
- }
-
- // eslint-disable-next-line no-new
- new Flash(message);
- });
- },
- updateGroups(groups, parentGroup) {
- this.store.setGroups(groups, parentGroup);
- },
- updatePagination(headers) {
- this.store.storePagination(headers);
- },
- handleErrorResponse() {
- this.isLoading = false;
- $.scrollTo(0);
-
- // eslint-disable-next-line no-new
- new Flash('An error occurred. Please try again.');
- },
- },
- created() {
- eventHub.$on('fetchPage', this.fetchPage);
- eventHub.$on('toggleSubGroups', this.toggleSubGroups);
- eventHub.$on('leaveGroup', this.leaveGroup);
- eventHub.$on('updateGroups', this.updateGroups);
- eventHub.$on('updatePagination', this.updatePagination);
- },
beforeMount() {
+ const dataset = this.$options.el.dataset;
let groupFilterList = null;
- const form = document.querySelector('form#group-filter-form');
- const filter = document.querySelector('.js-groups-list-filter');
- const holder = document.querySelector('.js-groups-list-holder');
+ const form = document.querySelector(dataset.formSel);
+ const filter = document.querySelector(dataset.filterSel);
+ const holder = document.querySelector(dataset.holderSel);
const opts = {
form,
filter,
holder,
- filterEndpoint: el.dataset.endpoint,
- pagePath: el.dataset.path,
+ filterEndpoint: dataset.endpoint,
+ pagePath: dataset.path,
+ dropdownSel: dataset.dropdownSel,
+ filterInputField: 'filter',
};
groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch();
},
- mounted() {
- this.fetchGroups()
- .then((response) => {
- this.updatePagination(response.headers);
- this.isLoading = false;
- })
- .catch(this.handleErrorResponse);
- },
- beforeDestroy() {
- eventHub.$off('fetchPage', this.fetchPage);
- eventHub.$off('toggleSubGroups', this.toggleSubGroups);
- eventHub.$off('leaveGroup', this.leaveGroup);
- eventHub.$off('updateGroups', this.updateGroups);
- eventHub.$off('updatePagination', this.updatePagination);
+ render(createElement) {
+ return createElement('groups-app', {
+ props: {
+ store: this.store,
+ service: this.service,
+ hideProjects: this.hideProjects,
+ },
+ });
},
});
});
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
new file mode 100644
index 00000000000..8e273579aae
--- /dev/null
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -0,0 +1,62 @@
+import DropLab from '../droplab/drop_lab';
+import ISetter from '../droplab/plugins/input_setter';
+
+const InputSetter = Object.assign({}, ISetter);
+
+const NEW_PROJECT = 'new-project';
+const NEW_SUBGROUP = 'new-subgroup';
+
+export default class NewGroupChild {
+ constructor(buttonWrapper) {
+ this.buttonWrapper = buttonWrapper;
+ this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
+ this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
+ this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
+
+ this.newGroupPath = this.buttonWrapper.dataset.projectPath;
+ this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
+
+ this.init();
+ }
+
+ init() {
+ this.initDroplab();
+ this.bindEvents();
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+ this.droplab.init(
+ this.dropdownToggle,
+ this.dropdownList,
+ [InputSetter],
+ this.getDroplabConfig(),
+ );
+ }
+
+ getDroplabConfig() {
+ return {
+ InputSetter: [{
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ }, {
+ input: this.newGroupChildButton,
+ valueAttribute: 'data-text',
+ }],
+ };
+ }
+
+ bindEvents() {
+ this.newGroupChildButton
+ .addEventListener('click', this.onClickNewGroupChildButton.bind(this));
+ }
+
+ onClickNewGroupChildButton(e) {
+ if (e.target.dataset.action === NEW_PROJECT) {
+ gl.utils.visitUrl(this.newGroupPath);
+ } else if (e.target.dataset.action === NEW_SUBGROUP) {
+ gl.utils.visitUrl(this.subgroupPath);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
index 97e02fcb76d..639410384c2 100644
--- a/app/assets/javascripts/groups/services/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -8,7 +8,7 @@ export default class GroupsService {
this.groups = Vue.resource(endpoint);
}
- getGroups(parentId, page, filterGroups, sort) {
+ getGroups(parentId, page, filterGroups, sort, archived) {
const data = {};
if (parentId) {
@@ -20,12 +20,16 @@ export default class GroupsService {
}
if (filterGroups) {
- data.filter_groups = filterGroups;
+ data.filter = filterGroups;
}
if (sort) {
data.sort = sort;
}
+
+ if (archived) {
+ data.archived = archived;
+ }
}
return this.groups.get(data);
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
new file mode 100644
index 00000000000..a1689f4c5cc
--- /dev/null
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -0,0 +1,105 @@
+import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
+
+export default class GroupsStore {
+ constructor(hideProjects) {
+ this.state = {};
+ this.state.groups = [];
+ this.state.pageInfo = {};
+ this.hideProjects = hideProjects;
+ }
+
+ setGroups(rawGroups) {
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setSearchedGroups(rawGroups) {
+ const formatGroups = groups => groups.map((group) => {
+ const formattedGroup = this.formatGroupItem(group);
+ if (formattedGroup.children && formattedGroup.children.length) {
+ formattedGroup.children = formatGroups(formattedGroup.children);
+ }
+ return formattedGroup;
+ });
+
+ if (rawGroups && rawGroups.length) {
+ this.state.groups = formatGroups(rawGroups);
+ } else {
+ this.state.groups = [];
+ }
+ }
+
+ setGroupChildren(parentGroup, children) {
+ const updatedParentGroup = parentGroup;
+ updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
+ updatedParentGroup.isOpen = true;
+ updatedParentGroup.isChildrenLoading = false;
+ }
+
+ getGroups() {
+ return this.state.groups;
+ }
+
+ setPaginationInfo(pagination = {}) {
+ let paginationInfo;
+
+ if (Object.keys(pagination).length) {
+ const normalizedHeaders = normalizeHeaders(pagination);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = pagination;
+ }
+
+ this.state.pageInfo = paginationInfo;
+ }
+
+ getPaginationInfo() {
+ return this.state.pageInfo;
+ }
+
+ formatGroupItem(rawGroupItem) {
+ const groupChildren = rawGroupItem.children || [];
+ const groupIsOpen = (groupChildren.length > 0) || false;
+ const childrenCount = this.hideProjects ?
+ rawGroupItem.subgroup_count :
+ rawGroupItem.children_count;
+
+ return {
+ id: rawGroupItem.id,
+ name: rawGroupItem.name,
+ fullName: rawGroupItem.full_name,
+ description: rawGroupItem.description,
+ visibility: rawGroupItem.visibility,
+ avatarUrl: rawGroupItem.avatar_url,
+ relativePath: rawGroupItem.relative_path,
+ editPath: rawGroupItem.edit_path,
+ leavePath: rawGroupItem.leave_path,
+ canEdit: rawGroupItem.can_edit,
+ canLeave: rawGroupItem.can_leave,
+ type: rawGroupItem.type,
+ permission: rawGroupItem.permission,
+ children: groupChildren,
+ isOpen: groupIsOpen,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ parentId: rawGroupItem.parent_id,
+ childrenCount,
+ projectCount: rawGroupItem.project_count,
+ subgroupCount: rawGroupItem.subgroup_count,
+ memberCount: rawGroupItem.number_users_with_delimiter,
+ starCount: rawGroupItem.star_count,
+ };
+ }
+
+ removeGroup(group, parentGroup) {
+ const updatedParentGroup = parentGroup;
+ if (updatedParentGroup.children && updatedParentGroup.children.length) {
+ updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
+ } else {
+ this.state.groups = this.state.groups.filter(child => group.id !== child.id);
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js
deleted file mode 100644
index f59ec677603..00000000000
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ /dev/null
@@ -1,167 +0,0 @@
-import Vue from 'vue';
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
-
-export default class GroupsStore {
- constructor() {
- this.state = {};
- this.state.groups = {};
- this.state.pageInfo = {};
- }
-
- setGroups(rawGroups, parent) {
- const parentGroup = parent;
- const tree = this.buildTree(rawGroups, parentGroup);
-
- if (parentGroup) {
- parentGroup.subGroups = tree;
- } else {
- this.state.groups = tree;
- }
-
- return tree;
- }
-
- // eslint-disable-next-line class-methods-use-this
- resetGroups(parent) {
- const parentGroup = parent;
- parentGroup.subGroups = {};
- }
-
- storePagination(pagination = {}) {
- let paginationInfo;
-
- if (Object.keys(pagination).length) {
- const normalizedHeaders = normalizeHeaders(pagination);
- paginationInfo = parseIntPagination(normalizedHeaders);
- } else {
- paginationInfo = pagination;
- }
-
- this.state.pageInfo = paginationInfo;
- }
-
- buildTree(rawGroups, parentGroup) {
- const groups = this.decorateGroups(rawGroups);
- const tree = {};
- const mappedGroups = {};
- const orphans = [];
-
- // Map groups to an object
- groups.map((group) => {
- mappedGroups[`id${group.id}`] = group;
- mappedGroups[`id${group.id}`].subGroups = {};
- return group;
- });
-
- Object.keys(mappedGroups).map((key) => {
- const currentGroup = mappedGroups[key];
- if (currentGroup.parentId) {
- // If the group is not at the root level, add it to its parent array of subGroups.
- const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
- if (findParentGroup) {
- mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
- mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
- } else if (parentGroup && parentGroup.id === currentGroup.parentId) {
- tree[`id${currentGroup.id}`] = currentGroup;
- } else {
- // No parent found. We save it for later processing
- orphans.push(currentGroup);
-
- // Add to tree to preserve original order
- tree[`id${currentGroup.id}`] = currentGroup;
- }
- } else {
- // If the group is at the top level, add it to first level elements array.
- tree[`id${currentGroup.id}`] = currentGroup;
- }
-
- return key;
- });
-
- if (orphans.length) {
- orphans.map((orphan) => {
- let found = false;
- const currentOrphan = orphan;
-
- Object.keys(tree).map((key) => {
- const group = tree[key];
-
- if (
- group &&
- currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
- // Make sure the currently selected orphan is not the same as the group
- // we are checking here otherwise it will end up in an infinite loop
- currentOrphan.id !== group.id
- ) {
- group.subGroups[currentOrphan.id] = currentOrphan;
- group.isOpen = true;
- currentOrphan.isOrphan = true;
- found = true;
-
- // Delete if group was put at the top level. If not the group will be displayed twice.
- if (tree[`id${currentOrphan.id}`]) {
- delete tree[`id${currentOrphan.id}`];
- }
- }
-
- return key;
- });
-
- if (!found) {
- currentOrphan.isOrphan = true;
-
- tree[`id${currentOrphan.id}`] = currentOrphan;
- }
-
- return orphan;
- });
- }
-
- return tree;
- }
-
- decorateGroups(rawGroups) {
- this.groups = rawGroups.map(this.decorateGroup);
- return this.groups;
- }
-
- // eslint-disable-next-line class-methods-use-this
- decorateGroup(rawGroup) {
- return {
- id: rawGroup.id,
- fullName: rawGroup.full_name,
- fullPath: rawGroup.full_path,
- avatarUrl: rawGroup.avatar_url,
- name: rawGroup.name,
- hasSubgroups: rawGroup.has_subgroups,
- canEdit: rawGroup.can_edit,
- description: rawGroup.description,
- webUrl: rawGroup.web_url,
- groupPath: rawGroup.group_path,
- parentId: rawGroup.parent_id,
- visibility: rawGroup.visibility,
- leavePath: rawGroup.leave_path,
- editPath: rawGroup.edit_path,
- isOpen: false,
- isOrphan: false,
- numberProjects: rawGroup.number_projects_with_delimiter,
- numberUsers: rawGroup.number_users_with_delimiter,
- permissions: {
- humanGroupAccess: rawGroup.permissions.human_group_access,
- },
- subGroups: {},
- };
- }
-
- // eslint-disable-next-line class-methods-use-this
- removeGroup(group, collection) {
- Vue.delete(collection, `id${group.id}`);
- }
-
- // eslint-disable-next-line class-methods-use-this
- toggleSubGroups(toggleGroup) {
- const group = toggleGroup;
- group.isOpen = !group.isOpen;
- return group;
- }
-}
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 3f6f40d47ba..6d671845f8e 100644
--- a/app/assets/javascripts/jobs/components/header.vue
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -43,16 +43,6 @@
type: 'link',
});
}
-
- if (this.job.retry_path) {
- actions.push({
- label: 'Retry',
- path: this.job.retry_path,
- cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
- type: 'ujs-link',
- });
- }
-
return actions;
},
},
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 9f05cf16967..07899777a1e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -403,7 +403,11 @@ export const setCiStatusFavicon = (pageUrl) => {
});
};
-export const spriteIcon = icon => `<svg><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+export const spriteIcon = (icon, className = '') => {
+ const classAttribute = className.length > 0 ? `class="${className}"` : '';
+
+ return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
+};
export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 951d5e559b4..e7d5325a509 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -146,7 +146,9 @@ import _ from 'underscore';
clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
+
var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
+ if (!selected) return;
page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
index 02d9c775046..2c597a3cd65 100644
--- a/app/assets/javascripts/repo/components/repo_editor.vue
+++ b/app/assets/javascripts/repo/components/repo_editor.vue
@@ -22,7 +22,8 @@ const RepoEditor = {
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
- contextmenu: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
});
Helper.monacoInstance = monacoInstance;
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 0d7a5cba928..aa61ddc6a2c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -7,6 +7,7 @@
@import "framework/animations";
@import "framework/avatar";
@import "framework/asciidoctor";
+@import "framework/banner";
@import "framework/blocks";
@import "framework/buttons";
@import "framework/badges";
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
new file mode 100644
index 00000000000..6433b0c7855
--- /dev/null
+++ b/app/assets/stylesheets/framework/banner.scss
@@ -0,0 +1,25 @@
+.banner-callout {
+ display: flex;
+ position: relative;
+ flex-wrap: wrap;
+
+ .banner-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ opacity: 1;
+
+ .dismiss-icon {
+ color: $gl-text-color;
+ font-size: $gl-font-size;
+ }
+ }
+
+ .banner-graphic {
+ margin: 20px auto;
+ }
+
+ &.banner-non-empty-state {
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 9dcf332eee2..a9d804e735d 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -838,6 +838,7 @@
a {
padding: 8px 40px;
+ &.is-indeterminate::before,
&.is-active::before {
left: 16px;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 588ec1ff3bc..5833ef939e9 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -10,6 +10,10 @@
border: 0;
}
+ &.file-holder-bottom-radius {
+ border-radius: 0 0 $border-radius-small $border-radius-small;
+ }
+
&.readme-holder {
margin: $gl-padding 0;
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index badc7b0eba3..d43f998cb82 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -281,6 +281,57 @@ ul.indent-list {
// Specific styles for tree list
+@keyframes spin-avatar {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.groups-list-tree-container {
+ .has-no-search-results {
+ text-align: center;
+ padding: $gl-padding;
+ font-style: italic;
+ color: $well-light-text-color;
+ }
+
+ > .group-list-tree > .group-row.has-children:first-child {
+ border-top: none;
+ }
+}
+
+.group-list-tree .avatar-container.content-loading {
+ position: relative;
+
+ > a,
+ > a .avatar {
+ height: 100%;
+ border-radius: 50%;
+ }
+
+ > a {
+ padding: 2px;
+ }
+
+ > a .avatar {
+ border: 2px solid $white-normal;
+
+ &.identicon {
+ line-height: 30px;
+ }
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ background-color: transparent;
+ border: 2px outset $kdb-border;
+ border-radius: 50%;
+ animation: spin-avatar 3s infinite linear;
+ }
+}
+
.group-list-tree {
.folder-toggle-wrap {
float: left;
@@ -293,7 +344,7 @@ ul.indent-list {
}
.folder-caret,
- .folder-icon {
+ .item-type-icon {
display: inline-block;
}
@@ -301,11 +352,11 @@ ul.indent-list {
width: 15px;
}
- .folder-icon {
+ .item-type-icon {
width: 20px;
}
- > .group-row:not(.has-subgroups) {
+ > .group-row:not(.has-children) {
.folder-caret .fa {
opacity: 0;
}
@@ -351,12 +402,23 @@ ul.indent-list {
top: 30px;
bottom: 0;
}
+
+ &.being-removed {
+ opacity: 0.5;
+ }
}
}
.group-row {
padding: 0;
- border: none;
+
+ &.has-children {
+ border-top: none;
+ }
+
+ &:first-child {
+ border-top: 1px solid $white-normal;
+ }
&:last-of-type {
.group-row-contents:not(:hover) {
@@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a {
width: 100%;
}
+
+ &.has-more-items {
+ display: block;
+ padding: 20px 10px;
+ }
+ }
+}
+
+ul.group-list-tree {
+ li.group-row {
+ &.has-description {
+ .title {
+ line-height: inherit;
+ }
+ }
+
+ .title {
+ line-height: $list-text-height;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 089a67a7c98..d5ca23ff870 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -233,6 +233,7 @@ $container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
$border-radius-default: 4px;
+$border-radius-small: 2px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5538e46a6c4..8d6f30e3b84 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -4,6 +4,6 @@
}
.alert-block {
- margin-bottom: 20px;
+ margin-bottom: 10px;
}
}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index d3cd4d507de..edfafa79c44 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -4,7 +4,7 @@
border-right: 1px solid $border-color;
border-left: 1px solid $border-color;
border-bottom: none;
- border-radius: 2px;
+ border-radius: $border-radius-small $border-radius-small 0 0;
background: $gray-normal;
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 6f6c6839975..9b7dda9b648 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -26,14 +26,117 @@
}
}
-.groups-header {
- @media (min-width: $screen-sm-min) {
- .nav-links {
- width: 35%;
+.group-nav-container .nav-controls {
+ display: flex;
+ align-items: flex-start;
+ padding: $gl-padding-top 0;
+ border-bottom: 1px solid $border-color;
+
+ .group-filter-form {
+ flex: 1;
+ }
+
+ .dropdown-menu-align-right {
+ margin-top: 0;
+ }
+
+ .new-project-subgroup {
+ .dropdown-primary {
+ min-width: 115px;
+ }
+
+ .dropdown-toggle {
+ .dropdown-btn-icon {
+ pointer-events: none;
+ color: inherit;
+ margin-left: 0;
+ }
}
- .nav-controls {
- width: 65%;
+ .dropdown-menu {
+ min-width: 280px;
+ margin-top: 2px;
+ }
+
+ li:not(.divider) {
+ padding: 0;
+
+ &.droplab-item-selected {
+ .icon-container {
+ .list-item-checkmark {
+ visibility: visible;
+ }
+ }
+ }
+
+ .menu-item {
+ padding: 8px 4px;
+
+ &:hover {
+ background-color: $gray-darker;
+ color: $theme-gray-900;
+ }
+ }
+
+ .icon-container {
+ float: left;
+ padding-left: 6px;
+
+ .list-item-checkmark {
+ visibility: hidden;
+ }
+ }
+
+ .description {
+ font-size: 14px;
+
+ strong {
+ display: block;
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+ }
+
+ @media (max-width: $screen-sm-max) {
+ &,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
+ }
+
+ .group-filter-form,
+ .dropdown .dropdown-toggle,
+ .btn-new {
+ width: 100%;
+ }
+
+ .dropdown .dropdown-toggle .fa-chevron-down {
+ position: absolute;
+ top: 11px;
+ right: 8px;
+ }
+
+ .new-project-subgroup {
+ display: flex;
+ align-items: flex-start;
+
+ .dropdown-primary {
+ flex: 1;
+ }
+
+ .dropdown-menu {
+ width: 100%;
+ max-width: inherit;
+ min-width: inherit;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index dae3ec7ac42..c93c4e93af5 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -117,7 +117,7 @@
}
.right-sidebar {
- a,
+ a:not(.btn-retry),
.btn-link {
color: inherit;
}
@@ -459,7 +459,7 @@
}
}
- a {
+ a:not(.btn-retry) {
&:hover {
color: $md-link-color;
text-decoration: none;
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
new file mode 100644
index 00000000000..9d4f97aa443
--- /dev/null
+++ b/app/controllers/concerns/group_tree.rb
@@ -0,0 +1,24 @@
+module GroupTree
+ def render_group_tree(groups)
+ @groups = if params[:filter].present?
+ Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
+ .base_and_ancestors
+ else
+ # Only show root groups if no parent-id is given
+ groups.where(parent_id: params[:parent_id])
+ end
+ @groups = @groups.with_selects_for_list(archived: params[:archived])
+ .sort(@sort = params[:sort])
+ .page(params[:page])
+
+ respond_to do |format|
+ format.html
+ format.json do
+ serializer = GroupChildSerializer.new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy if params[:filter].present?
+ render json: serializer.represent(@groups)
+ end
+ end
+ end
+end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 7ed18fb481c..025769f512a 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,33 +1,8 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
- def index
- @sort = params[:sort] || 'created_desc'
-
- @groups =
- if params[:parent_id] && Group.supports_nested_groups?
- parent = Group.find_by(id: params[:parent_id])
-
- if can?(current_user, :read_group, parent)
- GroupsFinder.new(current_user, parent: parent).execute
- else
- Group.none
- end
- else
- current_user.groups
- end
+ include GroupTree
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.includes(:route)
- @groups = @groups.sort(@sort)
- @groups = @groups.page(params[:page])
-
- respond_to do |format|
- format.html
- format.json do
- render json: GroupSerializer
- .new(current_user: @current_user)
- .with_pagination(request, response)
- .represent(@groups)
- end
- end
+ def index
+ groups = GroupsFinder.new(current_user, all_available: false).execute
+ render_group_tree(groups)
end
end
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 81883c543ba..fa0a0f68fbc 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -1,17 +1,7 @@
class Explore::GroupsController < Explore::ApplicationController
- def index
- @groups = GroupsFinder.new(current_user).execute
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.sort(@sort = params[:sort])
- @groups = @groups.page(params[:page])
+ include GroupTree
- respond_to do |format|
- format.html
- format.json do
- render json: {
- html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
- }
- end
- end
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
end
end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
new file mode 100644
index 00000000000..b474f5d15ee
--- /dev/null
+++ b/app/controllers/groups/children_controller.rb
@@ -0,0 +1,39 @@
+module Groups
+ class ChildrenController < Groups::ApplicationController
+ before_action :group
+
+ def index
+ parent = if params[:parent_id].present?
+ GroupFinder.new(current_user).execute(id: params[:parent_id])
+ else
+ @group
+ end
+
+ if parent.nil?
+ render_404
+ return
+ end
+
+ setup_children(parent)
+
+ respond_to do |format|
+ format.json do
+ serializer = GroupChildSerializer
+ .new(current_user: current_user)
+ .with_pagination(request, response)
+ serializer.expand_hierarchy(parent) if params[:filter].present?
+ render json: serializer.represent(@children)
+ end
+ end
+ end
+
+ protected
+
+ def setup_children(parent)
+ @children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: parent,
+ params: params).execute
+ @children = @children.page(params[:page])
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index a962d82e3b5..e23a82d01be 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
end
def show
- setup_projects
-
respond_to do |format|
- format.html
-
- format.json do
- render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
- }
+ format.html do
+ @has_children = GroupDescendantsFinder.new(current_user: current_user,
+ parent_group: @group,
+ params: params).has_children?
end
format.atom do
@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
end
end
- def subgroups
- return not_found unless Group.supports_nested_groups?
-
- @nested_groups = GroupsFinder.new(current_user, parent: group).execute
- @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
- end
-
def activity
respond_to do |format|
format.html
@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
protected
- def setup_projects
- set_non_archived_param
- params[:sort] ||= 'latest_activity_desc'
- @sort = params[:sort]
-
- options = {}
- options[:only_owned] = true if params[:shared] == '0'
- options[:only_shared] = true if params[:shared] == '1'
-
- @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
- @projects = @projects.includes(:namespace)
- @projects = @projects.page(params[:page]) if params[:name].blank?
- end
-
def authorize_create_group!
allowed = if params[:parent_id].present?
parent = Group.find_by(id: params[:parent_id])
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index 28afef101a9..366524b0783 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -53,7 +53,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
- rescue Gitlab::Conflict::ResolutionError => e
+ rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
render status: :bad_request, json: { message: e.message }
end
end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
new file mode 100644
index 00000000000..1a5f6063437
--- /dev/null
+++ b/app/finders/group_descendants_finder.rb
@@ -0,0 +1,153 @@
+# GroupDescendantsFinder
+#
+# Used to find and filter all subgroups and projects of a passed parent group
+# visible to a specified user.
+#
+# When passing a `filter` param, the search is performed over all nested levels
+# of the `parent_group`. All ancestors for a search result are loaded
+#
+# Arguments:
+# current_user: The user for which the children should be visible
+# parent_group: The group to find children of
+# params:
+# Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
+# support.
+#
+# filter: string - is aliased to `search` for consistency with the frontend
+# archived: string - `only` or `true`.
+# `non_archived` is passed to the `ProjectFinder`s if none
+# was given.
+class GroupDescendantsFinder
+ attr_reader :current_user, :parent_group, :params
+
+ def initialize(current_user: nil, parent_group:, params: {})
+ @current_user = current_user
+ @parent_group = parent_group
+ @params = params.reverse_merge(non_archived: params[:archived].blank?)
+ end
+
+ def execute
+ # The children array might be extended with the ancestors of projects when
+ # filtering. In that case, take the maximum so the array does not get limited
+ # Otherwise, allow paginating through all results
+ #
+ all_required_elements = children
+ all_required_elements |= ancestors_for_projects if params[:filter]
+ total_count = [all_required_elements.size, paginator.total_count].max
+
+ Kaminari.paginate_array(all_required_elements, total_count: total_count)
+ end
+
+ def has_children?
+ projects.any? || subgroups.any?
+ end
+
+ private
+
+ def children
+ @children ||= paginator.paginate(params[:page])
+ end
+
+ def paginator
+ @paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
+ per_page: params[:per_page])
+ end
+
+ def direct_child_groups
+ GroupsFinder.new(current_user,
+ parent: parent_group,
+ all_available: true).execute
+ end
+
+ def all_visible_descendant_groups
+ groups_table = Group.arel_table
+ visible_to_user = groups_table[:visibility_level]
+ .in(Gitlab::VisibilityLevel.levels_for_user(current_user))
+ if current_user
+ authorized_groups = GroupsFinder.new(current_user,
+ all_available: false)
+ .execute.as('authorized')
+ authorized_to_user = groups_table.project(1).from(authorized_groups)
+ .where(authorized_groups[:id].eq(groups_table[:id]))
+ .exists
+ visible_to_user = visible_to_user.or(authorized_to_user)
+ end
+
+ hierarchy_for_parent
+ .descendants
+ .where(visible_to_user)
+ end
+
+ def subgroups_matching_filter
+ all_visible_descendant_groups
+ .search(params[:filter])
+ end
+
+ # When filtering we want all to preload all the ancestors upto the specified
+ # parent group.
+ #
+ # - root
+ # - subgroup
+ # - nested-group
+ # - project
+ #
+ # So when searching 'project', on the 'subgroup' page we want to preload
+ # 'nested-group' but not 'subgroup' or 'root'
+ def ancestors_for_groups(base_for_ancestors)
+ Gitlab::GroupHierarchy.new(base_for_ancestors)
+ .base_and_ancestors(upto: parent_group.id)
+ end
+
+ def ancestors_for_projects
+ projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
+ groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
+ ancestors_for_groups(groups_to_load_ancestors_of)
+ .with_selects_for_list(archived: params[:archived])
+ end
+
+ def subgroups
+ return Group.none unless Group.supports_nested_groups?
+
+ # When filtering subgroups, we want to find all matches withing the tree of
+ # descendants to show to the user
+ groups = if params[:filter]
+ ancestors_for_groups(subgroups_matching_filter)
+ else
+ direct_child_groups
+ end
+ groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
+ end
+
+ def direct_child_projects
+ GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
+ .execute
+ end
+
+ # Finds all projects nested under `parent_group` or any of its descendant
+ # groups
+ def projects_matching_filter
+ projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
+ params_with_search = params.merge(search: params[:filter])
+
+ ProjectsFinder.new(params: params_with_search,
+ current_user: current_user,
+ project_ids_relation: projects_nested_in_group).execute
+ end
+
+ def projects
+ projects = if params[:filter]
+ projects_matching_filter
+ else
+ direct_child_projects
+ end
+ projects.with_route.order_by(sort)
+ end
+
+ def sort
+ params.fetch(:sort, 'id_asc')
+ end
+
+ def hierarchy_for_parent
+ @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
+ end
+end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index f2d3b90b8e2..6e8733bb49c 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
else
collection_without_user
end
-
union(projects)
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 7bd34df5c95..1ee8911bb1a 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -108,6 +108,34 @@ module ApplicationSettingsHelper
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
end
+ def circuitbreaker_failure_count_help_text
+ health_link = link_to(s_('AdminHealthPageLink|health page'), admin_health_check_path)
+ api_link = link_to(s_('CircuitBreakerApiLink|circuitbreaker api'), help_page_path("api/repository_storage_health"))
+ message = _("The number of failures of after which GitLab will completely "\
+ "prevent access to the storage. The number of failures can be "\
+ "reset in the admin interface: %{link_to_health_page} or using "\
+ "the %{api_documentation_link}.")
+ message = message % { link_to_health_page: health_link, api_documentation_link: api_link }
+
+ message.html_safe
+ end
+
+ def circuitbreaker_failure_wait_time_help_text
+ _("When access to a storage fails. GitLab will prevent access to the "\
+ "storage for the time specified here. This allows the filesystem to "\
+ "recover. Repositories on failing shards are temporarly unavailable")
+ end
+
+ def circuitbreaker_failure_reset_time_help_text
+ _("The time in seconds GitLab will keep failure information. When no "\
+ "failures occur during this time, information about the mount is reset.")
+ end
+
+ def circuitbreaker_storage_timeout_help_text
+ _("The time in seconds GitLab will try to access storage. After this time a "\
+ "timeout error will be raised.")
+ end
+
def visible_attributes
[
:admin_notification_email,
@@ -116,6 +144,10 @@ module ApplicationSettingsHelper
:akismet_api_key,
:akismet_enabled,
:auto_devops_enabled,
+ :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_storage_timeout,
:clientside_sentry_dsn,
:clientside_sentry_enabled,
:container_registry_token_expire_delay,
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 1b542ed2a96..b05eb93b465 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -42,6 +42,17 @@ module SortingHelper
options
end
+ def groups_sort_options_hash
+ options = {
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_oldest_updated => sort_title_oldest_updated
+ }
+
+ options
+ end
+
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 5b5bb3cbe2c..d3b8debb0fd 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -153,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0 }
+ validates :circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
new file mode 100644
index 00000000000..01957da0bf3
--- /dev/null
+++ b/app/models/concerns/group_descendant.rb
@@ -0,0 +1,56 @@
+module GroupDescendant
+ # Returns the hierarchy of a project or group in the from of a hash upto a
+ # given top.
+ #
+ # > project.hierarchy
+ # => { parent_group => { child_group => project } }
+ def hierarchy(hierarchy_top = nil, preloaded = nil)
+ preloaded ||= ancestors_upto(hierarchy_top)
+ expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
+ end
+
+ # Merges all hierarchies of the given groups or projects into an array of
+ # hashes. All ancestors need to be loaded into the given `descendants` to avoid
+ # queries down the line.
+ #
+ # > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
+ # => { parent => [{ child_group => project}, child_group2] }
+ def self.build_hierarchy(descendants, hierarchy_top = nil)
+ descendants = Array.wrap(descendants).uniq
+ return [] if descendants.empty?
+
+ unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
+ raise ArgumentError.new('element is not a hierarchy')
+ end
+
+ all_hierarchies = descendants.map do |descendant|
+ descendant.hierarchy(hierarchy_top, descendants)
+ end
+
+ Gitlab::Utils::MergeHash.merge(all_hierarchies)
+ end
+
+ private
+
+ def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
+ parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
+ parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
+
+ if parent.nil? && !child.parent_id.nil?
+ raise ArgumentError.new('parent was not preloaded')
+ end
+
+ if parent.nil? && hierarchy_top.present?
+ raise ArgumentError.new('specified top is not part of the tree')
+ end
+
+ if parent && parent != hierarchy_top
+ expand_hierarchy_for_child(parent,
+ { parent => hierarchy },
+ hierarchy_top,
+ preloaded)
+ else
+ hierarchy
+ end
+ end
+end
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
new file mode 100644
index 00000000000..dcb3b2b5ff3
--- /dev/null
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -0,0 +1,72 @@
+module LoadedInGroupList
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def with_counts(archived:)
+ selects_including_counts = [
+ 'namespaces.*',
+ "(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
+ "(#{member_count_sql.to_sql}) AS preloaded_member_count",
+ "(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
+ ]
+
+ select(selects_including_counts)
+ end
+
+ def with_selects_for_list(archived: nil)
+ with_route.with_counts(archived: archived)
+ end
+
+ private
+
+ def project_count_sql(archived = nil)
+ projects = Project.arel_table
+ namespaces = Namespace.arel_table
+
+ base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
+ .where(projects[:namespace_id].eq(namespaces[:id]))
+ if archived == 'only'
+ base_count.where(projects[:archived].eq(true))
+ elsif Gitlab::Utils.to_boolean(archived)
+ base_count
+ else
+ base_count.where(projects[:archived].not_eq(true))
+ end
+ end
+
+ def subgroup_count_sql
+ namespaces = Namespace.arel_table
+ children = namespaces.alias('children')
+
+ namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
+ .from(children)
+ .where(children[:parent_id].eq(namespaces[:id]))
+ end
+
+ def member_count_sql
+ members = Member.arel_table
+ namespaces = Namespace.arel_table
+
+ members.project(Arel.star.count.as('preloaded_member_count'))
+ .where(members[:source_type].eq(Namespace.name))
+ .where(members[:source_id].eq(namespaces[:id]))
+ .where(members[:requested_at].eq(nil))
+ end
+ end
+
+ def children_count
+ @children_count ||= project_count + subgroup_count
+ end
+
+ def project_count
+ @project_count ||= try(:preloaded_project_count) || projects.non_archived.count
+ end
+
+ def subgroup_count
+ @subgroup_count ||= try(:preloaded_subgroup_count) || children.count
+ end
+
+ def member_count
+ @member_count ||= try(:preloaded_member_count) || users.count
+ end
+end
diff --git a/app/models/gcp/cluster.rb b/app/models/gcp/cluster.rb
index 18bd6a6dcb4..162a690c0e3 100644
--- a/app/models/gcp/cluster.rb
+++ b/app/models/gcp/cluster.rb
@@ -7,6 +7,9 @@ module Gcp
belongs_to :user
belongs_to :service
+ scope :enabled, -> { where(enabled: true) }
+ scope :disabled, -> { where(enabled: false) }
+
default_value_for :gcp_cluster_zone, 'us-central1-a'
default_value_for :gcp_cluster_size, 3
default_value_for :gcp_machine_type, 'n1-standard-4'
diff --git a/app/models/group.rb b/app/models/group.rb
index e746e4a12c9..07fb62bb249 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -6,6 +6,8 @@ class Group < Namespace
include Avatarable
include Referable
include SelectForProjectAuthorization
+ include LoadedInGroupList
+ include GroupDescendant
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 4672881e220..0601a61a926 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors
end
+ # returns all ancestors upto but excluding the the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(self.class.where(id: id))
+ .ancestors(upto: top)
+ end
+
def self_and_ancestors
return self.class.where(id: id) unless parent_id
diff --git a/app/models/project.rb b/app/models/project.rb
index 57e91ab3b88..6e1cf9e31ee 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
include Routable
+ include GroupDescendant
extend Gitlab::ConfigHelper
extend Gitlab::CurrentSettings
@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace
+ alias_method :parent, :namespace
+ alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit
@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
end
end
+ # returns all ancestor-groups upto but excluding the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil)
+ Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
+ .base_and_ancestors(upto: top)
+ end
+
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path)
end
- def parent
- namespace
- end
-
def parent_changed?
namespace_id_changed?
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index bf526ca1762..4324ea46aac 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -468,9 +468,7 @@ class Repository
end
def blob_at(sha, path)
- unless Gitlab::Git.blank_ref?(sha)
- Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project)
- end
+ Blob.decorate(raw_repository.blob_at(sha, path), project)
rescue Gitlab::Git::Repository::NoRepository
nil
end
@@ -914,14 +912,6 @@ class Repository
end
end
- def resolve_conflicts(user, branch_name, params)
- with_branch(user, branch_name) do
- committer = user_to_committer(user)
-
- create_commit(params.merge(author: committer, committer: committer))
- end
- end
-
def merged_to_root_ref?(branch_name)
branch_commit = commit(branch_name)
root_ref_commit = commit(root_ref)
@@ -1127,7 +1117,7 @@ class Repository
def last_commit_id_for_path_by_shelling_out(sha, path)
args = %W(rev-list --max-count=1 #{sha} -- #{path})
- run_git(args).first.strip
+ raw_repository.run_git_with_timeout(args, Gitlab::Git::Popen::FAST_GIT_PROCESS_TIMEOUT).first.strip
end
def repository_storage_path
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 4e6c15f673b..8cade280b0c 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -1,6 +1,9 @@
class BaseSerializer
- def initialize(parameters = {})
- @request = EntityRequest.new(parameters)
+ attr_reader :params
+
+ def initialize(params = {})
+ @params = params
+ @request = EntityRequest.new(params)
end
def represent(resource, opts = {}, entity_class = nil)
diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb
new file mode 100644
index 00000000000..d29e22d6740
--- /dev/null
+++ b/app/serializers/concerns/with_pagination.rb
@@ -0,0 +1,22 @@
+module WithPagination
+ attr_accessor :paginator
+
+ def with_pagination(request, response)
+ tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
+ end
+
+ def paginated?
+ paginator.present?
+ end
+
+ # super is `BaseSerializer#represent` here.
+ #
+ # we shouldn't try to paginate single resources
+ def represent(resource, opts = {})
+ if paginated? && resource.respond_to?(:page)
+ super(@paginator.paginate(resource), opts)
+ else
+ super(resource, opts)
+ end
+ end
+end
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 88842a9aa75..84722f33f59 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -1,4 +1,6 @@
class EnvironmentSerializer < BaseSerializer
+ include WithPagination
+
Item = Struct.new(:name, :size, :latest)
entity EnvironmentEntity
@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
tap { @itemize = true }
end
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
def itemized?
@itemize
end
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if itemized?
itemize(resource).map do |item|
@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) }
end
else
- resource = @paginator.paginate(resource) if paginated?
-
super(resource, opts)
end
end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
new file mode 100644
index 00000000000..37240bfb0b1
--- /dev/null
+++ b/app/serializers/group_child_entity.rb
@@ -0,0 +1,77 @@
+class GroupChildEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+
+ expose :id, :name, :description, :visibility, :full_name,
+ :created_at, :updated_at, :avatar_url
+
+ expose :type do |instance|
+ type
+ end
+
+ expose :can_edit do |instance|
+ return false unless request.respond_to?(:current_user)
+
+ can?(request.current_user, "admin_#{type}", instance)
+ end
+
+ expose :edit_path do |instance|
+ # We know `type` will be one either `project` or `group`.
+ # The `edit_polymorphic_path` helper would try to call the path helper
+ # with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
+ # while our methods are `edit_group_path` or `edit_group_path`
+ public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ expose :relative_path do |instance|
+ polymorphic_path(instance)
+ end
+
+ expose :permission do |instance|
+ membership&.human_access
+ end
+
+ # Project only attributes
+ expose :star_count,
+ if: lambda { |_instance, _options| project? }
+
+ # Group only attributes
+ expose :children_count, :parent_id, :project_count, :subgroup_count,
+ unless: lambda { |_instance, _options| project? }
+
+ expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
+ leave_group_members_path(instance)
+ end
+
+ expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
+ if membership
+ can?(request.current_user, :destroy_group_member, membership)
+ else
+ false
+ end
+ end
+
+ expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.project_count)
+ end
+
+ expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
+ number_with_delimiter(instance.member_count)
+ end
+
+ private
+
+ def membership
+ return unless request.current_user
+
+ @membership ||= request.current_user.members.find_by(source: object)
+ end
+
+ def project?
+ object.is_a?(Project)
+ end
+
+ def type
+ object.class.name.downcase
+ end
+end
diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb
new file mode 100644
index 00000000000..2baef0a5703
--- /dev/null
+++ b/app/serializers/group_child_serializer.rb
@@ -0,0 +1,51 @@
+class GroupChildSerializer < BaseSerializer
+ include WithPagination
+
+ attr_reader :hierarchy_root, :should_expand_hierarchy
+
+ entity GroupChildEntity
+
+ def expand_hierarchy(hierarchy_root = nil)
+ @hierarchy_root = hierarchy_root
+ @should_expand_hierarchy = true
+
+ self
+ end
+
+ def represent(resource, opts = {}, entity_class = nil)
+ if should_expand_hierarchy
+ paginator.paginate(resource) if paginated?
+ represent_hierarchies(resource, opts)
+ else
+ super(resource, opts)
+ end
+ end
+
+ protected
+
+ def represent_hierarchies(children, opts)
+ if children.is_a?(GroupDescendant)
+ represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
+ else
+ hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
+ # When an array was passed, we always want to represent an array.
+ # Even if the hierarchy only contains one element
+ represent_hierarchy(Array.wrap(hierarchies), opts)
+ end
+ end
+
+ def represent_hierarchy(hierarchy, opts)
+ serializer = self.class.new(params)
+
+ if hierarchy.is_a?(Hash)
+ hierarchy.map do |parent, children|
+ serializer.represent(parent, opts)
+ .merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
+ end
+ elsif hierarchy.is_a?(Array)
+ hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
+ else
+ serializer.represent(hierarchy, opts)
+ end
+ end
+end
diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb
index 26e8566828b..8cf7eb63bcf 100644
--- a/app/serializers/group_serializer.rb
+++ b/app/serializers/group_serializer.rb
@@ -1,19 +1,5 @@
class GroupSerializer < BaseSerializer
- entity GroupEntity
-
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
+ include WithPagination
- def paginated?
- @paginator.present?
- end
-
- def represent(resource, opts = {})
- if paginated?
- super(@paginator.paginate(resource), opts)
- else
- super(resource, opts)
- end
- end
+ entity GroupEntity
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 661bf17983c..7181f8a6b04 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,16 +1,10 @@
class PipelineSerializer < BaseSerializer
+ include WithPagination
+
InvalidResourceError = Class.new(StandardError)
entity PipelineDetailsEntity
- def with_pagination(request, response)
- tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
- end
-
- def paginated?
- @paginator.present?
- end
-
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index d67b9f5cc56..c552193e66b 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -28,6 +28,8 @@ module Ci
attributes.push([:user, current_user])
+ build.retried = true
+
Ci::Build.transaction do
# mark all other builds of that name as retried
build.pipeline.builds.latest
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 727768b1a39..6805b2f7d1c 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -3,7 +3,7 @@ module MergeRequests
# Adds a todo to the parent merge_request when a CI build fails
#
def execute(commit_status)
- return if commit_status.allow_failure?
+ return if commit_status.allow_failure? || commit_status.retried?
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
index 9835606812c..0f677a996f7 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -23,13 +23,13 @@ module MergeRequests
# when there are no conflict files.
conflicts.files.each(&:lines)
@conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
- rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+ rescue Rugged::OdbError, Gitlab::Git::Conflict::Parser::UnresolvableError, Gitlab::Git::Conflict::Resolver::ConflictSideMissing
@conflicts_can_be_resolved_in_ui = false
end
end
def conflicts
- @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request)
+ @conflicts ||= Gitlab::Conflict::FileCollection.new(merge_request)
end
end
end
diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb
index 6b6e231f4f9..27cafd2d7d9 100644
--- a/app/services/merge_requests/conflicts/resolve_service.rb
+++ b/app/services/merge_requests/conflicts/resolve_service.rb
@@ -1,54 +1,10 @@
module MergeRequests
module Conflicts
class ResolveService < MergeRequests::Conflicts::BaseService
- MissingFiles = Class.new(Gitlab::Conflict::ResolutionError)
-
def execute(current_user, params)
- rugged = merge_request.source_project.repository.rugged
-
- Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution|
- merge_index = conflicts_for_resolution.merge_index
-
- params[:files].each do |file_params|
- conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path])
-
- write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params)
- end
-
- unless merge_index.conflicts.empty?
- missing_files = merge_index.conflicts.map { |file| file[:ours][:path] }
-
- raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}"
- end
-
- commit_params = {
- message: params[:commit_message] || conflicts_for_resolution.default_commit_message,
- parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid),
- tree: merge_index.write_tree(rugged)
- }
-
- conflicts_for_resolution
- .project
- .repository
- .resolve_conflicts(current_user, merge_request.source_branch, commit_params)
- end
- end
-
- private
-
- def write_resolved_file_to_index(merge_index, rugged, file, params)
- if params[:sections]
- new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n")
-
- new_file << "\n" if file.our_blob.data.ends_with?("\n")
- elsif params[:content]
- new_file = file.resolve_content(params[:content])
- end
-
- our_path = file.our_path
+ conflicts = Gitlab::Conflict::FileCollection.new(merge_request)
- merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
- merge_index.conflict_remove(our_path)
+ conflicts.resolve(current_user, params[:commit_message], params[:files])
end
end
end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index dbaed1d09fb..2b23af9212e 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -530,6 +530,32 @@
= succeed "." do
= link_to "repository storages documentation", help_page_path("administration/repository_storages")
+ %fieldset
+ %legend Git Storage Circuitbreaker settings
+ .form-group
+ = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_count_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_wait_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_wait_time_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_reset_time_help_text
+ .form-group
+ = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
+ .help-block
+ = circuitbreaker_storage_timeout_help_text
%fieldset
%legend Repository Checks
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 7981daa0705..cebdbab4e74 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,13 +1,13 @@
.top-area
%ul.nav-links
= nav_link(page: dashboard_groups_path) do
- = link_to dashboard_groups_path, title: 'Your groups' do
+ = link_to dashboard_groups_path, title: _("Your groups") do
Your groups
= nav_link(page: explore_groups_path) do
- = link_to explore_groups_path, title: 'Explore public groups' do
+ = link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
- = link_to "New group", new_group_path, class: "btn btn-new"
+ = link_to _("New group"), new_group_path, class: "btn btn-new"
diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml
deleted file mode 100644
index f5222fe631e..00000000000
--- a/app/views/dashboard/groups/_empty_state.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.groups-empty-state
- = custom_icon("icon_empty_groups")
-
- .text-content
- %h4 A group is a collection of several projects.
- %p If you organize your projects under a group, it works like a folder.
- %p You can manage your group member’s permissions and access to each project in the group.
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 168e6272d8e..601b6a8b1a7 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,9 +1,2 @@
.js-groups-list-holder
- #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
- .groups-list-loading
- = icon('spinner spin', 'v-show' => 'isLoading')
- %template{ 'v-if' => '!isLoading && isEmpty' }
- %div{ 'v-cloak' => true }
- = render 'empty_state'
- %template{ 'v-else-if' => '!isLoading && !isEmpty' }
- %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 1cea8182733..25bf08c6c12 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -6,7 +6,7 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
-- if @groups.empty?
- = render 'empty_state'
+- if params[:filter].blank? && @groups.empty?
+ = render 'shared/groups/empty_state'
- else
= render 'groups'
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 794c6d1d170..91149498248 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,6 +1,2 @@
.js-groups-list-holder
- %ul.content-list
- - @groups.each do |group|
- = render 'shared/groups/group', group: group
-
- = paginate @groups, theme: 'gitlab'
+ #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 2651ef37e67..86abdf547cc 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,6 +2,9 @@
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
- if current_user
= render 'dashboard/groups_head'
- else
@@ -17,7 +20,7 @@
%p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups.
-- if @groups.present?
- = render 'groups'
-- else
+- if params[:filter].blank? && @groups.empty?
.nothing-here-block No public groups
+- else
+ = render 'groups'
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
new file mode 100644
index 00000000000..3afb6b2f849
--- /dev/null
+++ b/app/views/groups/_children.html.haml
@@ -0,0 +1,5 @@
+= webpack_bundle_tag 'common_vue'
+= webpack_bundle_tag 'groups'
+
+.js-groups-list-holder
+ #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml
deleted file mode 100644
index 35b75bc0923..00000000000
--- a/app/views/groups/_show_nav.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%ul.nav-links
- = nav_link(page: group_path(@group)) do
- = link_to group_path(@group) do
- Projects
- - if Group.supports_nested_groups?
- = nav_link(page: subgroups_group_path(@group)) do
- = link_to subgroups_group_path(@group) do
- Subgroups
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 3ca63f9c3e0..7f9486d08d9 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
- breadcrumb_title "Details"
+- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
@@ -7,13 +8,38 @@
= render 'groups/home_panel'
.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = render 'shared/projects/search_form'
- = render 'shared/projects/dropdown'
+ .group-nav-container
+ .nav-controls.clearfix
+ = render "shared/groups/search_form"
+ = render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group
- = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
- New Project
+ - new_project_label = _("New project")
+ - new_subgroup_label = _("New subgroup")
+ - if can_create_subgroups
+ .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
+ %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
+ = icon("caret-down", class: "dropdown-btn-icon")
+ %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+ %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_project_label
+ %span= s_("GroupsTree|Create a project in this group.")
+ %li.divider.droplap-item-ignore
+ %li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_subgroup_label
+ %span= s_("GroupsTree|Create a subgroup in this group.")
+ - else
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- = render "projects", projects: @projects
+ - if params[:filter].blank? && !@has_children
+ = render "shared/groups/empty_state"
+ - else
+ = render "children", children: @children, group: @group
diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml
deleted file mode 100644
index 869b3b243c6..00000000000
--- a/app/views/groups/subgroups.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- breadcrumb_title "Details"
-- @no_container = true
-
-= render 'groups/home_panel'
-
-.groups-header{ class: container_class }
- .top-area
- = render 'groups/show_nav'
- .nav-controls
- = form_tag request.path, method: :get do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
- - if can?(current_user, :create_subgroup, @group)
- = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
- New Subgroup
-
- - if @nested_groups.present?
- %ul.content-list
- = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
- - else
- .nothing-here-block
- There are no subgroups to show.
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 4b344b2edb9..7777f55ddd7 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -1,6 +1,6 @@
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
-.file-holder.file.append-bottom-default
+.file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } }
.editor-ref
= icon('code-fork')
diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml
new file mode 100644
index 00000000000..6c162481dd8
--- /dev/null
+++ b/app/views/projects/clusters/_advanced_settings.html.haml
@@ -0,0 +1,14 @@
+- if can?(current_user, :admin_cluster, @cluster)
+ .append-bottom-20
+ %label.append-bottom-10
+ = s_('ClusterIntegration|Google Container Engine')
+ %p
+ - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+
+ .well.form-group
+ %label.text-danger
+ = s_('ClusterIntegration|Remove cluster integration')
+ %p
+ = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
+ = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index aee6f904a62..ff76abc3553 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -1,24 +1,37 @@
+- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title "Cluster"
- page_title _("Cluster")
+- expanded = Rails.env.test?
+
- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
-.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
+.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason } }
- .col-sm-4
- = render 'sidebar'
- .col-sm-8
- %label.append-bottom-10{ for: 'enable-cluster-integration' }
- = s_('ClusterIntegration|Enable cluster integration')
- %p
- - if @cluster.enabled?
- - if can?(current_user, :update_cluster, @cluster)
- = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+
+ %section.settings
+ %h4= s_('ClusterIntegration|Enable cluster integration')
+ .settings-content.expanded
+
+ .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
+ %p.js-error-reason
+
+ .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
+
+ .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+
+ %p
+ - if @cluster.enabled?
+ - if can?(current_user, :update_cluster, @cluster)
+ = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+ - else
+ = s_('ClusterIntegration|Cluster integration is enabled for this project.')
- else
- = s_('ClusterIntegration|Cluster integration is enabled for this project.')
- - else
- = s_('ClusterIntegration|Cluster integration is disabled for this project.')
+ = s_('ClusterIntegration|Cluster integration is disabled for this project.')
= form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
= form_errors(@cluster)
@@ -36,35 +49,28 @@
.form-group
= field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
- - if can?(current_user, :admin_cluster, @cluster)
- %label.append-bottom-10{ for: 'google-container-engine' }
- = s_('ClusterIntegration|Google Container Engine')
- %p
- - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+ %section.settings#js-cluster-details
+ .settings-header
+ %h4= s_('ClusterIntegration|Cluster details')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_('ClusterIntegration|See and edit the details for your cluster')
- .hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
- %p.js-error-reason
-
- .hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
-
- .hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
- = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
- .form_group.append-bottom-20
- %label.append-bottom-10{ for: 'cluter-name' }
- = s_('ClusterIntegration|Cluster name')
- .input-group
- %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
- %span.input-group-addon.clipboard-addon
- = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
+ .form_group.append-bottom-20
+ %label.append-bottom-10{ for: 'cluter-name' }
+ = s_('ClusterIntegration|Cluster name')
+ .input-group
+ %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
+ %span.input-group-addon.clipboard-addon
+ = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
- - if can?(current_user, :admin_cluster, @cluster)
- .well.form_group
- %label.text-danger
- = s_('ClusterIntegration|Remove cluster integration')
- %p
- = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
- = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
+ %section.settings#js-cluster-advanced-settings
+ .settings-header
+ %h4= s_('ClusterIntegration|Advanced settings')
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project')
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ = render 'advanced_settings'
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 3f3ce10419f..c9956183e12 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -24,10 +24,15 @@
%p
You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
+ - if show_auto_devops_callout?(@project)
+ %p
+ - link = link_to(s_('AutoDevOps|Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
+ = s_('AutoDevOps|You can activate %{link_to_settings} for this project.').html_safe % { link_to_settings: link }
+ %p
+ = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+
- if can?(current_user, :push_code, @project)
%div{ class: container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
.prepend-top-20
.empty_wrapper
%h3.page-title-empty
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index d5c6d329ce4..7da4ffd5e43 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -4,8 +4,10 @@
.sidebar-container
.blocks-container
.block
- %strong
+ %strong.prepend-top-10
= @build.name
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
= icon('angle-double-right')
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 6b8dcb3e60b..8da2243adef 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -13,8 +13,6 @@
- if @project.merge_requests.exists?
%div{ class: container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 0a835dcdeb0..0a7880ce4cd 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -54,6 +54,10 @@
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong
Import project from
.import-buttons
+ - if gitlab_project_import_enabled?
+ .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+ = icon('gitlab', text: 'GitLab export')
%div
- if github_import_enabled?
= link_to new_import_github_path, class: 'btn import_github' do
@@ -87,10 +91,6 @@
- if git_import_enabled?
%button.btn.js-toggle-button.import_git{ type: "button" }
= icon('git', text: 'Repo by URL')
- - if gitlab_project_import_enabled?
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- = icon('gitlab', text: 'GitLab export')
.col-lg-12
.js-toggle-content.hide.toggle-import-form
%hr
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index a10a7c23924..f8627a3818b 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -2,8 +2,6 @@
- page_title "Pipelines"
%div{ 'class' => container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
"help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 0cc6674842a..745a6040488 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -12,7 +12,5 @@
= webpack_bundle_tag 'repo'
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- - if show_auto_devops_callout?(@project) && !show_new_repo?
- = render 'shared/auto_devops_callout'
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 7c633175a06..934d65e8b42 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,16 @@
-.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
- .bordered-box.landing.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss Auto DevOps box' }
- = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
- .svg-container
- = custom_icon('icon_autodevops')
- .user-callout-copy
- %h4= s_('AutoDevOps|Auto DevOps (Beta)')
- %p= s_('AutoDevOps|Auto DevOps can be activated for this project. It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
- %p
- - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
- = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+ .banner-graphic
+ = custom_icon('icon_autodevops')
- = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn btn-primary js-close-callout'
+ .prepend-top-10.prepend-left-10.append-bottom-10
+ %h5= s_('AutoDevOps|Auto DevOps (Beta)')
+ %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+ %p
+ - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
+ .prepend-top-10
+ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout'
+
+ %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss Auto DevOps box' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 760370a6984..8e6747ca740 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,18 +1,32 @@
-.dropdown.inline.js-group-filter-dropdown-wrap
+- show_archive_options = local_assigns.fetch(:show_archive_options, false)
+- if @sort.present?
+ - default_sort_by = @sort
+- else
+ - if params[:sort]
+ - default_sort_by = params[:sort]
+ - else
+ - default_sort_by = sort_value_recently_created
+
+.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
+ = sort_options_hash[default_sort_by]
= icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right
- %li
- = link_to filter_groups_path(sort: sort_value_recently_created) do
- = sort_title_recently_created
- = link_to filter_groups_path(sort: sort_value_oldest_created) do
- = sort_title_oldest_created
- = link_to filter_groups_path(sort: sort_value_recently_updated) do
- = sort_title_recently_updated
- = link_to filter_groups_path(sort: sort_value_oldest_updated) do
- = sort_title_oldest_updated
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - groups_sort_options_hash.each do |value, title|
+ %li.js-filter-sort-order
+ = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
+ = title
+ - if show_archive_options
+ %li.divider
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
+ Hide archived projects
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ Show archived projects
+ %li.js-filter-archived-projects
+ = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ Show archived projects only
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
new file mode 100644
index 00000000000..13bb4baee3f
--- /dev/null
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -0,0 +1,7 @@
+.groups-empty-state
+ = custom_icon("icon_empty_groups")
+
+ .text-content
+ %h4= s_("GroupsEmptyState|A group is a collection of several projects.")
+ %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
+ %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 63f62eb476e..059dd24be6d 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -11,7 +11,7 @@
= link_to edit_group_path(group), class: "btn" do
= icon('cogs')
- = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
+ = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do
= icon('sign-out')
.stats
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index 427595c47a5..aec8ecd1714 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -3,4 +3,4 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group
- else
- .nothing-here-block No groups found
+ .nothing-here-block= s_("GroupsEmptyState|No groups found")
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index ad7a7faedf1..3f91263089a 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
-= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f|
- = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg
index 807ff27bb67..7e47c084bde 100644
--- a/app/views/shared/icons/_icon_autodevops.svg
+++ b/app/views/shared/icons/_icon_autodevops.svg
@@ -1,4 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="189" height="179" viewBox="0 0 189 179">
+<svg xmlns="http://www.w3.org/2000/svg" width="189" height="110" viewBox="0 0 189 179">
<g fill="none" fill-rule="evenodd">
<path fill="#FFFFFF" fill-rule="nonzero" d="M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
<path fill="#EEEEEE" fill-rule="nonzero" d="M110.160166,51.6956996 C106.846457,51.6956996 104.160166,54.3819911 104.160166,57.6956996 L104.160166,117.6957 C104.160166,121.009408 106.846457,123.6957 110.160166,123.6957 L160.160166,123.6957 C163.473874,123.6957 166.160166,121.009408 166.160166,117.6957 L166.160166,57.6956996 C166.160166,54.3819911 163.473874,51.6956996 160.160166,51.6956996 L110.160166,51.6956996 Z M110.160166,47.6956996 L160.160166,47.6956996 C165.683013,47.6956996 170.160166,52.1728521 170.160166,57.6956996 L170.160166,117.6957 C170.160166,123.218547 165.683013,127.6957 160.160166,127.6957 L110.160166,127.6957 C104.637318,127.6957 100.160166,123.218547 100.160166,117.6957 L100.160166,57.6956996 C100.160166,52.1728521 104.637318,47.6956996 110.160166,47.6956996 Z" transform="rotate(10 135.16 87.696)"/>
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 80432a73e4e..3d917346f6b 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,5 +1,5 @@
- @sort ||= sort_value_latest_activity
-.dropdown
+.dropdown.js-project-filter-dropdown-wrap
- toggle_text = projects_sort_options_hash[@sort]
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
diff --git a/bin/changelog b/bin/changelog
index 61d4de06e90..efe25032ba1 100755
--- a/bin/changelog
+++ b/bin/changelog
@@ -28,6 +28,7 @@ class ChangelogOptionParser
Type.new('deprecated', 'New deprecation'),
Type.new('removed', 'Feature removal'),
Type.new('security', 'Security fix'),
+ Type.new('performance', 'Performance improvement'),
Type.new('other', 'Other')
].freeze
TYPES_OFFSET = 1
diff --git a/changelogs/unreleased/27654-retry-button.yml b/changelogs/unreleased/27654-retry-button.yml
new file mode 100644
index 00000000000..11f3b5eb779
--- /dev/null
+++ b/changelogs/unreleased/27654-retry-button.yml
@@ -0,0 +1,5 @@
+---
+title: Move retry button in job page to sidebar
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
new file mode 100644
index 00000000000..554249a3f88
--- /dev/null
+++ b/changelogs/unreleased/37978-extra-border-radius-while-editing-a-file.yml
@@ -0,0 +1,6 @@
+---
+title: Removed extra border radius from .file-editor and .file-holder when editing
+ a file
+merge_request: 14803
+author: Rachel Pipkin
+type: fixed
diff --git a/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml b/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml
new file mode 100644
index 00000000000..48b92c02505
--- /dev/null
+++ b/changelogs/unreleased/38236-remove-build-failed-todo-if-it-has-been-auto-retried.yml
@@ -0,0 +1,5 @@
+---
+title: Don't create build failed todos when the job is automatically retried
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
new file mode 100644
index 00000000000..89506f88637
--- /dev/null
+++ b/changelogs/unreleased/39017-gitlabusagepingworker-is-not-running-on-gitlab-com.yml
@@ -0,0 +1,5 @@
+---
+title: Make usage ping scheduling more robust
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
new file mode 100644
index 00000000000..4b90d68d80c
--- /dev/null
+++ b/changelogs/unreleased/39035-move-gitlab-export-to-top-import-list.yml
@@ -0,0 +1,5 @@
+---
+title: 14830 Move GitLab export option to top of import list when creating a new project
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/an-popen-deadline.yml b/changelogs/unreleased/an-popen-deadline.yml
new file mode 100644
index 00000000000..4b74c63ed5c
--- /dev/null
+++ b/changelogs/unreleased/an-popen-deadline.yml
@@ -0,0 +1,5 @@
+---
+title: Use a timeout on certain git operations
+merge_request: 14872
+author:
+type: security
diff --git a/changelogs/unreleased/bvl-circuitbreaker-improvements.yml b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
new file mode 100644
index 00000000000..15cbd5592e9
--- /dev/null
+++ b/changelogs/unreleased/bvl-circuitbreaker-improvements.yml
@@ -0,0 +1,5 @@
+---
+title: Store circuitbreaker settings in the database instead of config
+merge_request: 14842
+author:
+type: changed
diff --git a/changelogs/unreleased/bvl-group-trees.yml b/changelogs/unreleased/bvl-group-trees.yml
new file mode 100644
index 00000000000..9f76eb81627
--- /dev/null
+++ b/changelogs/unreleased/bvl-group-trees.yml
@@ -0,0 +1,5 @@
+---
+title: Show collapsible project lists
+merge_request: 14055
+author:
+type: changed
diff --git a/changelogs/unreleased/fix_diff_parsing.yml b/changelogs/unreleased/fix_diff_parsing.yml
new file mode 100644
index 00000000000..7a26b4f9ff5
--- /dev/null
+++ b/changelogs/unreleased/fix_diff_parsing.yml
@@ -0,0 +1,5 @@
+---
+title: Fix diff parser so it tolerates to diff special markers in the content
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_global_board_routes_39073.yml b/changelogs/unreleased/fix_global_board_routes_39073.yml
new file mode 100644
index 00000000000..cc9ae8592db
--- /dev/null
+++ b/changelogs/unreleased/fix_global_board_routes_39073.yml
@@ -0,0 +1,5 @@
+---
+title: Allow boards as top level route
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fl-autodevops-fix.yml b/changelogs/unreleased/fl-autodevops-fix.yml
new file mode 100644
index 00000000000..21b739231a8
--- /dev/null
+++ b/changelogs/unreleased/fl-autodevops-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Improve autodevops banner UX and render it only in project page
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/sha-handling.yml b/changelogs/unreleased/sha-handling.yml
new file mode 100644
index 00000000000..d776edafef5
--- /dev/null
+++ b/changelogs/unreleased/sha-handling.yml
@@ -0,0 +1,5 @@
+---
+title: Fix 404 errors in API caused when the branch name had a dot
+merge_request: 14462
+author: gvieira37
+type: fixed
diff --git a/changelogs/unreleased/winh-indeterminate-dropdown.yml b/changelogs/unreleased/winh-indeterminate-dropdown.yml
new file mode 100644
index 00000000000..61205d1643e
--- /dev/null
+++ b/changelogs/unreleased/winh-indeterminate-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Fix alignment for indeterminate marker in dropdowns
+merge_request: 14809
+author:
+type: fixed
diff --git a/changelogs/unreleased/zj-add-performance-changelog-cat.yml b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
new file mode 100644
index 00000000000..3d58044a254
--- /dev/null
+++ b/changelogs/unreleased/zj-add-performance-changelog-cat.yml
@@ -0,0 +1,5 @@
+---
+title: Add Performance improvement as category on the changelog
+merge_request:
+author:
+type: performance
diff --git a/config/application.rb b/config/application.rb
index 31e91835b9e..5100ec5d2b7 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -29,6 +29,7 @@ module Gitlab
#{config.root}/app/models/project_services
#{config.root}/app/workers/concerns
#{config.root}/app/services/concerns
+ #{config.root}/app/serializers/concerns
#{config.root}/app/finders/concerns])
config.generators.templates.push("#{config.root}/generator_templates")
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 1069c7be5f0..4bfa5be0136 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -522,11 +522,6 @@ production: &base
path: /home/git/repositories/
gitaly_address: unix:/home/git/gitlab/tmp/sockets/private/gitaly.socket # TCP connections are supported too (e.g. tcp://host:port)
# gitaly_token: 'special token' # Optional: override global gitaly.token for this storage.
- failure_count_threshold: 10 # number of failures before stopping attempts
- failure_wait_time: 30 # Seconds after an access failure before allowing access again
- failure_reset_time: 1800 # Time in seconds to expire failures
- storage_timeout: 30 # Time in seconds to wait before aborting a storage access attempt
-
## Backup settings
backup:
@@ -659,9 +654,6 @@ test:
default:
path: tmp/tests/repositories/
gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
- failure_count_threshold: 999999
- failure_wait_time: 0
- storage_timeout: 30
broken:
path: tmp/tests/non-existent-repositories
gitaly_address: unix:tmp/tests/gitaly/gitaly.socket
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index a4b7c1a3919..12694f8016f 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -113,12 +113,14 @@ class Settings < Settingslogic
URI.parse(url_without_path).host
end
- # Random cron time every Sunday to load balance usage pings
- def cron_random_weekly_time
+ # Runs every minute in a random ten-minute period on Sundays, to balance the
+ # load on the server receiving these pings. The usage ping is safe to run
+ # multiple times because of a 24 hour exclusive lock.
+ def cron_for_usage_ping
hour = rand(24)
- minute = rand(60)
+ minute = rand(6)
- "#{minute} #{hour} * * 0"
+ "#{minute}0-#{minute}9 #{hour} * * 0"
end
end
end
@@ -398,7 +400,7 @@ Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
-Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time)
+Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_for_usage_ping)
Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
@@ -453,17 +455,6 @@ Settings.repositories.storages.each do |key, storage|
# Expand relative paths
storage['path'] = Settings.absolute(storage['path'])
- # Set failure defaults
- storage['failure_count_threshold'] ||= 10
- storage['failure_wait_time'] ||= 30
- storage['failure_reset_time'] ||= 1800
- storage['storage_timeout'] ||= 5
- # Set turn strings into numbers
- storage['failure_count_threshold'] = storage['failure_count_threshold'].to_i
- storage['failure_wait_time'] = storage['failure_wait_time'].to_i
- storage['failure_reset_time'] = storage['failure_reset_time'].to_i
- # We might want to have a timeout shorter than 1 second.
- storage['storage_timeout'] = storage['storage_timeout'].to_f
Settings.repositories.storages[key] = storage
end
diff --git a/config/routes.rb b/config/routes.rb
index 405bfcc2d8e..fc13dc4865f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -44,6 +44,19 @@ Rails.application.routes.draw do
get 'readiness' => 'health#readiness'
resources :metrics, only: [:index]
mount Peek::Railtie => '/peek'
+
+ # Boards resources shared between group and projects
+ resources :boards, only: [] do
+ resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
+ collection do
+ post :generate
+ end
+
+ resources :issues, only: [:index, :create, :update]
+ end
+
+ resources :issues, module: :boards, only: [:index, :update]
+ end
end
# Koding route
@@ -74,19 +87,6 @@ Rails.application.routes.draw do
# Notification settings
resources :notification_settings, only: [:create, :update]
- # Boards resources shared between group and projects
- resources :boards do
- resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
- collection do
- post :generate
- end
-
- resources :issues, only: [:index, :create, :update]
- end
-
- resources :issues, module: :boards, only: [:index, :update]
- end
-
draw :google_api
draw :import
draw :uploads
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 8cc30bfcc50..702df5b7b5a 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -32,6 +32,8 @@ scope(path: 'groups/*group_id',
end
resources :variables, only: [:index, :show, :update, :create, :destroy]
+
+ resources :children, only: [:index]
end
end
@@ -43,7 +45,6 @@ scope(path: 'groups/*id',
get :merge_requests, as: :merge_requests_group
get :projects, as: :projects_group
get :activity, as: :activity_group
- get :subgroups, as: :subgroups_group
get '/', action: :show, as: :group_canonical
end
diff --git a/config/webpack.config.js b/config/webpack.config.js
index a71794b379d..f7a7182a627 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -236,7 +236,7 @@ var config = {
from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`),
to: 'monaco-editor/vs',
transform: function(content, path) {
- if (/\.js$/.test(path) && !/worker/i.test(path)) {
+ if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) {
return (
'(function(){\n' +
'var define = this.define, require = this.require;\n' +
diff --git a/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
new file mode 100644
index 00000000000..bcf7dbd8e64
--- /dev/null
+++ b/db/migrate/20171012101043_add_circuit_breaker_properties_to_application_settings.rb
@@ -0,0 +1,27 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddCircuitBreakerPropertiesToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings,
+ :circuitbreaker_failure_count_threshold,
+ :integer,
+ default: 160
+ add_column :application_settings,
+ :circuitbreaker_failure_wait_time,
+ :integer,
+ default: 30
+ add_column :application_settings,
+ :circuitbreaker_failure_reset_time,
+ :integer,
+ default: 1800
+ add_column :application_settings,
+ :circuitbreaker_storage_timeout,
+ :integer,
+ default: 30
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8aadcfeb7d1..c2c04873d4d 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: 20171006091000) do
+ActiveRecord::Schema.define(version: 20171012101043) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -134,6 +134,10 @@ ActiveRecord::Schema.define(version: 20171006091000) do
t.boolean "hashed_storage_enabled", default: false, null: false
t.boolean "project_export_enabled", default: true, null: false
t.boolean "auto_devops_enabled", default: false, null: false
+ t.integer "circuitbreaker_failure_count_threshold", default: 160
+ t.integer "circuitbreaker_failure_wait_time", default: 30
+ t.integer "circuitbreaker_failure_reset_time", default: 1800
+ t.integer "circuitbreaker_storage_timeout", default: 30
end
create_table "audit_events", force: :cascade do |t|
diff --git a/doc/administration/img/circuitbreaker_config.png b/doc/administration/img/circuitbreaker_config.png
new file mode 100644
index 00000000000..9250d38297c
--- /dev/null
+++ b/doc/administration/img/circuitbreaker_config.png
Binary files differ
diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md
index 04c70c3644e..6b8ad1b039b 100644
--- a/doc/administration/raketasks/github_import.md
+++ b/doc/administration/raketasks/github_import.md
@@ -7,6 +7,7 @@
> projects. You can get it from: https://github.com/settings/tokens
> - You also need to pass an username as the second argument to the rake task
> which will become the owner of the project.
+> - You can also resume an import with the same command.
To import a project from the list of your GitHub projects available:
diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md
index 624a908b3a3..efcabd69822 100644
--- a/doc/administration/repository_storage_paths.md
+++ b/doc/administration/repository_storage_paths.md
@@ -105,61 +105,26 @@ When GitLab detects access to the repositories storage fails repeatedly, it can
gracefully prevent attempts to access the storage. This might be useful when
the repositories are stored somewhere on the network.
-The configuration could look as follows:
+This can be configured from the admin interface:
-**For Omnibus installations**
-
-1. Edit `/etc/gitlab/gitlab.rb`:
-
- ```ruby
- git_data_dirs({
- "default" => {
- "path" => "/mnt/nfs-01/git-data",
- "failure_count_threshold" => 10,
- "failure_wait_time" => 30,
- "failure_reset_time" => 1800,
- "storage_timeout" => 5
- }
- })
- ```
-
-1. Save the file and [reconfigure GitLab][reconfigure-gitlab] for the changes to take effect.
-
----
-
-**For installations from source**
-
-1. Edit `config/gitlab.yml`:
-
- ```yaml
- repositories:
- storages: # You must have at least a `default` storage path.
- default:
- path: /home/git/repositories/
- failure_count_threshold: 10 # number of failures before stopping attempts
- failure_wait_time: 30 # Seconds after last access failure before trying again
- failure_reset_time: 1800 # Time in seconds to expire failures
- storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt
- ```
-
-1. Save the file and [restart GitLab][restart-gitlab] for the changes to take effect.
+![circuitbreaker configuration](img/circuitbreaker_config.png)
-**`failure_count_threshold`:** The number of failures of after which GitLab will
+**Maximum git storage failures:** The number of failures of after which GitLab will
completely prevent access to the storage. The number of failures can be reset in
the admin interface: `https://gitlab.example.com/admin/health_check` or using the
[api](../api/repository_storage_health.md) to allow access to the storage again.
-**`failure_wait_time`:** When access to a storage fails. GitLab will prevent
-access to the storage for the time specified here. This allows the filesystem to
-recover without.
+**Seconds to wait after a storage failure:** When access to a storage fails. GitLab
+will prevent access to the storage for the time specified here. This allows the
+filesystem to recover.
-**`failure_reset_time`:** The time in seconds GitLab will keep failure
-information. When no failures occur during this time, information about the
+**Seconds before reseting failure information:** The time in seconds GitLab will
+keep failure information. When no failures occur during this time, information about the
mount is reset.
-**`storage_timeout`:** The time in seconds GitLab will try to access storage.
-After this time a timeout error will be raised.
+**Seconds to wait for a storage access attempt:** The time in seconds GitLab will
+try to access storage. After this time a timeout error will be raised.
When storage failures occur, this will be visible in the admin interface like this:
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index bccef924375..594babc74be 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -85,7 +85,7 @@ GET /projects/:id/repository/blobs/:sha
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
-- `sha` (required) - The commit or branch name
+- `sha` (required) - The blob SHA
## Raw blob content
diff --git a/doc/api/settings.md b/doc/api/settings.md
index be75e1b66ba..664f3ef7b77 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -69,6 +69,10 @@ PUT /application/settings
| `after_sign_up_text` | string | no | Text shown to the user after signing up |
| `akismet_api_key` | string | no | API key for akismet spam protection |
| `akismet_enabled` | boolean | no | Enable or disable akismet spam protection |
+| `circuitbreaker_failure_count_threshold` | integer | no | The number of failures of after which GitLab will completely prevent access to the storage. |
+| `circuitbreaker_failure_reset_time` | integer | no | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset. |
+| `circuitbreaker_failure_wait_time` | integer | no | Time in seconds GitLab will block access to a failing storage to allow it to recover. |
+| `circuitbreaker_storage_timeout` | integer | no | Seconds to wait for a storage access attempt |
| `clientside_sentry_dsn` | string | no | Required if `clientside_sentry_dsn` is enabled |
| `clientside_sentry_enabled` | boolean | no | Enable Sentry error reporting for the client side |
| `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes |
diff --git a/doc/ci/README.md b/doc/ci/README.md
index 5cfd82de381..ec0ddfbea75 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -42,7 +42,7 @@ digging into specific reference guides.
- **The permissions model** - Learn about the access levels a user can have for
performing certain CI actions
- [User permissions](../user/permissions.md#gitlab-ci)
- - [Jobs permissions](../user/permissions.md#jobs-permissions)
+ - [Job permissions](../user/permissions.md#job-permissions)
## Auto DevOps
diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md
index e41d258bec6..ca2048c7019 100644
--- a/doc/development/gitaly.md
+++ b/doc/development/gitaly.md
@@ -52,8 +52,8 @@ rm -rf tmp/tests/gitaly
## `TooManyInvocationsError` errors
-During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures.
-The `GitalyClient` will attempt to block against potential n+1 issues by raising this error
+During development and testing, you may experience `Gitlab::GitalyClient::TooManyInvocationsError` failures.
+The `GitalyClient` will attempt to block against potential n+1 issues by raising this error
when Gitaly is called more than 30 times in a single Rails request or Sidekiq execution.
As a temporary measure, export `GITALY_DISABLE_REQUEST_LIMITS=1` to suppress the error. This will disable the n+1 detection
@@ -64,7 +64,7 @@ Please raise an issue in the GitLab CE or EE repositories to report the issue. I
`TooManyInvocationsError`. Also include any known failing tests if possible.
Isolate the source of the n+1 problem. This will normally be a loop that results in Gitaly being called for each
-element in an array. If you are unable to isolate the problem, please contact a member
+element in an array. If you are unable to isolate the problem, please contact a member
of the [Gitaly Team](https://gitlab.com/groups/gl-gitaly/group_members) for assistance.
Once the source has been found, wrap it in an `allow_n_plus_1_calls` block, as follows:
@@ -79,6 +79,24 @@ end
Once the code is wrapped in this block, this code-path will be excluded from n+1 detection.
+## Request counts
+
+Commits and other git data, is now fetched through Gitaly. These fetches can,
+much like with a database, be batched. This improves performance for the client
+and for Gitaly itself and therefore for the users too. To keep performance stable
+and guard performance regressions, Gitaly calls can be counted and the call count
+can be tested against. This requires the `:request_store` flag to be set.
+
+```ruby
+describe 'Gitaly Request count tests' do
+ context 'when the request store is activated', :request_store do
+ it 'correctly counts the gitaly requests made' do
+ expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+ end
+ end
+end
+```
+
---
[Return to Development documentation](README.md)
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index 67ef189fee9..e18711f3392 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -17,7 +17,7 @@
[Project Templates](https://gitlab.com/gitlab-org/project-templates):
this will kickstart your repository code and CI automatically.
Otherwise, if you have a project in a different repository, you can [import it] by
- clicking an **Import project from** button provided this is enabled in
+ clicking on the **Import project** tab, provided this is enabled in
your GitLab instance. Ask your administrator if not.
1. Provide the following information:
diff --git a/doc/gitlab-basics/img/create_new_project_info.png b/doc/gitlab-basics/img/create_new_project_info.png
index ef8753e224b..ce4f7d1204b 100644
--- a/doc/gitlab-basics/img/create_new_project_info.png
+++ b/doc/gitlab-basics/img/create_new_project_info.png
Binary files differ
diff --git a/doc/install/installation.md b/doc/install/installation.md
index 2c93297ca2f..2a004152d5e 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed
# Install Git
sudo apt-get install -y git-core
- # Make sure Git is version 2.13.0 or higher
+ # Make sure Git is version 2.13.6 or higher
git --version
Is the system packaged Git too old? Remove it and compile from source.
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 17fe80fa93d..3d7becd18fc 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -121,8 +121,8 @@ Existing users using GitLab with MySQL/MariaDB are advised to
### PostgreSQL Requirements
-As of GitLab 9.3, PostgreSQL 9.2 or newer is required, and earlier versions are
-not supported. We highly recommend users to use at least PostgreSQL 9.6 as this
+As of GitLab 10.0, PostgreSQL 9.6 or newer is required, and earlier versions are
+not supported. We highly recommend users to use PostgreSQL 9.6 as this
is the PostgreSQL version used for development and testing.
Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every
diff --git a/doc/user/project/issues/automatic_issue_closing.md b/doc/user/project/issues/automatic_issue_closing.md
index d6f3a7d5555..10dede255ec 100644
--- a/doc/user/project/issues/automatic_issue_closing.md
+++ b/doc/user/project/issues/automatic_issue_closing.md
@@ -19,7 +19,7 @@ When not specified, the default issue closing pattern as shown below will be
used:
```bash
-((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)
+((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing)|[Ii]mplement(?:s|ed|ing)?)(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)
```
Note that `%{issue_ref}` is a complex regular expression defined inside GitLab's
@@ -34,6 +34,7 @@ This translates to the following keywords:
- Close, Closes, Closed, Closing, close, closes, closed, closing
- Fix, Fixes, Fixed, Fixing, fix, fixes, fixed, fixing
- Resolve, Resolves, Resolved, Resolving, resolve, resolves, resolved, resolving
+- Implement, Implements, Implemented, Implementing, implement, implements, implemented, implementing
---
diff --git a/features/explore/groups.feature b/features/explore/groups.feature
index 9eacbe0b25e..830810615e0 100644
--- a/features/explore/groups.feature
+++ b/features/explore/groups.feature
@@ -3,6 +3,7 @@ Feature: Explore Groups
Background:
Given group "TestGroup" has private project "Enterprise"
+ @javascript
Scenario: I should see group with private and internal projects as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -10,6 +11,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for internal project as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -17,6 +19,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for internal project as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
@@ -24,6 +27,7 @@ Feature: Explore Groups
Then I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with private, internal and public projects as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -32,6 +36,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for public project as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -40,6 +45,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for public project as visitor
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -48,6 +54,7 @@ Feature: Explore Groups
And I should not see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with private, internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -57,6 +64,7 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group issues for internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -66,6 +74,7 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group merge requests for internal and public projects as user
Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community"
@@ -75,17 +84,20 @@ Feature: Explore Groups
And I should see project "Internal" items
And I should not see project "Enterprise" items
+ @javascript
Scenario: I should see group with public project in public groups area
Given group "TestGroup" has public project "Community"
When I visit the public groups area
Then I should see group "TestGroup"
+ @javascript
Scenario: I should see group with public project in public groups area as user
Given group "TestGroup" has public project "Community"
When I sign in as a user
And I visit the public groups area
Then I should see group "TestGroup"
+ @javascript
Scenario: I should see group with internal project in public groups area as user
Given group "TestGroup" has internal project "Internal"
When I sign in as a user
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 79e55a2f4f7..99fcc59ba04 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -4,6 +4,10 @@ module API
LOG_FILENAME = Rails.root.join("log", "api_json.log")
+ NO_SLASH_URL_PART_REGEX = %r{[^/]+}
+ PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
+ COMMIT_ENDPOINT_REQUIREMENTS = PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze
+
use GrapeLogging::Middleware::RequestLogger,
logger: Logger.new(LOG_FILENAME),
formatter: Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp.new,
@@ -96,9 +100,6 @@ module API
helpers ::API::Helpers
helpers ::API::Helpers::CommonHelpers
- NO_SLASH_URL_PART_REGEX = %r{[^/]+}
- PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze
-
# Keep in alphabetical order
mount ::API::AccessRequests
mount ::API::AwardEmoji
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 4af37a2ad1d..2685dc27252 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -4,8 +4,6 @@ module API
class Commits < Grape::API
include PaginationParams
- COMMIT_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: API::NO_SLASH_URL_PART_REGEX)
-
before { authorize! :download_code, user_project }
params do
@@ -85,7 +83,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -99,7 +97,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/diff', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha/diff', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -115,7 +113,7 @@ module API
use :pagination
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -132,7 +130,7 @@ module API
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag to be cherry picked'
requires :branch, type: String, desc: 'The name of the branch'
end
- post ':id/repository/commits/:sha/cherry_pick', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
commit = user_project.commit(params[:sha])
@@ -169,7 +167,7 @@ module API
requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
end
end
- post ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do
+ post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -186,7 +184,7 @@ module API
lines.each do |line|
next unless line.new_pos == params[:line] && line.type == params[:line_type]
- break opts[:line_code] = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos)
+ break opts[:line_code] = Gitlab::Git.diff_line_code(diff.new_path, line.new_pos, line.old_pos)
end
break if opts[:line_code]
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index ceee3226732..7887b886c03 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -57,7 +57,7 @@ module API
desc 'Get raw blob contents from the repository'
params do
- requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ requires :sha, type: String, desc: 'The commit hash'
end
get ':id/repository/blobs/:sha/raw' do
assign_blob_vars!
@@ -67,7 +67,7 @@ module API
desc 'Get a blob from the repository'
params do
- requires :sha, type: String, desc: 'The commit, branch name, or tag name'
+ requires :sha, type: String, desc: 'The commit hash'
end
get ':id/repository/blobs/:sha' do
assign_blob_vars!
diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb
index c189d486f50..f493fd7c7ec 100644
--- a/lib/api/v3/builds.rb
+++ b/lib/api/v3/builds.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
params :optional_scope do
optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb
index 345cb7e7c11..ed206a6def0 100644
--- a/lib/api/v3/commits.rb
+++ b/lib/api/v3/commits.rb
@@ -11,7 +11,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a project repository commits' do
success ::API::Entities::Commit
end
@@ -72,7 +72,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha" do
+ get ":id/repository/commits/:sha", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! "Commit" unless commit
@@ -86,7 +86,7 @@ module API
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ":id/repository/commits/:sha/diff" do
+ get ":id/repository/commits/:sha/diff", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! "Commit" unless commit
@@ -102,7 +102,7 @@ module API
use :pagination
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
end
- get ':id/repository/commits/:sha/comments' do
+ get ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -119,7 +119,7 @@ module API
requires :sha, type: String, desc: 'A commit sha to be cherry picked'
requires :branch, type: String, desc: 'The name of the branch'
end
- post ':id/repository/commits/:sha/cherry_pick' do
+ post ':id/repository/commits/:sha/cherry_pick', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
commit = user_project.commit(params[:sha])
@@ -156,7 +156,7 @@ module API
requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
end
end
- post ':id/repository/commits/:sha/comments' do
+ post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
@@ -173,7 +173,7 @@ module API
lines.each do |line|
next unless line.new_pos == params[:line] && line.type == params[:line_type]
- break opts[:line_code] = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos)
+ break opts[:line_code] = Gitlab::Git.diff_line_code(diff.new_path, line.new_pos, line.old_pos)
end
break if opts[:line_code]
diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb
index 41a7c6b83ae..f9a47101e27 100644
--- a/lib/api/v3/repositories.rb
+++ b/lib/api/v3/repositories.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
@@ -43,7 +43,7 @@ module API
requires :sha, type: String, desc: 'The commit, branch name, or tag name'
requires :filepath, type: String, desc: 'The path to the file to display'
end
- get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"] do
+ get [":id/repository/blobs/:sha", ":id/repository/commits/:sha/blob"], requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
repo = user_project.repository
commit = repo.commit(params[:sha])
not_found! "Commit" unless commit
@@ -56,7 +56,7 @@ module API
params do
requires :sha, type: String, desc: 'The commit, branch name, or tag name'
end
- get ':id/repository/raw_blobs/:sha' do
+ get ':id/repository/raw_blobs/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
repo = user_project.repository
begin
blob = Gitlab::Git::Blob.raw(repo, params[:sha])
diff --git a/lib/github/representation/comment.rb b/lib/github/representation/comment.rb
index 1b5be91461b..83bf0b5310d 100644
--- a/lib/github/representation/comment.rb
+++ b/lib/github/representation/comment.rb
@@ -23,7 +23,7 @@ module Github
private
def generate_line_code(line)
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def on_diff?
diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
index 8e5c95f2287..380802258f5 100644
--- a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
+++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
@@ -81,6 +81,7 @@ module Gitlab
def single_diff_rows(merge_request_diff)
sha_attribute = Gitlab::Database::ShaAttribute.new
commits = YAML.load(merge_request_diff.st_commits) rescue []
+ commits ||= []
commit_rows = commits.map.with_index do |commit, index|
commit_hash = commit.to_hash.with_indifferent_access.except(:parent_ids)
diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb
index d1979bb7ed3..033ecd15749 100644
--- a/lib/gitlab/bitbucket_import/importer.rb
+++ b/lib/gitlab/bitbucket_import/importer.rb
@@ -241,7 +241,7 @@ module Gitlab
end
def generate_line_code(pr_comment)
- Gitlab::Diff::LineCode.generate(pr_comment.file_path, pr_comment.new_pos, pr_comment.old_pos)
+ Gitlab::Git.diff_line_code(pr_comment.file_path, pr_comment.new_pos, pr_comment.old_pos)
end
def pull_request_comment_attributes(comment)
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
index 98dfe900044..2a0cb640a14 100644
--- a/lib/gitlab/conflict/file.rb
+++ b/lib/gitlab/conflict/file.rb
@@ -4,82 +4,29 @@ module Gitlab
include Gitlab::Routing
include IconsHelper
- MissingResolution = Class.new(ResolutionError)
-
CONTEXT_LINES = 3
- attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository
-
- def initialize(merge_file_result, conflict, merge_request:)
- @merge_file_result = merge_file_result
- @their_path = conflict[:theirs][:path]
- @our_path = conflict[:ours][:path]
- @our_mode = conflict[:ours][:mode]
- @merge_request = merge_request
- @repository = merge_request.project.repository
- @match_line_headers = {}
- end
-
- def content
- merge_file_result[:data]
- end
+ attr_reader :merge_request
- def our_blob
- @our_blob ||= repository.blob_at(merge_request.diff_refs.head_sha, our_path)
- end
+ # 'raw' holds the Gitlab::Git::Conflict::File that this instance wraps
+ attr_reader :raw
- def type
- lines unless @type
+ delegate :type, :content, :their_path, :our_path, :our_mode, :our_blob, :repository, to: :raw
- @type.inquiry
+ def initialize(raw, merge_request:)
+ @raw = raw
+ @merge_request = merge_request
+ @match_line_headers = {}
end
- # Array of Gitlab::Diff::Line objects
def lines
return @lines if defined?(@lines)
- begin
- @type = 'text'
- @lines = Gitlab::Conflict::Parser.new.parse(content,
- our_path: our_path,
- their_path: their_path,
- parent_file: self)
- rescue Gitlab::Conflict::Parser::ParserError
- @type = 'text-editor'
- @lines = nil
- end
+ @lines = raw.lines.nil? ? nil : map_raw_lines(raw.lines)
end
def resolve_lines(resolution)
- section_id = nil
-
- lines.map do |line|
- unless line.type
- section_id = nil
- next line
- end
-
- section_id ||= line_code(line)
-
- case resolution[section_id]
- when 'head'
- next unless line.type == 'new'
- when 'origin'
- next unless line.type == 'old'
- else
- raise MissingResolution, "Missing resolution for section ID: #{section_id}"
- end
-
- line
- end.compact
- end
-
- def resolve_content(resolution)
- if resolution == content
- raise MissingResolution, "Resolved content has no changes for file #{our_path}"
- end
-
- resolution
+ map_raw_lines(raw.resolve_lines(resolution))
end
def highlight_lines!
@@ -163,7 +110,7 @@ module Gitlab
end
def line_code(line)
- Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(our_path, line.new_pos, line.old_pos)
end
def create_match_line(line)
@@ -227,15 +174,14 @@ module Gitlab
new_path: our_path)
end
- # Don't try to print merge_request or repository.
- def inspect
- instance_variables = [:merge_file_result, :their_path, :our_path, :our_mode, :type].map do |instance_variable|
- value = instance_variable_get("@#{instance_variable}")
+ private
- "#{instance_variable}=\"#{value}\""
+ def map_raw_lines(raw_lines)
+ raw_lines.map do |raw_line|
+ Gitlab::Diff::Line.new(raw_line[:full_line], raw_line[:type],
+ raw_line[:line_obj_index], raw_line[:line_old],
+ raw_line[:line_new], parent_file: self)
end
-
- "#<#{self.class} #{instance_variables.join(' ')}>"
end
end
end
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
index 90f83e0f810..fb28e80ff73 100644
--- a/lib/gitlab/conflict/file_collection.rb
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -1,48 +1,29 @@
module Gitlab
module Conflict
class FileCollection
- ConflictSideMissing = Class.new(StandardError)
-
- attr_reader :merge_request, :our_commit, :their_commit, :project
-
- delegate :repository, to: :project
-
- class << self
- # We can only write when getting the merge index from the source
- # project, because we will write to that project. We don't use this all
- # the time because this fetches a ref into the source project, which
- # isn't needed for reading.
- def for_resolution(merge_request)
- project = merge_request.source_project
-
- new(merge_request, project).tap do |file_collection|
- project
- .repository
- .with_repo_branch_commit(merge_request.target_project.repository.raw_repository, merge_request.target_branch) do
-
- yield file_collection
- end
- end
- end
-
- # We don't need to do `with_repo_branch_commit` here, because the target
- # project always fetches source refs when creating merge request diffs.
- def read_only(merge_request)
- new(merge_request, merge_request.target_project)
- end
+ attr_reader :merge_request, :resolver
+
+ def initialize(merge_request)
+ source_repo = merge_request.source_project.repository.raw
+ our_commit = merge_request.source_branch_head.raw
+ their_commit = merge_request.target_branch_head.raw
+ target_repo = merge_request.target_project.repository.raw
+ @resolver = Gitlab::Git::Conflict::Resolver.new(source_repo, our_commit, target_repo, their_commit)
+ @merge_request = merge_request
end
- def merge_index
- @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit)
+ def resolve(user, commit_message, files)
+ args = {
+ source_branch: merge_request.source_branch,
+ target_branch: merge_request.target_branch,
+ commit_message: commit_message || default_commit_message
+ }
+ resolver.resolve_conflicts(user, files, args)
end
def files
- @files ||= merge_index.conflicts.map do |conflict|
- raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
-
- Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]),
- conflict,
- merge_request: merge_request)
+ @files ||= resolver.conflicts.map do |conflict_file|
+ Gitlab::Conflict::File.new(conflict_file, merge_request: merge_request)
end
end
@@ -61,8 +42,8 @@ module Gitlab
end
def default_commit_message
- conflict_filenames = merge_index.conflicts.map do |conflict|
- "# #{conflict[:ours][:path]}"
+ conflict_filenames = files.map do |conflict|
+ "# #{conflict.our_path}"
end
<<EOM.chomp
@@ -72,15 +53,6 @@ Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branc
#{conflict_filenames.join("\n")}
EOM
end
-
- private
-
- def initialize(merge_request, project)
- @merge_request = merge_request
- @our_commit = merge_request.source_branch_head.raw.rugged_commit
- @their_commit = merge_request.target_branch_head.raw.rugged_commit
- @project = project
- end
end
end
end
diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb
deleted file mode 100644
index e3678c914db..00000000000
--- a/lib/gitlab/conflict/parser.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-module Gitlab
- module Conflict
- class Parser
- UnresolvableError = Class.new(StandardError)
- UnmergeableFile = Class.new(UnresolvableError)
- UnsupportedEncoding = Class.new(UnresolvableError)
-
- # Recoverable errors - the conflict can be resolved in an editor, but not with
- # sections.
- ParserError = Class.new(StandardError)
- UnexpectedDelimiter = Class.new(ParserError)
- MissingEndDelimiter = Class.new(ParserError)
-
- def parse(text, our_path:, their_path:, parent_file: nil)
- validate_text!(text)
-
- line_obj_index = 0
- line_old = 1
- line_new = 1
- type = nil
- lines = []
- conflict_start = "<<<<<<< #{our_path}"
- conflict_middle = '======='
- conflict_end = ">>>>>>> #{their_path}"
-
- text.each_line.map do |line|
- full_line = line.delete("\n")
-
- if full_line == conflict_start
- validate_delimiter!(type.nil?)
-
- type = 'new'
- elsif full_line == conflict_middle
- validate_delimiter!(type == 'new')
-
- type = 'old'
- elsif full_line == conflict_end
- validate_delimiter!(type == 'old')
-
- type = nil
- elsif line[0] == '\\'
- type = 'nonewline'
- lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
- else
- lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
- line_old += 1 if type != 'new'
- line_new += 1 if type != 'old'
-
- line_obj_index += 1
- end
- end
-
- raise MissingEndDelimiter unless type.nil?
-
- lines
- end
-
- private
-
- def validate_text!(text)
- raise UnmergeableFile if text.blank? # Typically a binary file
- raise UnmergeableFile if text.length > 200.kilobytes
-
- text.force_encoding('UTF-8')
-
- raise UnsupportedEncoding unless text.valid_encoding?
- end
-
- def validate_delimiter!(condition)
- raise UnexpectedDelimiter unless condition
- end
- end
- end
-end
diff --git a/lib/gitlab/conflict/resolution_error.rb b/lib/gitlab/conflict/resolution_error.rb
deleted file mode 100644
index 0b61256b35a..00000000000
--- a/lib/gitlab/conflict/resolution_error.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module Gitlab
- module Conflict
- ResolutionError = Class.new(StandardError)
- end
-end
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index 599c3c5deab..ea5891a028a 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -49,7 +49,7 @@ module Gitlab
def line_code(line)
return if line.meta?
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def line_for_line_code(code)
diff --git a/lib/gitlab/diff/line_code.rb b/lib/gitlab/diff/line_code.rb
deleted file mode 100644
index f3578ab3d35..00000000000
--- a/lib/gitlab/diff/line_code.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-module Gitlab
- module Diff
- class LineCode
- def self.generate(file_path, new_line_position, old_line_position)
- "#{Digest::SHA1.hexdigest(file_path)}_#{old_line_position}_#{new_line_position}"
- end
- end
- end
-end
diff --git a/lib/gitlab/diff/parser.rb b/lib/gitlab/diff/parser.rb
index 742f989c50b..7dc9cc7c281 100644
--- a/lib/gitlab/diff/parser.rb
+++ b/lib/gitlab/diff/parser.rb
@@ -17,7 +17,9 @@ module Gitlab
# without having to instantiate all the others that come after it.
Enumerator.new do |yielder|
@lines.each do |line|
- next if filename?(line)
+ # We're expecting a filename parameter only in a meta-part of the diff content
+ # when type is defined then we're already in a content-part
+ next if filename?(line) && type.nil?
full_line = line.delete("\n")
diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb
index c78fe63f9b5..1f31cdbc96d 100644
--- a/lib/gitlab/git.rb
+++ b/lib/gitlab/git.rb
@@ -66,6 +66,10 @@ module Gitlab
end
end
end
+
+ def diff_line_code(file_path, new_line_position, old_line_position)
+ "#{Digest::SHA1.hexdigest(file_path)}_#{old_line_position}_#{new_line_position}"
+ end
end
end
end
diff --git a/lib/gitlab/git/conflict/file.rb b/lib/gitlab/git/conflict/file.rb
new file mode 100644
index 00000000000..fc1595f1faf
--- /dev/null
+++ b/lib/gitlab/git/conflict/file.rb
@@ -0,0 +1,86 @@
+module Gitlab
+ module Git
+ module Conflict
+ class File
+ attr_reader :content, :their_path, :our_path, :our_mode, :repository
+
+ def initialize(repository, commit_oid, conflict, content)
+ @repository = repository
+ @commit_oid = commit_oid
+ @their_path = conflict[:theirs][:path]
+ @our_path = conflict[:ours][:path]
+ @our_mode = conflict[:ours][:mode]
+ @content = content
+ end
+
+ def lines
+ return @lines if defined?(@lines)
+
+ begin
+ @type = 'text'
+ @lines = Gitlab::Git::Conflict::Parser.parse(content,
+ our_path: our_path,
+ their_path: their_path)
+ rescue Gitlab::Git::Conflict::Parser::ParserError
+ @type = 'text-editor'
+ @lines = nil
+ end
+ end
+
+ def type
+ lines unless @type
+
+ @type.inquiry
+ end
+
+ def our_blob
+ # REFACTOR NOTE: the source of `commit_oid` used to be
+ # `merge_request.diff_refs.head_sha`. Instead of passing this value
+ # around the new lib structure, I decided to use `@commit_oid` which is
+ # equivalent to `merge_request.source_branch_head.raw.rugged_commit.oid`.
+ # That is what `merge_request.diff_refs.head_sha` is equivalent to when
+ # `merge_request` is not persisted (see `MergeRequest#diff_head_commit`).
+ # I think using the same oid is more consistent anyways, but if Conflicts
+ # start breaking, the change described above is a good place to look at.
+ @our_blob ||= repository.blob_at(@commit_oid, our_path)
+ end
+
+ def line_code(line)
+ Gitlab::Git.diff_line_code(our_path, line[:line_new], line[:line_old])
+ end
+
+ def resolve_lines(resolution)
+ section_id = nil
+
+ lines.map do |line|
+ unless line[:type]
+ section_id = nil
+ next line
+ end
+
+ section_id ||= line_code(line)
+
+ case resolution[section_id]
+ when 'head'
+ next unless line[:type] == 'new'
+ when 'origin'
+ next unless line[:type] == 'old'
+ else
+ raise Gitlab::Git::Conflict::Resolver::ResolutionError, "Missing resolution for section ID: #{section_id}"
+ end
+
+ line
+ end.compact
+ end
+
+ def resolve_content(resolution)
+ if resolution == content
+ raise Gitlab::Git::Conflict::Resolver::ResolutionError, "Resolved content has no changes for file #{our_path}"
+ end
+
+ resolution
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/conflict/parser.rb b/lib/gitlab/git/conflict/parser.rb
new file mode 100644
index 00000000000..3effa9d2d31
--- /dev/null
+++ b/lib/gitlab/git/conflict/parser.rb
@@ -0,0 +1,91 @@
+module Gitlab
+ module Git
+ module Conflict
+ class Parser
+ UnresolvableError = Class.new(StandardError)
+ UnmergeableFile = Class.new(UnresolvableError)
+ UnsupportedEncoding = Class.new(UnresolvableError)
+
+ # Recoverable errors - the conflict can be resolved in an editor, but not with
+ # sections.
+ ParserError = Class.new(StandardError)
+ UnexpectedDelimiter = Class.new(ParserError)
+ MissingEndDelimiter = Class.new(ParserError)
+
+ class << self
+ def parse(text, our_path:, their_path:, parent_file: nil)
+ validate_text!(text)
+
+ line_obj_index = 0
+ line_old = 1
+ line_new = 1
+ type = nil
+ lines = []
+ conflict_start = "<<<<<<< #{our_path}"
+ conflict_middle = '======='
+ conflict_end = ">>>>>>> #{their_path}"
+
+ text.each_line.map do |line|
+ full_line = line.delete("\n")
+
+ if full_line == conflict_start
+ validate_delimiter!(type.nil?)
+
+ type = 'new'
+ elsif full_line == conflict_middle
+ validate_delimiter!(type == 'new')
+
+ type = 'old'
+ elsif full_line == conflict_end
+ validate_delimiter!(type == 'old')
+
+ type = nil
+ elsif line[0] == '\\'
+ type = 'nonewline'
+ lines << {
+ full_line: full_line,
+ type: type,
+ line_obj_index: line_obj_index,
+ line_old: line_old,
+ line_new: line_new
+ }
+ else
+ lines << {
+ full_line: full_line,
+ type: type,
+ line_obj_index: line_obj_index,
+ line_old: line_old,
+ line_new: line_new
+ }
+
+ line_old += 1 if type != 'new'
+ line_new += 1 if type != 'old'
+
+ line_obj_index += 1
+ end
+ end
+
+ raise MissingEndDelimiter unless type.nil?
+
+ lines
+ end
+
+ private
+
+ def validate_text!(text)
+ raise UnmergeableFile if text.blank? # Typically a binary file
+ raise UnmergeableFile if text.length > 200.kilobytes
+
+ text.force_encoding('UTF-8')
+
+ raise UnsupportedEncoding unless text.valid_encoding?
+ end
+
+ def validate_delimiter!(condition)
+ raise UnexpectedDelimiter unless condition
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/conflict/resolver.rb b/lib/gitlab/git/conflict/resolver.rb
new file mode 100644
index 00000000000..df509c5f4ce
--- /dev/null
+++ b/lib/gitlab/git/conflict/resolver.rb
@@ -0,0 +1,91 @@
+module Gitlab
+ module Git
+ module Conflict
+ class Resolver
+ ConflictSideMissing = Class.new(StandardError)
+ ResolutionError = Class.new(StandardError)
+
+ def initialize(repository, our_commit, target_repository, their_commit)
+ @repository = repository
+ @our_commit = our_commit.rugged_commit
+ @target_repository = target_repository
+ @their_commit = their_commit.rugged_commit
+ end
+
+ def conflicts
+ @conflicts ||= begin
+ target_index = @target_repository.rugged.merge_commits(@our_commit, @their_commit)
+
+ # We don't need to do `with_repo_branch_commit` here, because the target
+ # project always fetches source refs when creating merge request diffs.
+ target_index.conflicts.map do |conflict|
+ raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
+
+ Gitlab::Git::Conflict::File.new(
+ @target_repository,
+ @our_commit.oid,
+ conflict,
+ target_index.merge_file(conflict[:ours][:path])[:data]
+ )
+ end
+ end
+ end
+
+ def resolve_conflicts(user, files, source_branch:, target_branch:, commit_message:)
+ @repository.with_repo_branch_commit(@target_repository, target_branch) do
+ files.each do |file_params|
+ conflict_file = conflict_for_path(file_params[:old_path], file_params[:new_path])
+
+ write_resolved_file_to_index(conflict_file, file_params)
+ end
+
+ unless index.conflicts.empty?
+ missing_files = index.conflicts.map { |file| file[:ours][:path] }
+
+ raise ResolutionError, "Missing resolutions for the following files: #{missing_files.join(', ')}"
+ end
+
+ commit_params = {
+ message: commit_message,
+ parents: [@our_commit, @their_commit].map(&:oid)
+ }
+
+ @repository.commit_index(user, source_branch, index, commit_params)
+ end
+ end
+
+ def conflict_for_path(old_path, new_path)
+ conflicts.find do |conflict|
+ conflict.their_path == old_path && conflict.our_path == new_path
+ end
+ end
+
+ private
+
+ # We can only write when getting the merge index from the source
+ # project, because we will write to that project. We don't use this all
+ # the time because this fetches a ref into the source project, which
+ # isn't needed for reading.
+ def index
+ @index ||= @repository.rugged.merge_commits(@our_commit, @their_commit)
+ end
+
+ def write_resolved_file_to_index(file, params)
+ if params[:sections]
+ resolved_lines = file.resolve_lines(params[:sections])
+ new_file = resolved_lines.map { |line| line[:full_line] }.join("\n")
+
+ new_file << "\n" if file.our_blob.data.ends_with?("\n")
+ elsif params[:content]
+ new_file = file.resolve_content(params[:content])
+ end
+
+ our_path = file.our_path
+
+ index.add(path: our_path, oid: @repository.rugged.write(new_file, :blob), mode: file.our_mode)
+ index.conflict_remove(our_path)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/env.rb b/lib/gitlab/git/env.rb
index 80f0731cd99..9d0b47a1a6d 100644
--- a/lib/gitlab/git/env.rb
+++ b/lib/gitlab/git/env.rb
@@ -30,6 +30,17 @@ module Gitlab
RequestStore.fetch(:gitlab_git_env) { {} }
end
+ def self.to_env_hash
+ env = {}
+
+ all.compact.each do |key, value|
+ value = value.join(File::PATH_SEPARATOR) if value.is_a?(Array)
+ env[key.to_s] = value
+ end
+
+ env
+ end
+
def self.[](key)
all[key]
end
diff --git a/lib/gitlab/git/popen.rb b/lib/gitlab/git/popen.rb
index 3d2fc471d28..b45da6020ee 100644
--- a/lib/gitlab/git/popen.rb
+++ b/lib/gitlab/git/popen.rb
@@ -5,6 +5,8 @@ require 'open3'
module Gitlab
module Git
module Popen
+ FAST_GIT_PROCESS_TIMEOUT = 15.seconds
+
def popen(cmd, path, vars = {})
unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings"
@@ -27,6 +29,67 @@ module Gitlab
[@cmd_output, @cmd_status]
end
+
+ def popen_with_timeout(cmd, timeout, path, vars = {})
+ unless cmd.is_a?(Array)
+ raise "System commands must be given as an array of strings"
+ end
+
+ path ||= Dir.pwd
+ vars['PWD'] = path
+
+ unless File.directory?(path)
+ FileUtils.mkdir_p(path)
+ end
+
+ rout, wout = IO.pipe
+ rerr, werr = IO.pipe
+
+ pid = Process.spawn(vars, *cmd, out: wout, err: werr, chdir: path, pgroup: true)
+
+ begin
+ status = process_wait_with_timeout(pid, timeout)
+
+ # close write ends so we could read them
+ wout.close
+ werr.close
+
+ cmd_output = rout.readlines.join
+ cmd_output << rerr.readlines.join # Copying the behaviour of `popen` which merges stderr into output
+
+ [cmd_output, status.exitstatus]
+ rescue Timeout::Error => e
+ kill_process_group_for_pid(pid)
+
+ raise e
+ ensure
+ wout.close unless wout.closed?
+ werr.close unless werr.closed?
+
+ rout.close
+ rerr.close
+ end
+ end
+
+ def process_wait_with_timeout(pid, timeout)
+ deadline = timeout.seconds.from_now
+ wait_time = 0.01
+
+ while deadline > Time.now
+ sleep(wait_time)
+ _, status = Process.wait2(pid, Process::WNOHANG)
+
+ return status unless status.nil?
+ end
+
+ raise Timeout::Error, "Timeout waiting for process ##{pid}"
+ end
+
+ def kill_process_group_for_pid(pid)
+ Process.kill("KILL", -pid)
+ Process.wait(pid)
+ rescue Errno::ESRCH
+ end
end
end
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index f99be3cef7b..59a54b48ed9 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -1072,6 +1072,13 @@ module Gitlab
end
# Refactoring aid; allows us to copy code from app/models/repository.rb
+ def run_git_with_timeout(args, timeout, env: {})
+ circuit_breaker.perform do
+ popen_with_timeout([Gitlab.config.git.bin_path, *args], timeout, path, env)
+ end
+ end
+
+ # Refactoring aid; allows us to copy code from app/models/repository.rb
def commit(ref = 'HEAD')
Gitlab::Git::Commit.find(self, ref)
end
@@ -1102,6 +1109,24 @@ module Gitlab
popen(args, @path).last.zero?
end
+ def blob_at(sha, path)
+ Gitlab::Git::Blob.find(self, sha, path) unless Gitlab::Git.blank_ref?(sha)
+ end
+
+ def commit_index(user, branch_name, index, options)
+ committer = user_to_committer(user)
+
+ OperationService.new(user, self).with_branch(branch_name) do
+ commit_params = options.merge(
+ tree: index.write_tree(rugged),
+ author: committer,
+ committer: committer
+ )
+
+ create_commit(commit_params)
+ end
+ end
+
def gitaly_repository
Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository)
end
diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb
index 92a6a672534..60b2a4ec411 100644
--- a/lib/gitlab/git/rev_list.rb
+++ b/lib/gitlab/git/rev_list.rb
@@ -28,7 +28,7 @@ module Gitlab
private
def execute(args)
- output, status = popen(args, nil, Gitlab::Git::Env.all.stringify_keys)
+ output, status = popen(args, nil, Gitlab::Git::Env.to_env_hash)
unless status.zero?
raise "Got a non-zero exit code while calling out `#{args.join(' ')}`: #{output}"
diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb
index 1eaa2d83fb6..4e2d261f461 100644
--- a/lib/gitlab/git/storage/circuit_breaker.rb
+++ b/lib/gitlab/git/storage/circuit_breaker.rb
@@ -2,15 +2,13 @@ module Gitlab
module Git
module Storage
class CircuitBreaker
+ include CircuitBreakerSettings
+
FailureInfo = Struct.new(:last_failure, :failure_count)
attr_reader :storage,
:hostname,
- :storage_path,
- :failure_count_threshold,
- :failure_wait_time,
- :failure_reset_time,
- :storage_timeout
+ :storage_path
delegate :last_failure, :failure_count, to: :failure_info
@@ -53,10 +51,6 @@ module Gitlab
config = Gitlab.config.repositories.storages[@storage]
@storage_path = config['path']
- @failure_count_threshold = config['failure_count_threshold']
- @failure_wait_time = config['failure_wait_time']
- @failure_reset_time = config['failure_reset_time']
- @storage_timeout = config['storage_timeout']
end
def perform
diff --git a/lib/gitlab/git/storage/circuit_breaker_settings.rb b/lib/gitlab/git/storage/circuit_breaker_settings.rb
new file mode 100644
index 00000000000..d2313fe7c1b
--- /dev/null
+++ b/lib/gitlab/git/storage/circuit_breaker_settings.rb
@@ -0,0 +1,29 @@
+module Gitlab
+ module Git
+ module Storage
+ module CircuitBreakerSettings
+ def failure_count_threshold
+ application_settings.circuitbreaker_failure_count_threshold
+ end
+
+ def failure_wait_time
+ application_settings.circuitbreaker_failure_wait_time
+ end
+
+ def failure_reset_time
+ application_settings.circuitbreaker_failure_reset_time
+ end
+
+ def storage_timeout
+ application_settings.circuitbreaker_storage_timeout
+ end
+
+ private
+
+ def application_settings
+ Gitlab::CurrentSettings.current_application_settings
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/storage/null_circuit_breaker.rb b/lib/gitlab/git/storage/null_circuit_breaker.rb
index 297c043d054..60c6791a7e4 100644
--- a/lib/gitlab/git/storage/null_circuit_breaker.rb
+++ b/lib/gitlab/git/storage/null_circuit_breaker.rb
@@ -2,15 +2,14 @@ module Gitlab
module Git
module Storage
class NullCircuitBreaker
+ include CircuitBreakerSettings
+
# These will have actual values
attr_reader :storage,
:hostname
# These will always have nil values
- attr_reader :storage_path,
- :failure_wait_time,
- :failure_reset_time,
- :storage_timeout
+ attr_reader :storage_path
def initialize(storage, hostname, error: nil)
@storage = storage
@@ -26,16 +25,12 @@ module Gitlab
!!@error
end
- def failure_count_threshold
- 1
- end
-
def last_failure
circuit_broken? ? Time.now : nil
end
def failure_count
- circuit_broken? ? 1 : 0
+ circuit_broken? ? failure_count_threshold : 0
end
def failure_info
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index cf36106e23d..6c1ae19ff11 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -70,15 +70,27 @@ module Gitlab
# All Gitaly RPC call sites should use GitalyClient.call. This method
# makes sure that per-request authentication headers are set.
+ #
+ # This method optionally takes a block which receives the keyword
+ # arguments hash 'kwargs' that will be passed to gRPC. This allows the
+ # caller to modify or augment the keyword arguments. The block must
+ # return a hash.
+ #
+ # For example:
+ #
+ # GitalyClient.call(storage, service, rpc, request) do |kwargs|
+ # kwargs.merge(deadline: Time.now + 10)
+ # end
+ #
def self.call(storage, service, rpc, request)
enforce_gitaly_request_limits(:call)
- metadata = request_metadata(storage)
- metadata = yield(metadata) if block_given?
- stub(service, storage).__send__(rpc, request, metadata) # rubocop:disable GitlabSecurity/PublicSend
+ kwargs = request_kwargs(storage)
+ kwargs = yield(kwargs) if block_given?
+ stub(service, storage).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend
end
- def self.request_metadata(storage)
+ def self.request_kwargs(storage)
encoded_token = Base64.strict_encode64(token(storage).to_s)
metadata = {
'authorization' => "Bearer #{encoded_token}",
diff --git a/lib/gitlab/github_import/comment_formatter.rb b/lib/gitlab/github_import/comment_formatter.rb
index e21922070c1..8911b81ec9a 100644
--- a/lib/gitlab/github_import/comment_formatter.rb
+++ b/lib/gitlab/github_import/comment_formatter.rb
@@ -38,7 +38,7 @@ module Gitlab
end
def generate_line_code(line)
- Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
+ Gitlab::Git.diff_line_code(file_path, line.new_pos, line.old_pos)
end
def on_diff?
diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb
index 635f52131f9..42ded7c286f 100644
--- a/lib/gitlab/group_hierarchy.rb
+++ b/lib/gitlab/group_hierarchy.rb
@@ -17,12 +17,32 @@ module Gitlab
@model = ancestors_base.model
end
+ # Returns the set of descendants of a given relation, but excluding the given
+ # relation
+ def descendants
+ base_and_descendants.where.not(id: descendants_base.select(:id))
+ end
+
+ # Returns the set of ancestors of a given relation, but excluding the given
+ # relation
+ #
+ # Passing an `upto` will stop the recursion once the specified parent_id is
+ # reached. So all ancestors *lower* than the specified ancestor will be
+ # included.
+ def ancestors(upto: nil)
+ base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id))
+ end
+
# Returns a relation that includes the ancestors_base set of groups
# and all their ancestors (recursively).
- def base_and_ancestors
+ #
+ # Passing an `upto` will stop the recursion once the specified parent_id is
+ # reached. So all ancestors *lower* than the specified acestor will be
+ # included.
+ def base_and_ancestors(upto: nil)
return ancestors_base unless Group.supports_nested_groups?
- read_only(base_and_ancestors_cte.apply_to(model.all))
+ read_only(base_and_ancestors_cte(upto).apply_to(model.all))
end
# Returns a relation that includes the descendants_base set of groups
@@ -78,17 +98,19 @@ module Gitlab
private
- def base_and_ancestors_cte
+ def base_and_ancestors_cte(stop_id = nil)
cte = SQL::RecursiveCTE.new(:base_and_ancestors)
cte << ancestors_base.except(:order)
# Recursively get all the ancestors of the base set.
- cte << model
+ parent_query = model
.from([groups_table, cte.table])
.where(groups_table[:id].eq(cte.table[:parent_id]))
.except(:order)
+ parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
+ cte << parent_query
cte
end
diff --git a/lib/gitlab/multi_collection_paginator.rb b/lib/gitlab/multi_collection_paginator.rb
new file mode 100644
index 00000000000..eb3c9002710
--- /dev/null
+++ b/lib/gitlab/multi_collection_paginator.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ class MultiCollectionPaginator
+ attr_reader :first_collection, :second_collection, :per_page
+
+ def initialize(*collections, per_page: nil)
+ raise ArgumentError.new('Only 2 collections are supported') if collections.size != 2
+
+ @per_page = per_page || Kaminari.config.default_per_page
+ @first_collection, @second_collection = collections
+ end
+
+ def paginate(page)
+ page = page.to_i
+ paginated_first_collection(page) + paginated_second_collection(page)
+ end
+
+ def total_count
+ @total_count ||= first_collection.size + second_collection.size
+ end
+
+ private
+
+ def paginated_first_collection(page)
+ @first_collection_pages ||= Hash.new do |hash, page|
+ hash[page] = first_collection.page(page).per(per_page)
+ end
+
+ @first_collection_pages[page]
+ end
+
+ def paginated_second_collection(page)
+ @second_collection_pages ||= Hash.new do |hash, page|
+ second_collection_page = page - first_collection_page_count
+
+ offset = if second_collection_page < 1 || first_collection_page_count.zero?
+ 0
+ else
+ per_page - first_collection_last_page_size
+ end
+ hash[page] = second_collection.page(second_collection_page)
+ .per(per_page - paginated_first_collection(page).size)
+ .padding(offset)
+ end
+
+ @second_collection_pages[page]
+ end
+
+ def first_collection_page_count
+ return @first_collection_page_count if defined?(@first_collection_page_count)
+
+ first_collection_page = paginated_first_collection(0)
+ @first_collection_page_count = first_collection_page.total_pages
+ end
+
+ def first_collection_last_page_size
+ return @first_collection_last_page_size if defined?(@first_collection_last_page_size)
+
+ @first_collection_last_page_size = paginated_first_collection(first_collection_page_count).count
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index 7c02c9c5c48..22f8dd669d0 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -26,7 +26,6 @@ module Gitlab
apple-touch-icon.png
assets
autocomplete
- boards
ci
dashboard
deploy.html
@@ -129,7 +128,6 @@ module Gitlab
notification_setting
pipeline_quota
projects
- subgroups
].freeze
ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
diff --git a/lib/gitlab/sql/union.rb b/lib/gitlab/sql/union.rb
index f30c771837a..c99b262f1ca 100644
--- a/lib/gitlab/sql/union.rb
+++ b/lib/gitlab/sql/union.rb
@@ -26,7 +26,11 @@ module Gitlab
@relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?)
end
- fragments.join("\n#{union_keyword}\n")
+ if fragments.any?
+ fragments.join("\n#{union_keyword}\n")
+ else
+ 'NULL'
+ end
end
def union_keyword
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 3f3ba77d47f..70a403652e7 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -49,6 +49,8 @@ module Gitlab
deployments: Deployment.count,
environments: ::Environment.count,
gcp_clusters: ::Gcp::Cluster.count,
+ gcp_clusters_enabled: ::Gcp::Cluster.enabled.count,
+ gcp_clusters_disabled: ::Gcp::Cluster.disabled.count,
in_review_folder: ::Environment.in_review_folder.count,
groups: Group.count,
issues: Issue.count,
diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb
new file mode 100644
index 00000000000..385141d44d0
--- /dev/null
+++ b/lib/gitlab/utils/merge_hash.rb
@@ -0,0 +1,117 @@
+module Gitlab
+ module Utils
+ module MergeHash
+ extend self
+ # Deep merges an array of hashes
+ #
+ # [{ hello: ["world"] },
+ # { hello: "Everyone" },
+ # { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
+ # "Goodbye", "Hallo"]
+ # => [
+ # {
+ # hello:
+ # [
+ # "world",
+ # "Everyone",
+ # { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
+ # ]
+ # },
+ # "Goodbye"
+ # ]
+ def merge(elements)
+ merged, *other_elements = elements
+
+ other_elements.each do |element|
+ merged = merge_hash_tree(merged, element)
+ end
+
+ merged
+ end
+
+ # This extracts all keys and values from a hash into an array
+ #
+ # { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ # => [:hello, "world", :this, :crushes, "an entire", "hash"]
+ def crush(array_or_hash)
+ if array_or_hash.is_a?(Array)
+ crush_array(array_or_hash)
+ else
+ crush_hash(array_or_hash)
+ end
+ end
+
+ private
+
+ def merge_hash_into_array(array, new_hash)
+ crushed_new_hash = crush_hash(new_hash)
+ # Merge the hash into an existing element of the array if there is overlap
+ if mergeable_index = array.index { |element| crushable?(element) && (crush(element) & crushed_new_hash).any? }
+ array[mergeable_index] = merge_hash_tree(array[mergeable_index], new_hash)
+ else
+ array << new_hash
+ end
+
+ array
+ end
+
+ def merge_hash_tree(first_element, second_element)
+ # If one of the elements is an object, and the other is a Hash or Array
+ # we can check if the object is already included. If so, we don't need to do anything
+ #
+ # Handled cases
+ # [Hash, Object], [Array, Object]
+ if crushable?(first_element) && crush(first_element).include?(second_element)
+ first_element
+ elsif crushable?(second_element) && crush(second_element).include?(first_element)
+ second_element
+ # When the first is an array, we need to go over every element to see if
+ # we can merge deeper. If no match is found, we add the element to the array
+ #
+ # Handled cases:
+ # [Array, Hash]
+ elsif first_element.is_a?(Array) && second_element.is_a?(Hash)
+ merge_hash_into_array(first_element, second_element)
+ elsif first_element.is_a?(Hash) && second_element.is_a?(Array)
+ merge_hash_into_array(second_element, first_element)
+ # If both of them are hashes, we can deep_merge with the same logic
+ #
+ # Handled cases:
+ # [Hash, Hash]
+ elsif first_element.is_a?(Hash) && second_element.is_a?(Hash)
+ first_element.deep_merge(second_element) { |key, first, second| merge_hash_tree(first, second) }
+ # If both elements are arrays, we try to merge each element separatly
+ #
+ # Handled cases
+ # [Array, Array]
+ elsif first_element.is_a?(Array) && second_element.is_a?(Array)
+ first_element.map { |child_element| merge_hash_tree(child_element, second_element) }
+ # If one or both elements are a GroupDescendant, we wrap create an array
+ # combining them.
+ #
+ # Handled cases:
+ # [Object, Object], [Array, Array]
+ else
+ (Array.wrap(first_element) + Array.wrap(second_element)).uniq
+ end
+ end
+
+ def crushable?(element)
+ element.is_a?(Hash) || element.is_a?(Array)
+ end
+
+ def crush_hash(hash)
+ hash.flat_map do |key, value|
+ crushed_value = crushable?(value) ? crush(value) : value
+ Array.wrap(key) + Array.wrap(crushed_value)
+ end
+ end
+
+ def crush_array(array)
+ array.flat_map do |element|
+ crushable?(element) ? crush(element) : element
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c73e582e608..1f356a231b0 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-10-06 18:33+0200\n"
-"PO-Revision-Date: 2017-10-06 18:33+0200\n"
+"POT-Creation-Date: 2017-10-10 17:50+0200\n"
+"PO-Revision-Date: 2017-10-10 17:50+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -109,6 +109,9 @@ msgstr ""
msgid "All"
msgstr ""
+msgid "An error occurred. Please try again."
+msgstr ""
+
msgid "Appearance"
msgstr ""
@@ -375,6 +378,9 @@ msgstr ""
msgid "Clone repository"
msgstr ""
+msgid "Cluster"
+msgstr ""
+
msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
msgstr ""
@@ -420,13 +426,10 @@ msgstr ""
msgid "ClusterIntegration|Google Container Engine project"
msgstr ""
-msgid "ClusterIntegration|Google Container Engine"
-msgstr ""
-
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
-msgid "ClusterIntegration|See machine types"
+msgid "ClusterIntegration|Machine type"
msgstr ""
msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
@@ -438,9 +441,15 @@ msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
+msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgstr ""
+
msgid "ClusterIntegration|Remove cluster integration"
msgstr ""
@@ -450,7 +459,10 @@ msgstr ""
msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
msgstr ""
-msgid "ClusterIntegration|Save changes"
+msgid "ClusterIntegration|Save"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
msgstr ""
msgid "ClusterIntegration|See your projects"
@@ -462,18 +474,12 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine."
-msgstr ""
-
-msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine"
msgstr ""
msgid "ClusterIntegration|Toggle Cluster"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr ""
-
msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
@@ -691,6 +697,9 @@ msgstr ""
msgid "Discard changes"
msgstr ""
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
msgid "Don't show again"
msgstr ""
@@ -760,6 +769,9 @@ msgstr ""
msgid "Explore projects"
msgstr ""
+msgid "Explore public groups"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
@@ -846,6 +858,51 @@ msgstr ""
msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
msgstr ""
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTreeRole|as"
+msgstr ""
+
+msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -876,6 +933,12 @@ msgstr ""
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
msgid "Interval Pattern"
msgstr ""
@@ -935,6 +998,9 @@ msgstr ""
msgid "Learn more in the|pipeline schedules documentation"
msgstr ""
+msgid "Leave"
+msgstr ""
+
msgid "Leave group"
msgstr ""
@@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most"
msgstr[0] ""
msgstr[1] ""
+msgid "Lock"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
msgid "Median"
msgstr ""
@@ -973,6 +1048,9 @@ msgstr ""
msgid "More information is available|here"
msgstr ""
+msgid "New Cluster"
+msgstr ""
+
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] ""
@@ -990,18 +1068,27 @@ msgstr ""
msgid "New file"
msgstr ""
+msgid "New group"
+msgstr ""
+
msgid "New issue"
msgstr ""
msgid "New merge request"
msgstr ""
+msgid "New project"
+msgstr ""
+
msgid "New schedule"
msgstr ""
msgid "New snippet"
msgstr ""
+msgid "New subgroup"
+msgstr ""
+
msgid "New tag"
msgstr ""
@@ -1080,6 +1167,9 @@ msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr ""
+msgid "Only project members can comment."
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
@@ -1110,6 +1200,9 @@ msgstr ""
msgid "Password"
msgstr ""
+msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgstr ""
+
msgid "Pipeline"
msgstr ""
@@ -1209,9 +1302,51 @@ msgstr ""
msgid "Preferences"
msgstr ""
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
msgid "Profile"
msgstr ""
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@@ -1266,6 +1401,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
+msgid "Projects"
+msgstr ""
+
msgid "ProjectsDropdown|Frequently visited"
msgstr ""
@@ -1287,6 +1425,12 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
msgid "Push events"
msgstr ""
@@ -1415,12 +1559,18 @@ msgstr ""
msgid "Something went wrong on our end."
msgstr ""
+msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgstr ""
+
msgid "Something went wrong while fetching the projects."
msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Sort by"
+msgstr ""
+
msgid "SortOptions|Access level, ascending"
msgstr ""
@@ -1529,6 +1679,9 @@ msgstr ""
msgid "Start the Runner!"
msgstr ""
+msgid "Subgroups"
+msgstr ""
+
msgid "Switch branch/tag"
msgstr ""
@@ -1600,12 +1753,24 @@ msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""
+msgid "This is a confidential issue."
+msgstr ""
+
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
+msgid "This merge request is locked."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -1760,6 +1925,12 @@ msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
msgid "Unstar"
msgstr ""
@@ -1922,9 +2093,15 @@ msgstr ""
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
msgstr ""
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
msgid "You can only add files when you are on a branch"
msgstr ""
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
msgid "You have reached your project limit"
msgstr ""
@@ -1955,6 +2132,12 @@ msgstr ""
msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
msgstr ""
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
msgid "Your name"
msgstr ""
@@ -1977,5 +2160,11 @@ msgid_plural "parents"
msgstr[0] ""
msgstr[1] ""
+msgid "password"
+msgstr ""
+
msgid "personal access token"
msgstr ""
+
+msgid "username"
+msgstr ""
diff --git a/package.json b/package.json
index b670415deb4..620b39766f4 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"jszip": "^3.1.3",
"jszip-utils": "^0.0.2",
"marked": "^0.3.6",
- "monaco-editor": "0.8.3",
+ "monaco-editor": "0.10.0",
"mousetrap": "^1.4.6",
"name-all-modules-plugin": "^1.0.1",
"pikaday": "^1.5.1",
diff --git a/qa/qa.rb b/qa/qa.rb
index eb6f922d0d3..59d9dd131c2 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -18,6 +18,7 @@ module QA
# Support files
#
autoload :Actable, 'qa/scenario/actable'
+ autoload :Entrypoint, 'qa/scenario/entrypoint'
autoload :Template, 'qa/scenario/template'
##
@@ -25,6 +26,10 @@ module QA
#
module Test
autoload :Instance, 'qa/scenario/test/instance'
+
+ module Integration
+ autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
+ end
end
##
diff --git a/qa/qa/scenario/entrypoint.rb b/qa/qa/scenario/entrypoint.rb
new file mode 100644
index 00000000000..33cb2696f8f
--- /dev/null
+++ b/qa/qa/scenario/entrypoint.rb
@@ -0,0 +1,36 @@
+module QA
+ module Scenario
+ ##
+ # Base class for running the suite against any GitLab instance,
+ # including staging and on-premises installation.
+ #
+ class Entrypoint < Template
+ def self.tags(*tags)
+ @tags = tags
+ end
+
+ def self.get_tags
+ @tags
+ end
+
+ def perform(address, *files)
+ Specs::Config.perform do |specs|
+ specs.address = address
+ end
+
+ ##
+ # Perform before hooks, which are different for CE and EE
+ #
+ Runtime::Release.perform_before_hooks
+
+ Specs::Runner.perform do |specs|
+ specs.rspec(
+ tty: true,
+ tags: self.class.get_tags,
+ files: files.any? ? files : 'qa/specs/features'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb
index 689292bc60b..e2a1f6bf2bd 100644
--- a/qa/qa/scenario/test/instance.rb
+++ b/qa/qa/scenario/test/instance.rb
@@ -5,21 +5,8 @@ module QA
# Run test suite against any GitLab instance,
# including staging and on-premises installation.
#
- class Instance < Scenario::Template
- def perform(address, *files)
- Specs::Config.perform do |specs|
- specs.address = address
- end
-
- ##
- # Perform before hooks, which are different for CE and EE
- #
- Runtime::Release.perform_before_hooks
-
- Specs::Runner.perform do |specs|
- specs.rspec('--tty', files.any? ? files : 'qa/specs/features')
- end
- end
+ class Instance < Entrypoint
+ tags :core
end
end
end
diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb
new file mode 100644
index 00000000000..4732f2b635b
--- /dev/null
+++ b/qa/qa/scenario/test/integration/mattermost.rb
@@ -0,0 +1,15 @@
+module QA
+ module Scenario
+ module Test
+ module Integration
+ ##
+ # Run test suite against any GitLab instance where mattermost is enabled,
+ # including staging and on-premises installation.
+ #
+ class Mattermost < Scenario::Entrypoint
+ tags :core, :mattermost
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/login/standard_spec.rb b/qa/qa/specs/features/login/standard_spec.rb
index 8e1ae6efa47..ba19ce17ee5 100644
--- a/qa/qa/specs/features/login/standard_spec.rb
+++ b/qa/qa/specs/features/login/standard_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'standard root login' do
+ feature 'standard root login', :core do
scenario 'user logs in using credentials' do
Page::Main::Entry.act { sign_in_using_credentials }
diff --git a/qa/qa/specs/features/mattermost/group_create_spec.rb b/qa/qa/specs/features/mattermost/group_create_spec.rb
new file mode 100644
index 00000000000..c4afd83c8e4
--- /dev/null
+++ b/qa/qa/specs/features/mattermost/group_create_spec.rb
@@ -0,0 +1,16 @@
+module QA
+ feature 'create a new group', :mattermost do
+ scenario 'creating a group with a mattermost team' do
+ Page::Main::Entry.act { sign_in_using_credentials }
+ Page::Main::Menu.act { go_to_groups }
+
+ Page::Dashboard::Groups.perform do |page|
+ page.go_to_new_group
+
+ expect(page).to have_content(
+ /Create a Mattermost team for this group/
+ )
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/create_spec.rb b/qa/qa/specs/features/project/create_spec.rb
index 610492b9717..27eb22f15a6 100644
--- a/qa/qa/specs/features/project/create_spec.rb
+++ b/qa/qa/specs/features/project/create_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'create a new project' do
+ feature 'create a new project', :core do
scenario 'user creates a new project' do
Page::Main::Entry.act { sign_in_using_credentials }
diff --git a/qa/qa/specs/features/repository/clone_spec.rb b/qa/qa/specs/features/repository/clone_spec.rb
index 521bd955857..3571173783d 100644
--- a/qa/qa/specs/features/repository/clone_spec.rb
+++ b/qa/qa/specs/features/repository/clone_spec.rb
@@ -1,5 +1,5 @@
module QA
- feature 'clone code from the repository' do
+ feature 'clone code from the repository', :core do
context 'with regular account over http' do
given(:location) do
Page::Project::Show.act do
diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb
index 5fe45d63d37..0e691fb0d75 100644
--- a/qa/qa/specs/features/repository/push_spec.rb
+++ b/qa/qa/specs/features/repository/push_spec.rb
@@ -1,7 +1,7 @@
module QA
- feature 'push code to repository' do
+ feature 'push code to repository', :core do
context 'with regular account over http' do
- scenario 'user pushes code to the repository' do
+ scenario 'user pushes code to the repository' do
Page::Main::Entry.act { sign_in_using_credentials }
Scenario::Gitlab::Project::Create.perform do |scenario|
diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb
index 83ae15d0995..2aa18d5d3a1 100644
--- a/qa/qa/specs/runner.rb
+++ b/qa/qa/specs/runner.rb
@@ -5,7 +5,14 @@ module QA
class Runner
include Scenario::Actable
- def rspec(*args)
+ def rspec(tty: false, tags: [], files: ['qa/specs/features'])
+ args = []
+ args << '--tty' if tty
+ tags.to_a.each do |tag|
+ args << ['-t', tag.to_s]
+ end
+ args << files
+
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
abort if status.nonzero?
end
diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb
index 6d8b9865dcb..fc1bf67d7b9 100644
--- a/spec/bin/changelog_spec.rb
+++ b/spec/bin/changelog_spec.rb
@@ -84,7 +84,7 @@ describe 'bin/changelog' do
expect do
expect do
expect { described_class.read_type }.to raise_error(SystemExit)
- end.to output("Invalid category index, please select an index between 1 and 7\n").to_stderr
+ end.to output("Invalid category index, please select an index between 1 and 8\n").to_stderr
end.to output.to_stdout
end
end
diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb
new file mode 100644
index 00000000000..ba84fbf8564
--- /dev/null
+++ b/spec/controllers/concerns/group_tree_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe GroupTree do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+
+ controller(ApplicationController) do
+ # `described_class` is not available in this context
+ include GroupTree # rubocop:disable RSpec/DescribedClass
+
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
+ end
+ end
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ it 'filters groups' do
+ other_group = create(:group, name: 'filter')
+ other_group.add_owner(user)
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(other_group)
+ end
+
+ context 'for subgroups', :nested_groups do
+ it 'only renders root groups when no parent was given' do
+ create(:group, :public, parent: group)
+
+ get :index, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group)
+ end
+
+ it 'contains only the subgroup when a parent was given' do
+ subgroup = create(:group, :public, parent: group)
+
+ get :index, parent_id: group.id, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(subgroup)
+ end
+
+ it 'allows filtering for subgroups and includes the parents for rendering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group, subgroup)
+ end
+
+ it 'does not include groups the user does not have access to' do
+ parent = create(:group, :private)
+ subgroup = create(:group, :private, parent: parent, name: 'filter')
+ subgroup.add_developer(user)
+ _other_subgroup = create(:group, :private, parent: parent, name: 'filte')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(parent, subgroup)
+ end
+ end
+
+ context 'json content' do
+ it 'shows groups as json' do
+ get :index, format: :json
+
+ expect(json_response.first['id']).to eq(group.id)
+ end
+
+ context 'nested groups', :nested_groups do
+ it 'expands the tree when filtering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ children_response = json_response.first['children']
+
+ expect(json_response.first['id']).to eq(group.id)
+ expect(children_response.first['id']).to eq(subgroup.id)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb
new file mode 100644
index 00000000000..fb9d3efbac0
--- /dev/null
+++ b/spec/controllers/dashboard/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Dashboard::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'only includes projects the user is a member of' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group)
+ end
+end
diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb
new file mode 100644
index 00000000000..9e0ad9ea86f
--- /dev/null
+++ b/spec/controllers/explore/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Explore::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'includes public projects' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ public_group = create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group, public_group)
+ end
+end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
new file mode 100644
index 00000000000..4262d474e59
--- /dev/null
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -0,0 +1,286 @@
+require 'spec_helper'
+
+describe Groups::ChildrenController do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ describe 'GET #index' do
+ context 'for projects' do
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project)
+ end
+ end
+ end
+
+ context 'for subgroups', :nested_groups do
+ let!(:public_subgroup) { create(:group, :public, parent: group) }
+ let!(:private_subgroup) { create(:group, :private, parent: group) }
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_subgroup.add_guest(user)
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, public_project)
+ end
+ end
+
+ context 'filtering children' do
+ it 'expands the tree for matching projects' do
+ project = create(:project, :public, namespace: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ project_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(project_json['id']).to eq(project.id)
+ end
+
+ it 'expands the tree for matching subgroups' do
+ matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ matched_group_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(matched_group_json['id']).to eq(matched_group.id)
+ end
+
+ it 'merges the trees correctly' do
+ shared_subgroup = create(:group, :public, parent: group, path: 'hardware')
+ matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc')
+
+ l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom')
+ l3_subgroup = create(:group, :public, parent: l2_subgroup, path: 'wifi-group')
+ matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile')
+
+ get :index, group_id: group.to_param, filter: 'mobile', format: :json
+
+ shared_group_json = json_response.first
+ expect(shared_group_json['id']).to eq(shared_subgroup.id)
+
+ matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' }
+ expect(matched_project_1_json['id']).to eq(matched_project_1.id)
+
+ l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' }
+ expect(l2_subgroup_json['id']).to eq(l2_subgroup.id)
+
+ l3_subgroup_json = l2_subgroup_json['children'].first
+ expect(l3_subgroup_json['id']).to eq(l3_subgroup.id)
+
+ matched_project_2_json = l3_subgroup_json['children'].first
+ expect(matched_project_2_json['id']).to eq(matched_project_2.id)
+ end
+
+ it 'expands the tree upto a specified parent' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'test')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns an array with one element when only one result is matched' do
+ create(:project, :public, namespace: group, name: 'match')
+
+ get :index, group_id: group.to_param, filter: 'match', format: :json
+
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns an empty array when there are no search results' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'no-match')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(json_response).to eq([])
+ end
+
+ it 'includes pagination headers' do
+ 2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
+
+ get :index, group_id: group.to_param, filter: 'filter', per_page: 1, format: :json
+
+ expect(response).to include_pagination_headers
+ end
+ end
+
+ context 'queries per rendered element', :request_store do
+ # We need to make sure the following counts are preloaded
+ # otherwise they will cause an extra query
+ # 1. Count of visible projects in the element
+ # 2. Count of visible subgroups in the element
+ # 3. Count of members of a group
+ let(:expected_queries_per_group) { 0 }
+ let(:expected_queries_per_project) { 0 }
+
+ def get_list
+ get :index, group_id: group.to_param, format: :json
+ end
+
+ it 'queries the expected amount for a group row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+
+ _new_group = create(:group, :public, parent: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group)
+ end
+
+ it 'queries the expected amount for a project row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+ _new_project = create(:project, :public, namespace: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project)
+ end
+
+ context 'when rendering hierarchies' do
+ # When loading hierarchies we load the all the ancestors for matched projects
+ # in 1 separate query
+ let(:extra_queries_for_hierarchies) { 1 }
+
+ def get_filtered_list
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+ end
+
+ it 'queries the expected amount when nested rows are increased for a group' do
+ matched_group = create(:group, :public, parent: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_group.update!(parent: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when a new group match is added' do
+ create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ create(:group, :public, parent: public_subgroup, name: 'filterme2')
+ create(:group, :public, parent: public_subgroup, name: 'filterme3')
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when nested rows are increased for a project' do
+ matched_project = create(:project, :public, namespace: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_project.update!(namespace: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+ end
+ end
+ end
+
+ context 'pagination' do
+ let(:per_page) { 3 }
+
+ before do
+ allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
+ end
+
+ context 'with only projects' do
+ let!(:other_project) { create(:project, :public, namespace: group) }
+ let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) }
+
+ it 'has projects on the first page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_projects)
+ end
+
+ it 'has projects on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_project)
+ end
+ end
+
+ context 'with subgroups and projects', :nested_groups do
+ let!(:first_page_subgroups) { create_list(:group, per_page, :public, parent: group) }
+ let!(:other_subgroup) { create(:group, :public, parent: group) }
+ let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) }
+
+ it 'contains all subgroups' do
+ get :index, group_id: group.to_param, sort: 'id_asc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_subgroups)
+ end
+
+ it 'contains the project and group on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_asc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index b0564e27a68..e7631d4d709 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -1,4 +1,4 @@
-require 'rails_helper'
+require 'spec_helper'
describe GroupsController do
let(:user) { create(:user) }
@@ -150,42 +150,6 @@ describe GroupsController do
end
end
- describe 'GET #subgroups', :nested_groups do
- let!(:public_subgroup) { create(:group, :public, parent: group) }
- let!(:private_subgroup) { create(:group, :private, parent: group) }
-
- context 'as a user' do
- before do
- sign_in(user)
- end
-
- it 'shows all subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
-
- context 'being member of private subgroup' do
- it 'shows public and private subgroups the user is member of' do
- group_member.destroy!
- private_subgroup.add_guest(user)
-
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
- end
- end
-
- context 'as a guest' do
- it 'shows the public subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
- end
- end
- end
-
describe 'GET #issues' do
let(:issue_1) { create(:issue, project: project) }
let(:issue_2) { create(:issue, project: project) }
@@ -425,62 +389,62 @@ describe GroupsController do
end
end
end
- end
- context 'for a POST request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ context 'for a POST request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ it 'does not redirect to the correct casing' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- post :update, id: redirect_route.path, group: { path: 'new_path' }
+ it 'returns not found' do
+ post :update, id: redirect_route.path, group: { path: 'new_path' }
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
- end
- context 'for a DELETE request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- delete :destroy, id: group.to_param.upcase
+ context 'for a DELETE request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- delete :destroy, id: group.to_param.upcase
+ it 'does not redirect to the correct casing' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- delete :destroy, id: redirect_route.path
+ it 'returns not found' do
+ delete :destroy, id: redirect_route.path
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
end
- end
- def group_moved_message(redirect_route, group)
- "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ def group_moved_message(redirect_route, group)
+ "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
end
diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
index 393d38c6e6b..c6d50c28106 100644
--- a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
@@ -17,8 +17,8 @@ describe Projects::MergeRequests::ConflictsController do
describe 'GET show' do
context 'when the conflicts cannot be resolved in the UI' do
before do
- allow_any_instance_of(Gitlab::Conflict::Parser)
- .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+ allow(Gitlab::Git::Conflict::Parser).to receive(:parse)
+ .and_raise(Gitlab::Git::Conflict::Parser::UnmergeableFile)
get :show,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
@@ -109,8 +109,8 @@ describe Projects::MergeRequests::ConflictsController do
context 'when the conflicts cannot be resolved in the UI' do
before do
- allow_any_instance_of(Gitlab::Conflict::Parser)
- .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile)
+ allow(Gitlab::Git::Conflict::Parser).to receive(:parse)
+ .and_raise(Gitlab::Git::Conflict::Parser::UnmergeableFile)
conflict_for_path('files/ruby/regex.rb')
end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 167e80ed9cd..67b53d2acce 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -3,32 +3,36 @@ require 'spec_helper'
describe Projects::PipelinesController do
include ApiHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ set(:user) { create(:user) }
+ set(:project) { create(:project, :public, :repository) }
let(:feature) { ProjectFeature::DISABLED }
before do
stub_not_protect_default_branch
project.add_developer(user)
- project.project_feature.update(
- builds_access_level: feature)
+ project.project_feature.update(builds_access_level: feature)
sign_in(user)
end
describe 'GET index.json' do
before do
- create(:ci_empty_pipeline, status: 'pending', project: project)
- create(:ci_empty_pipeline, status: 'running', project: project)
- create(:ci_empty_pipeline, status: 'created', project: project)
- create(:ci_empty_pipeline, status: 'success', project: project)
+ branch_head = project.commit
+ parent = branch_head.parent
- get :index, namespace_id: project.namespace,
- project_id: project,
- format: :json
+ create(:ci_empty_pipeline, status: 'pending', project: project, sha: branch_head.id)
+ create(:ci_empty_pipeline, status: 'running', project: project, sha: branch_head.id)
+ create(:ci_empty_pipeline, status: 'created', project: project, sha: parent.id)
+ create(:ci_empty_pipeline, status: 'success', project: project, sha: parent.id)
+ end
+
+ subject do
+ get :index, namespace_id: project.namespace, project_id: project, format: :json
end
it 'returns JSON with serialized pipelines' do
+ subject
+
expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('pipeline')
@@ -39,6 +43,12 @@ describe Projects::PipelinesController do
expect(json_response['count']['pending']).to eq 1
expect(json_response['count']['finished']).to eq 1
end
+
+ context 'when performing gitaly calls', :request_store do
+ it 'limits the Gitaly requests' do
+ expect { subject }.to change { Gitlab::GitalyClient.get_request_count }.by(10)
+ end
+ end
end
describe 'GET show JSON' do
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index 09e6965849a..4430fc15501 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -65,9 +65,11 @@ feature "Admin Health Check", :feature, :broken_storage do
it 'shows storage failure information' do
hostname = Gitlab::Environment.hostname
+ maximum_failures = Gitlab::CurrentSettings.current_application_settings
+ .circuitbreaker_failure_count_threshold
expect(page).to have_content('broken: failed storage access attempt on host:')
- expect(page).to have_content("#{hostname}: 1 of 10 failures.")
+ expect(page).to have_content("#{hostname}: 1 of #{maximum_failures} failures.")
end
it 'allows resetting storage failures' do
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index a6329b5c78d..c6873d1923c 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
let(:nested_group) { create(:group, :nested) }
let(:another_group) { create(:group) }
+ def click_group_caret(group)
+ within("#group-#{group.id}") do
+ first('.folder-caret').click
+ end
+ wait_for_requests
+ end
+
it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do
sign_in(user)
visit dashboard_groups_path
+ wait_for_requests
+
+ expect(page).to have_content(group.name)
+
+ expect(page).not_to have_content(another_group.name)
+ end
+
+ it 'shows subgroups the user is member of', :nested_groups do
+ group.add_owner(user)
+ nested_group.add_owner(user)
+
+ sign_in(user)
+ visit dashboard_groups_path
+ wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).to have_content(nested_group.parent.name)
+ click_group_caret(nested_group.parent)
+ expect(page).to have_content(nested_group.name)
end
- describe 'when filtering groups' do
+ describe 'when filtering groups', :nested_groups do
before do
group.add_owner(user)
nested_group.add_owner(user)
@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
visit dashboard_groups_path
end
- it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ it 'expands when filtering groups' do
+ fill_in 'filter', with: nested_group.name
wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).not_to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).not_to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).to have_content(nested_group.name)
+ expect(page).not_to have_content(another_group.name)
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ''
+ fill_in 'filter', with: ''
wait_for_requests
- expect(page).to have_content(group.full_name)
- expect(page).to have_content(nested_group.full_name)
- expect(page).not_to have_content(another_group.full_name)
+ expect(page).to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).not_to have_content(another_group.name)
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
end
end
@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
end
it 'shows subgroups inside of its parent group' do
- expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
- expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
+ expect(page).to have_selector("#group-#{group.id}")
+ click_group_caret(group)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
it 'can toggle parent group' do
- # Expanded by default
- expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
+ # Collapsed by default
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
- # Collapse
- find("#group-#{group.id}").trigger('click')
+ # expand
+ click_group_caret(group)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
- expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
- # Expand
- find("#group-#{group.id}").trigger('click')
+ # collapse
+ click_group_caret(group)
- expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
- expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
+ expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
end
diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb
index b5325301968..801a33979ff 100644
--- a/spec/features/explore/groups_list_spec.rb
+++ b/spec/features/explore/groups_list_spec.rb
@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do
sign_in(user)
visit explore_groups_path
+ wait_for_requests
end
it 'shows groups user is member of' do
@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do
end
it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ""
+ fill_in 'filter', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do
it 'shows non-archived projects count' do
# Initially project is not archived
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
# Archive project
empty_project.archive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0")
# Unarchive project
empty_project.unarchive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
end
describe 'landing component' do
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 303013e59d5..7fc2b383749 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -24,4 +24,35 @@ feature 'Group show page' do
it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
+
+ context 'subgroup support' do
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ context 'when subgroups are supported', :js, :nested_groups do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { true }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).to have_css("li[data-text='New subgroup']", visible: false)
+ end
+ end
+
+ context 'when subgroups are not supported' do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { false }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false)
+ end
+ end
+ end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 862823d862e..cc8906fa969 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -90,8 +90,7 @@ feature 'Group' do
context 'as admin' do
before do
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
end
it 'creates a nested group' do
@@ -111,8 +110,8 @@ feature 'Group' do
sign_out(:user)
sign_in(user)
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
+
fill_in 'Group path', with: 'bar'
click_button 'Create group'
@@ -120,16 +119,6 @@ feature 'Group' do
expect(page).to have_content("Group 'bar' was successfully created.")
end
end
-
- context 'when nested group feature is disabled' do
- it 'renders 404' do
- allow(Group).to receive(:supports_nested_groups?).and_return(false)
-
- visit subgroups_group_path(group)
-
- expect(page.status_code).to eq(404)
- end
- end
end
it 'checks permissions to avoid exposing groups by parent_id' do
@@ -210,13 +199,15 @@ feature 'Group' do
describe 'group page with nested groups', :nested_groups, :js do
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
+ let!(:project) { create(:project, namespace: group) }
let!(:path) { group_path(group) }
- it 'has nested groups tab with nested groups inside' do
+ it 'it renders projects and groups on the page' do
visit path
- click_link 'Subgroups'
+ wait_for_requests
expect(page).to have_content(nested_group.name)
+ expect(page).to have_content(project.name)
end
end
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
new file mode 100644
index 00000000000..074914420a1
--- /dev/null
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendantsFinder do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:params) { {} }
+ subject(:finder) do
+ described_class.new(current_user: user, parent_group: group, params: params)
+ end
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#has_children?' do
+ it 'is true when there are projects' do
+ create(:project, namespace: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+
+ context 'when there are subgroups', :nested_groups do
+ it 'is true when there are projects' do
+ create(:group, parent: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+ end
+ end
+
+ describe '#execute' do
+ it 'includes projects' do
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(project)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project, project)
+ end
+ end
+
+ context 'when archived is `only`' do
+ let(:params) { { archived: 'only' } }
+
+ it 'includes only archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ _project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project)
+ end
+ end
+
+ it 'does not include archived projects' do
+ _archived_project = create(:project, :archived, namespace: group)
+
+ expect(finder.execute).to be_empty
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'includes only projects matching the filter' do
+ _other_project = create(:project, namespace: group)
+ matching_project = create(:project, namespace: group, name: 'testproject')
+
+ expect(finder.execute).to contain_exactly(matching_project)
+ end
+ end
+ end
+
+ context 'with nested groups', :nested_groups do
+ let!(:project) { create(:project, namespace: group) }
+ let!(:subgroup) { create(:group, :private, parent: group) }
+
+ describe '#execute' do
+ it 'contains projects and subgroups' do
+ expect(finder.execute).to contain_exactly(subgroup, project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ public_subgroup = create(:group, :public, parent: group, path: 'public-group')
+ other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup)
+ end
+
+ it 'only includes public groups when no user is given' do
+ public_subgroup = create(:group, :public, parent: group)
+ _private_subgroup = create(:group, :private, parent: group)
+
+ finder = described_class.new(current_user: nil, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects in the count of subgroups' do
+ create(:project, namespace: subgroup, archived: true)
+
+ expect(finder.execute.first.preloaded_project_count).to eq(1)
+ end
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'contains only matching projects and subgroups' do
+ matching_project = create(:project, namespace: group, name: 'Testproject')
+ matching_subgroup = create(:group, name: 'testgroup', parent: group)
+
+ expect(finder.execute).to contain_exactly(matching_subgroup, matching_project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ _invisible_subgroup = create(:group, :private, parent: group, name: 'test1')
+ other_subgroup = create(:group, :private, parent: group, name: 'test2')
+ public_subgroup = create(:group, :public, parent: group, name: 'test3')
+ other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user,
+ parent_group: group,
+ params: params)
+
+ expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
+ end
+
+ context 'with matching children' do
+ it 'includes a group that has a subgroup matching the query and its parent' do
+ matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup)
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
+ end
+
+ it 'includes the parent of a matching project' do
+ matching_project = create(:project, namespace: subgroup, name: 'Testproject')
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_project)
+ end
+
+ it 'does not include the parent itself' do
+ group.update!(name: 'test')
+
+ expect(finder.execute).not_to include(group)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/pages.tar.gz b/spec/fixtures/pages.tar.gz
index d0e89378b3e..5c4ea9690e8 100644
--- a/spec/fixtures/pages.tar.gz
+++ b/spec/fixtures/pages.tar.gz
Binary files differ
diff --git a/spec/fixtures/pages.zip b/spec/fixtures/pages.zip
index 9558fcd4b94..9bb75f953f8 100644
--- a/spec/fixtures/pages.zip
+++ b/spec/fixtures/pages.zip
Binary files differ
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index 9a974e70e8c..a11824d0ac5 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -18,26 +18,6 @@ describe Settings do
end
end
- describe '#repositories' do
- it 'assigns the default failure attributes' do
- repository_settings = Gitlab.config.repositories.storages['broken']
-
- expect(repository_settings['failure_count_threshold']).to eq(10)
- expect(repository_settings['failure_wait_time']).to eq(30)
- expect(repository_settings['failure_reset_time']).to eq(1800)
- expect(repository_settings['storage_timeout']).to eq(5)
- end
-
- it 'can be accessed with dot syntax all the way down' do
- expect(Gitlab.config.repositories.storages.broken.failure_count_threshold).to eq(10)
- end
-
- it 'can be accessed in a very specific way that breaks without reassigning each element with Settingslogic' do
- storage_settings = Gitlab.config.repositories.storages['broken']
- expect(storage_settings.failure_count_threshold).to eq(10)
- end
- end
-
describe '#host_without_www' do
context 'URL with protocol' do
it 'returns the host' do
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
new file mode 100644
index 00000000000..cd19a0fae1e
--- /dev/null
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -0,0 +1,443 @@
+import Vue from 'vue';
+
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+
+import {
+ mockEndpoint, mockGroups, mockSearchedGroups,
+ mockRawPageInfo, mockParentGroupItem, mockRawChildren,
+ mockChildren, mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+});
+
+describe('AppComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ spyOn(vm.store, 'getGroups');
+
+ const groups = vm.groups;
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ spyOn(vm.store, 'getPaginationInfo');
+
+ const pageInfo = vm.pageInfo;
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
+
+ vm.fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ done();
+ }, 0);
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
+ spyOn(vm, 'updatePagination');
+
+ vm.fetchGroups({ updatePagination: true });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should show flash error when request fails', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
+ spyOn($, 'scrollTo');
+ spyOn(window, 'Flash');
+
+ vm.fetchGroups({});
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ it('should fetch default set of groups', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updatePagination').and.callThrough();
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ setTimeout(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchPage', () => {
+ it('should fetch groups for provided page details and update window state', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+ spyOn(gl.utils, 'mergeUrlParams').and.callThrough();
+ spyOn(window.history, 'replaceState');
+ spyOn($, 'scrollTo');
+
+ vm.fetchPage(2, null, null, true);
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith({
+ page: jasmine.any(String),
+ }, jasmine.any(String), jasmine.any(String));
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
+ spyOn(vm.store, 'setGroupChildren');
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ setTimeout(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeTruthy();
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeFalsy();
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ setTimeout(() => {
+ expect(groupItem.isChildrenLoading).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.children = mockChildren;
+ childGroupItem = groupItem.children[0];
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should leave group and remove group item from tree', (done) => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+ spyOn($, 'scrollTo');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ done();
+ }, 0);
+ });
+
+ it('should show error flash message if request failed to leave group', (done) => {
+ const message = 'An error occurred. Please try again.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', (done) => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ spyOn(vm.store, 'setPaginationInfo');
+
+ vm.updatePagination(mockRawPageInfo);
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ spyOn(vm.store, 'setGroups');
+
+ vm.updateGroups(mockGroups);
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ spyOn(vm.store, 'setSearchedGroups');
+
+ vm.updateGroups(mockGroups, true);
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+ expect(vm.isSearchEmpty).toBeFalsy();
+
+ vm.updateGroups([]);
+ expect(vm.isSearchEmpty).toBeTruthy();
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$on');
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
+ const newVm = createComponent();
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$off');
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', (done) => {
+ vm.isLoading = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups');
+ done();
+ });
+ });
+
+ it('should render groups tree', (done) => {
+ vm.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ vm.pageInfo = mockPageInfo;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..4eb198595fb
--- /dev/null
+++ b/spec/javascripts/groups/components/group_folder_spec.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = Object.assign({}, mockParentGroupItem);
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..0f4fbdae445
--- /dev/null
+++ b/spec/javascripts/groups/components/group_item_spec.js
@@ -0,0 +1,177 @@
+import Vue from 'vue';
+
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const rowClass = vm.rowClass;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach((className) => {
+ expect(classes.indexOf(className) > -1).toBeTruthy();
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.type = 'group';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ spyOn(eventHub, '$emit');
+
+ vm.onClickRowGroup(event);
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', (done) => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ spyOn(gl.utils, 'visitUrl').and.stub();
+ spyOn(eventHub, '$emit');
+
+ newVm.onClickRowGroup(event);
+ setTimeout(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ done();
+ }, 0);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
new file mode 100644
index 00000000000..90e818c1545
--- /dev/null
+++ b/spec/javascripts/groups/components/groups_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ spyOn(eventHub, '$emit').and.stub();
+
+ vm.change(2);
+ expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object), jasmine.any(Object));
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', (done) => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', (done) => {
+ vm.searchEmpty = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..2ce1a749a96
--- /dev/null
+++ b/spec/javascripts/groups/components/item_actions_spec.js
@@ -0,0 +1,110 @@
+import Vue from 'vue';
+
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('leaveConfirmationMessage', () => {
+ it('should return appropriate string for leave group confirmation', () => {
+ expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
+ expect(vm.dialogStatus).toBeFalsy();
+ vm.onLeaveGroup();
+ expect(vm.dialogStatus).toBeTruthy();
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(true);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
+ });
+
+ it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(false);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(editBtn.getAttribute('href')).toBe(group.editPath);
+ expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+ expect(editBtn.dataset.originalTitle).toBe('Edit group');
+ expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+ expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+ expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+ expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should show modal dialog when `dialogStatus` is set to `true`', () => {
+ vm.dialogStatus = true;
+ const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
+ expect(modalDialogEl).toBeDefined();
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..4310a07e6e6
--- /dev/null
+++ b/spec/javascripts/groups/components/item_caret_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+ const vm = createComponent(true);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
+ vm.$destroy();
+ });
+
+ it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..e200f9f08bd
--- /dev/null
+++ b/spec/javascripts/groups/components/item_stats_spec.js
@@ -0,0 +1,159 @@
+import Vue from 'vue';
+
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, { visibility });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.GROUP,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.PROJECT,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+ expect(vm.$el.classList.contains('.stats')).toBeDefined();
+ expect(visibilityIconEl).toBeDefined();
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Group', () => {
+ const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
+ expect(subgroupIconEl).toBeDefined();
+ expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
+ expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
+ expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
+
+ const projectsIconEl = vm.$el.querySelector('span.number-projects');
+ expect(projectsIconEl).toBeDefined();
+ expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
+ expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
+ expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
+
+ const membersIconEl = vm.$el.querySelector('span.number-users');
+ expect(membersIconEl).toBeDefined();
+ expect(membersIconEl.dataset.originalTitle).toBe('Members');
+ expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
+ expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Project', () => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ type: ITEM_TYPE.PROJECT,
+ starCount: 4,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+ expect(projectStarIconEl).toBeDefined();
+ expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
+ expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..528e6ed1b4c
--- /dev/null
+++ b/spec/javascripts/groups/components/item_type_icon_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
deleted file mode 100644
index 25e10552d95..00000000000
--- a/spec/javascripts/groups/group_item_spec.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import Vue from 'vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { group1 } from './mock_data';
-
-describe('Groups Component', () => {
- let GroupItemComponent;
- let component;
- let store;
- let group;
-
- describe('group with default data', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render the group item correctly', () => {
- expect(component.$el.classList.contains('group-row')).toBe(true);
- expect(component.$el.classList.contains('.no-description')).toBe(false);
- expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects);
- expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers);
- expect(component.$el.querySelector('.group-visibility')).toBeDefined();
- expect(component.$el.querySelector('.avatar-container')).toBeDefined();
- expect(component.$el.querySelector('.title').textContent).toContain(group.name);
- expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess);
- expect(component.$el.querySelector('.description').textContent).toContain(group.description);
- expect(component.$el.querySelector('.edit-group')).toBeDefined();
- expect(component.$el.querySelector('.leave-group')).toBeDefined();
- });
- });
-
- describe('group without description', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group1.description = '';
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render group item correctly', () => {
- expect(component.$el.querySelector('.description').textContent).toBe('');
- expect(component.$el.classList.contains('.no-description')).toBe(false);
- });
- });
-
- describe('user has not access to group', () => {
- beforeEach((done) => {
- GroupItemComponent = Vue.extend(groupItemComponent);
- store = new GroupsStore();
- group1.permissions.human_group_access = null;
- group = store.decorateGroup(group1);
-
- component = new GroupItemComponent({
- propsData: {
- group,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should not display access type', () => {
- expect(component.$el.querySelector('.access-type')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js
deleted file mode 100644
index b14153dbbfa..00000000000
--- a/spec/javascripts/groups/groups_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import eventHub from '~/groups/event_hub';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import groupsComponent from '~/groups/components/groups.vue';
-import GroupsStore from '~/groups/stores/groups_store';
-import { groupsData } from './mock_data';
-
-describe('Groups Component', () => {
- let GroupsComponent;
- let store;
- let component;
- let groups;
-
- beforeEach((done) => {
- Vue.component('group-folder', groupFolderComponent);
- Vue.component('group-item', groupItemComponent);
-
- store = new GroupsStore();
- groups = store.setGroups(groupsData.groups);
-
- store.storePagination(groupsData.pagination);
-
- GroupsComponent = Vue.extend(groupsComponent);
-
- component = new GroupsComponent({
- propsData: {
- groups: store.state.groups,
- pageInfo: store.state.pageInfo,
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- describe('with data', () => {
- it('should render a list of groups', () => {
- expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true);
- expect(component.$el.querySelector('#group-12')).toBeDefined();
- expect(component.$el.querySelector('#group-1119')).toBeDefined();
- expect(component.$el.querySelector('#group-1120')).toBeDefined();
- });
-
- it('should respect the order of groups', () => {
- const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
- expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
- expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
- });
-
- it('should render group and its subgroup', () => {
- const lists = component.$el.querySelectorAll('.group-list-tree');
-
- expect(lists.length).toBe(3); // one parent and two subgroups
-
- expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
- expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
-
- expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
- });
-
- it('should render group identicon when group avatar is not present', () => {
- const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
- expect(avatar.nodeName).toBe('DIV');
- expect(avatar.classList.contains('identicon')).toBeTruthy();
- expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
- });
-
- it('should render group avatar when group avatar is present', () => {
- const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar');
- expect(avatar.nodeName).toBe('IMG');
- expect(avatar.classList.contains('identicon')).toBeFalsy();
- });
-
- it('should remove prefix of parent group', () => {
- expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
- });
-
- it('should remove the group after leaving the group', (done) => {
- spyOn(window, 'confirm').and.returnValue(true);
-
- eventHub.$on('leaveGroup', (group, collection) => {
- store.removeGroup(group, collection);
- });
-
- component.$el.querySelector('#group-12 .leave-group').click();
-
- Vue.nextTick(() => {
- expect(component.$el.querySelector('#group-12')).toBeNull();
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
index 5bb84b591f4..6184d671790 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/javascripts/groups/mock_data.js
@@ -1,114 +1,380 @@
-const group1 = {
- id: 12,
- name: 'level1',
- path: 'level1',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/level1',
- group_path: '/level1',
- full_name: 'level1',
- full_path: 'level1',
- parent_id: null,
- created_at: '2017-05-15T19:01:23.670Z',
- updated_at: '2017-05-15T19:01:23.670Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
};
-// This group has no direct parent, should be placed as subgroup of group1
-const group14 = {
- id: 1128,
- name: 'level4',
- path: 'level4',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
- group_path: '/level1/level2/level3/level4',
- full_name: 'level1 / level2 / level3 / level4',
- full_path: 'level1/level2/level3/level4',
- parent_id: 1127,
- created_at: '2017-05-15T19:02:01.645Z',
- updated_at: '2017-05-15T19:02:01.645Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const GROUP_VISIBILITY_TYPE = {
+ public: 'Public - The group and any public projects can be viewed without any authentication.',
+ internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ private: 'Private - The group and its projects can only be viewed by members.',
};
-const group2 = {
- id: 1119,
- name: 'devops',
- path: 'devops',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/devops',
- group_path: '/devops',
- full_name: 'devops',
- full_path: 'devops',
- parent_id: null,
- created_at: '2017-05-11T19:35:09.635Z',
- updated_at: '2017-05-11T19:35:09.635Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+export const PROJECT_VISIBILITY_TYPE = {
+ public: 'Public - The project can be accessed without any authentication.',
+ internal: 'Internal - The project can be accessed by any logged in user.',
+ private: 'Private - Project access must be granted explicitly to each user.',
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'fa-globe',
+ internal: 'fa-shield',
+ private: 'fa-lock',
};
-const group21 = {
- id: 1120,
- name: 'chef',
- path: 'chef',
- description: 'foo',
+export const mockParentGroupItem = {
+ id: 55,
+ name: 'hardware',
+ description: '',
visibility: 'public',
- avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
- web_url: 'http://localhost:3000/groups/devops/chef',
- group_path: '/devops/chef',
- full_name: 'devops / chef',
- full_path: 'devops/chef',
- parent_id: 1119,
- created_at: '2017-05-11T19:51:04.060Z',
- updated_at: '2017-05-11T19:51:04.060Z',
- number_projects_with_delimiter: '1',
- number_users_with_delimiter: '1',
- has_subgroups: true,
- permissions: {
- human_group_access: 'Master',
- },
+ fullName: 'platform / hardware',
+ relativePath: '/platform/hardware',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/edit',
+ childrenCount: 3,
+ leavePath: '/groups/platform/hardware/group_members/leave',
+ parentId: 54,
+ memberCount: '1',
+ projectCount: 1,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
};
-const groupsData = {
- groups: [group1, group14, group2, group21],
- pagination: {
- Date: 'Mon, 22 May 2017 22:31:52 GMT',
- 'X-Prev-Page': '1',
- 'X-Content-Type-Options': 'nosniff',
- 'X-Total': '31',
- 'Transfer-Encoding': 'chunked',
- 'X-Runtime': '0.611144',
- 'X-Xss-Protection': '1; mode=block',
- 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
- 'X-Ua-Compatible': 'IE=edge',
- 'X-Per-Page': '20',
- Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
- 'X-Next-Page': '',
- Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
- 'X-Frame-Options': 'DENY',
- 'Content-Type': 'application/json; charset=utf-8',
- 'Cache-Control': 'max-age=0, private, must-revalidate',
- 'X-Total-Pages': '2',
- 'X-Page': '2',
+export const mockRawChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [],
+ },
+];
+
+export const mockChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware / bsp',
+ relativePath: '/platform/hardware/bsp',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/bsp/edit',
+ childrenCount: 6,
+ leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+ parentId: 55,
+ memberCount: '1',
+ projectCount: 4,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
},
+];
+
+export const mockGroups = [
+ {
+ id: 75,
+ name: 'test-group',
+ description: '',
+ visibility: 'public',
+ full_name: 'test-group',
+ relative_path: '/test-group',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/test-group/edit',
+ children_count: 2,
+ leave_path: '/groups/test-group/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 67,
+ name: 'open-source',
+ description: '',
+ visibility: 'private',
+ full_name: 'open-source',
+ relative_path: '/open-source',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/open-source/edit',
+ children_count: 0,
+ leave_path: '/groups/open-source/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 54,
+ name: 'platform',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform',
+ relative_path: '/platform',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ },
+ {
+ id: 5,
+ name: 'H5bp',
+ description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+ visibility: 'public',
+ full_name: 'H5bp',
+ relative_path: '/h5bp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/h5bp/edit',
+ children_count: 1,
+ leave_path: '/groups/h5bp/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 4,
+ name: 'Twitter',
+ description: 'Deserunt hic nostrum placeat veniam.',
+ visibility: 'public',
+ full_name: 'Twitter',
+ relative_path: '/twitter',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/twitter/edit',
+ children_count: 2,
+ leave_path: '/groups/twitter/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 3,
+ name: 'Documentcloud',
+ description: 'Consequatur saepe totam ea pariatur maxime.',
+ visibility: 'public',
+ full_name: 'Documentcloud',
+ relative_path: '/documentcloud',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/documentcloud/edit',
+ children_count: 1,
+ leave_path: '/groups/documentcloud/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 2,
+ name: 'Gitlab Org',
+ description: 'Debitis ea quas aperiam velit doloremque ab.',
+ visibility: 'public',
+ full_name: 'Gitlab Org',
+ relative_path: '/gitlab-org',
+ can_edit: true,
+ type: 'group',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ permission: 'Owner',
+ edit_path: '/groups/gitlab-org/edit',
+ children_count: 4,
+ leave_path: '/groups/gitlab-org/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 4,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+];
+
+export const mockSearchedGroups = [
+ {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware',
+ relative_path: '/platform/hardware',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/edit',
+ children_count: 3,
+ leave_path: '/groups/platform/hardware/group_members/leave',
+ parent_id: 54,
+ number_users_with_delimiter: '1',
+ project_count: 1,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 60,
+ name: 'kernel',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel',
+ relative_path: '/platform/hardware/bsp/kernel',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+ parent_id: 57,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ children: [
+ {
+ id: 61,
+ name: 'common',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common',
+ relative_path: '/platform/hardware/bsp/kernel/common',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+ children_count: 2,
+ leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+ parent_id: 60,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ children: [
+ {
+ id: 17,
+ name: 'v4.4',
+ description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.4',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+ star_count: 0,
+ },
+ {
+ id: 16,
+ name: 'v4.1',
+ description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+ star_count: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockRawPageInfo = {
+ 'x-per-page': 10,
+ 'x-page': 10,
+ 'x-total': 10,
+ 'x-total-pages': 10,
+ 'x-next-page': 10,
+ 'x-prev-page': 10,
};
-export { groupsData, group1 };
+export const mockPageInfo = {
+ perPage: 10,
+ page: 10,
+ total: 10,
+ totalPages: 10,
+ nextPage: 10,
+ prevPage: 10,
+};
diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..20bb63687f7
--- /dev/null
+++ b/spec/javascripts/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+Vue.use(VueResource);
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ spyOn(service.groups, 'get').and.stub();
+ const queryParams = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith(queryParams);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ spyOn(Vue.http, 'delete').and.stub();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+ expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..d74f38f476e
--- /dev/null
+++ b/spec/javascripts/groups/store/groups_store_spec.js
@@ -0,0 +1,110 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups, mockSearchedGroups,
+ mockParentGroupItem, mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroups(mockGroups);
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setSearchedGroups(mockSearchedGroups);
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = Object.assign({}, mockGroups[0]);
+ const rawChildGroup = Object.assign({}, mockGroups[1]);
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js
index c7179b3e03d..4a210faa017 100644
--- a/spec/javascripts/jobs/header_spec.js
+++ b/spec/javascripts/jobs/header_spec.js
@@ -30,7 +30,6 @@ describe('Job details header', () => {
email: 'foo@bar.com',
avatar_url: 'link',
},
- retry_path: 'path',
new_issue_path: 'path',
},
isLoading: false,
@@ -49,12 +48,6 @@ describe('Job details header', () => {
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
});
- it('should render retry link', () => {
- expect(
- vm.$el.querySelector('.js-retry-button').getAttribute('href'),
- ).toEqual(props.job.retry_path);
- });
-
it('should render new issue link', () => {
expect(
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index f86f2f260c3..6613b7dee6b 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -474,7 +474,11 @@ describe('common_utils', () => {
});
it('should return the svg for a linked icon', () => {
- expect(gl.utils.spriteIcon('test')).toEqual('<svg><use xlink:href="icons.svg#test" /></svg>');
+ expect(gl.utils.spriteIcon('test')).toEqual('<svg ><use xlink:href="icons.svg#test" /></svg>');
+ });
+
+ it('should set svg className when passed', () => {
+ expect(gl.utils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>');
});
});
});
diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
index d2e7243ee05..4d3fdbd9554 100644
--- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
+++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
@@ -31,8 +31,8 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
end
it 'creates correct entries in the merge_request_diff_commits table' do
- expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(commits.count)
- expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(commits)
+ expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(expected_commits.count)
+ expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(expected_commits)
end
it 'creates correct entries in the merge_request_diff_files table' do
@@ -199,6 +199,16 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diff has valid commits and diffs' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
+ let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
+ let(:expected_diffs) { diffs }
+
+ include_examples 'updated MR diff'
+ end
+
+ context 'when the merge request diff has diffs but no commits' do
+ let(:commits) { nil }
+ let(:expected_commits) { [] }
let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:expected_diffs) { diffs }
@@ -207,6 +217,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs do not have too_large set' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:diffs) do
@@ -218,6 +229,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs do not have a_mode and b_mode set' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) }
let(:diffs) do
@@ -229,6 +241,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs have binary content' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:expected_diffs) { diffs }
# The start of a PDF created by Illustrator
@@ -257,6 +270,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diff has commits, but no diffs' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:diffs) { [] }
let(:expected_diffs) { diffs }
@@ -265,6 +279,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs have invalid content' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
+ let(:expected_commits) { commits }
let(:diffs) { ['--broken-diff'] }
let(:expected_diffs) { [] }
@@ -274,6 +289,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs are Rugged::Patch instances' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:expected_commits) { commits }
let(:diffs) { first_commit.rugged_diff_from_parent.patches }
let(:expected_diffs) { [] }
@@ -283,6 +299,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
context 'when the merge request diffs are Rugged::Diff::Delta instances' do
let(:commits) { merge_request_diff.commits.map(&:to_hash) }
let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) }
+ let(:expected_commits) { commits }
let(:diffs) { first_commit.rugged_diff_from_parent.deltas }
let(:expected_diffs) { [] }
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
index a4d7628b03a..5944ce8049a 100644
--- a/spec/lib/gitlab/conflict/file_collection_spec.rb
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Conflict::FileCollection do
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
- let(:file_collection) { described_class.read_only(merge_request) }
+ let(:file_collection) { described_class.new(merge_request) }
describe '#files' do
it 'returns an array of Conflict::Files' do
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
index 5356e9742b4..bf981d2f6f6 100644
--- a/spec/lib/gitlab/conflict/file_spec.rb
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -8,9 +8,10 @@ describe Gitlab::Conflict::File do
let(:our_commit) { rugged.branches['conflict-resolvable'].target }
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) }
let(:index) { rugged.merge_commits(our_commit, their_commit) }
- let(:conflict) { index.conflicts.last }
- let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
- let(:conflict_file) { described_class.new(merge_file_result, conflict, merge_request: merge_request) }
+ let(:rugged_conflict) { index.conflicts.last }
+ let(:raw_conflict_content) { index.merge_file('files/ruby/regex.rb')[:data] }
+ let(:raw_conflict_file) { Gitlab::Git::Conflict::File.new(repository, our_commit.oid, rugged_conflict, raw_conflict_content) }
+ let(:conflict_file) { described_class.new(raw_conflict_file, merge_request: merge_request) }
describe '#resolve_lines' do
let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
@@ -48,18 +49,18 @@ describe Gitlab::Conflict::File do
end
end
- it 'raises MissingResolution when passed a hash without resolutions for all sections' do
+ it 'raises ResolutionError when passed a hash without resolutions for all sections' do
empty_hash = section_keys.map { |key| [key, nil] }.to_h
invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
expect { conflict_file.resolve_lines({}) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
expect { conflict_file.resolve_lines(empty_hash) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
expect { conflict_file.resolve_lines(invalid_hash) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
@@ -144,7 +145,7 @@ describe Gitlab::Conflict::File do
end
context 'with an example file' do
- let(:file) do
+ let(:raw_conflict_content) do
<<FILE
# Ensure there is no match line header here
def username_regexp
@@ -220,7 +221,6 @@ end
FILE
end
- let(:conflict_file) { described_class.new({ data: file }, conflict, merge_request: merge_request) }
let(:sections) { conflict_file.sections }
it 'sets the correct match line headers' do
diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb
index 8af49ed50ff..80c8c189665 100644
--- a/spec/lib/gitlab/diff/parser_spec.rb
+++ b/spec/lib/gitlab/diff/parser_spec.rb
@@ -143,4 +143,21 @@ eos
it { expect(parser.parse([])).to eq([]) }
it { expect(parser.parse(nil)).to eq([]) }
end
+
+ describe 'tolerates special diff markers in a content' do
+ it "counts lines correctly" do
+ diff = <<~END
+ --- a/test
+ +++ b/test
+ @@ -1,2 +1,2 @@
+ +ipsum
+ +++ b
+ -ipsum
+ END
+
+ lines = parser.parse(diff.lines).to_a
+
+ expect(lines.size).to eq(3)
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index 9bf54fdecc4..245f24e96d4 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -40,7 +40,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 0)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -108,7 +108,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 15)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 15)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -149,7 +149,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -189,7 +189,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 13, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 13, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -233,7 +233,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 5)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 5)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -274,7 +274,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -314,7 +314,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 4, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 4, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -357,7 +357,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 0, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -399,7 +399,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, subject.new_line, 0)
expect(subject.line_code(project.repository)).to eq(line_code)
end
@@ -447,7 +447,7 @@ describe Gitlab::Diff::Position do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line)
+ line_code = Gitlab::Git.diff_line_code(subject.file_path, 0, subject.old_line)
expect(subject.line_code(project.repository)).to eq(line_code)
end
diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/git/conflict/parser_spec.rb
index fce606a2bb5..7b035a381f1 100644
--- a/spec/lib/gitlab/conflict/parser_spec.rb
+++ b/spec/lib/gitlab/git/conflict/parser_spec.rb
@@ -1,11 +1,9 @@
require 'spec_helper'
-describe Gitlab::Conflict::Parser do
- let(:parser) { described_class.new }
-
- describe '#parse' do
+describe Gitlab::Git::Conflict::Parser do
+ describe '.parse' do
def parse_text(text)
- parser.parse(text, our_path: 'README.md', their_path: 'README.md')
+ described_class.parse(text, our_path: 'README.md', their_path: 'README.md')
end
context 'when the file has valid conflicts' do
@@ -87,33 +85,37 @@ CONFLICT
end
let(:lines) do
- parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
+ described_class.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
+ end
+ let(:old_line_numbers) do
+ lines.select { |line| line[:type] != 'new' }.map { |line| line[:line_old] }
end
+ let(:new_line_numbers) do
+ lines.select { |line| line[:type] != 'old' }.map { |line| line[:line_new] }
+ end
+ let(:line_indexes) { lines.map { |line| line[:line_obj_index] } }
it 'sets our lines as new lines' do
- expect(lines[8..13]).to all(have_attributes(type: 'new'))
- expect(lines[26..27]).to all(have_attributes(type: 'new'))
- expect(lines[56..57]).to all(have_attributes(type: 'new'))
+ expect(lines[8..13]).to all(include(type: 'new'))
+ expect(lines[26..27]).to all(include(type: 'new'))
+ expect(lines[56..57]).to all(include(type: 'new'))
end
it 'sets their lines as old lines' do
- expect(lines[14..19]).to all(have_attributes(type: 'old'))
- expect(lines[28..29]).to all(have_attributes(type: 'old'))
- expect(lines[58..59]).to all(have_attributes(type: 'old'))
+ expect(lines[14..19]).to all(include(type: 'old'))
+ expect(lines[28..29]).to all(include(type: 'old'))
+ expect(lines[58..59]).to all(include(type: 'old'))
end
it 'sets non-conflicted lines as both' do
- expect(lines[0..7]).to all(have_attributes(type: nil))
- expect(lines[20..25]).to all(have_attributes(type: nil))
- expect(lines[30..55]).to all(have_attributes(type: nil))
- expect(lines[60..62]).to all(have_attributes(type: nil))
+ expect(lines[0..7]).to all(include(type: nil))
+ expect(lines[20..25]).to all(include(type: nil))
+ expect(lines[30..55]).to all(include(type: nil))
+ expect(lines[60..62]).to all(include(type: nil))
end
- it 'sets consecutive line numbers for index, old_pos, and new_pos' do
- old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos)
- new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos)
-
- expect(lines.map(&:index)).to eq(0.upto(62).to_a)
+ it 'sets consecutive line numbers for line_obj_index, line_old, and line_new' do
+ expect(line_indexes).to eq(0.upto(62).to_a)
expect(old_line_numbers).to eq(1.upto(53).to_a)
expect(new_line_numbers).to eq(1.upto(53).to_a)
end
@@ -123,12 +125,12 @@ CONFLICT
context 'when there is a non-start delimiter first' do
it 'raises UnexpectedDelimiter when there is a middle delimiter first' do
expect { parse_text('=======') }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when there is an end delimiter first' do
expect { parse_text('>>>>>>> README.md') }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when there is an end delimiter for a different path first' do
@@ -143,12 +145,12 @@ CONFLICT
it 'raises UnexpectedDelimiter when it is followed by an end delimiter' do
expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by another start delimiter' do
expect { parse_text(start_text + start_text + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for a different path' do
@@ -163,12 +165,12 @@ CONFLICT
it 'raises UnexpectedDelimiter when it is followed by another middle delimiter' do
expect { parse_text(start_text + '=======' + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'raises UnexpectedDelimiter when it is followed by a start delimiter' do
expect { parse_text(start_text + start_text + end_text) }
- .to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnexpectedDelimiter)
end
it 'does not raise when it is followed by a start delimiter for another path' do
@@ -181,25 +183,25 @@ CONFLICT
start_text = "<<<<<<< README.md\n=======\n"
expect { parse_text(start_text) }
- .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::MissingEndDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md') }
- .to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
+ .to raise_error(Gitlab::Git::Conflict::Parser::MissingEndDelimiter)
end
end
context 'other file types' do
it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
expect { parse_text('') }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
expect { parse_text(nil) }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
end
it 'raises UnmergeableFile when the file is over 200 KB' do
expect { parse_text('a' * 204801) }
- .to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnmergeableFile)
end
# All text from Rugged has an encoding of ASCII_8BIT, so force that in
@@ -214,7 +216,7 @@ CONFLICT
context 'when the file contains non-UTF-8 characters' do
it 'raises UnsupportedEncoding' do
expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }
- .to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding)
+ .to raise_error(Gitlab::Git::Conflict::Parser::UnsupportedEncoding)
end
end
end
diff --git a/spec/lib/gitlab/git/env_spec.rb b/spec/lib/gitlab/git/env_spec.rb
index d9df99bfe05..03836d49518 100644
--- a/spec/lib/gitlab/git/env_spec.rb
+++ b/spec/lib/gitlab/git/env_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Git::Env do
- describe "#set" do
+ describe ".set" do
context 'with RequestStore.store disabled' do
before do
allow(RequestStore).to receive(:active?).and_return(false)
@@ -34,25 +34,57 @@ describe Gitlab::Git::Env do
end
end
- describe "#all" do
+ describe ".all" do
context 'with RequestStore.store enabled' do
before do
allow(RequestStore).to receive(:active?).and_return(true)
described_class.set(
GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar')
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: ['bar'])
end
it 'returns an env hash' do
expect(described_class.all).to eq({
'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar'
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => ['bar']
})
end
end
end
- describe "#[]" do
+ describe ".to_env_hash" do
+ context 'with RequestStore.store enabled' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:key) { 'GIT_OBJECT_DIRECTORY' }
+ subject { described_class.to_env_hash }
+
+ where(:input, :output) do
+ nil | nil
+ 'foo' | 'foo'
+ [] | ''
+ ['foo'] | 'foo'
+ %w[foo bar] | 'foo:bar'
+ end
+
+ with_them do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ described_class.set(key.to_sym => input)
+ end
+
+ it 'puts the right value in the hash' do
+ if output
+ expect(subject.fetch(key)).to eq(output)
+ else
+ expect(subject.has_key?(key)).to eq(false)
+ end
+ end
+ end
+ end
+ end
+
+ describe ".[]" do
context 'with RequestStore.store enabled' do
before do
allow(RequestStore).to receive(:active?).and_return(true)
diff --git a/spec/lib/gitlab/git/popen_spec.rb b/spec/lib/gitlab/git/popen_spec.rb
new file mode 100644
index 00000000000..2b65bc1cf15
--- /dev/null
+++ b/spec/lib/gitlab/git/popen_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe 'Gitlab::Git::Popen' do
+ let(:path) { Rails.root.join('tmp').to_s }
+
+ let(:klass) do
+ Class.new(Object) do
+ include Gitlab::Git::Popen
+ end
+ end
+
+ context 'popen' do
+ context 'zero status' do
+ let(:result) { klass.new.popen(%w(ls), path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to include('tests') }
+ end
+
+ context 'non-zero status' do
+ let(:result) { klass.new.popen(%w(cat NOTHING), path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to eq(1) }
+ it { expect(output).to include('No such file or directory') }
+ end
+
+ context 'unsafe string command' do
+ it 'raises an error when it gets called with a string argument' do
+ expect { klass.new.popen('ls', path) }.to raise_error(RuntimeError)
+ end
+ end
+
+ context 'with custom options' do
+ let(:vars) { { 'foobar' => 123, 'PWD' => path } }
+ let(:options) { { chdir: path } }
+
+ it 'calls popen3 with the provided environment variables' do
+ expect(Open3).to receive(:popen3).with(vars, 'ls', options)
+
+ klass.new.popen(%w(ls), path, { 'foobar' => 123 })
+ end
+ end
+
+ context 'use stdin' do
+ let(:result) { klass.new.popen(%w[cat], path) { |stdin| stdin.write 'hello' } }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to eq('hello') }
+ end
+ end
+
+ context 'popen_with_timeout' do
+ let(:timeout) { 1.second }
+
+ context 'no timeout' do
+ context 'zero status' do
+ let(:result) { klass.new.popen_with_timeout(%w(ls), timeout, path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to be_zero }
+ it { expect(output).to include('tests') }
+ end
+
+ context 'non-zero status' do
+ let(:result) { klass.new.popen_with_timeout(%w(cat NOTHING), timeout, path) }
+ let(:output) { result.first }
+ let(:status) { result.last }
+
+ it { expect(status).to eq(1) }
+ it { expect(output).to include('No such file or directory') }
+ end
+
+ context 'unsafe string command' do
+ it 'raises an error when it gets called with a string argument' do
+ expect { klass.new.popen_with_timeout('ls', timeout, path) }.to raise_error(RuntimeError)
+ end
+ end
+ end
+
+ context 'timeout' do
+ context 'timeout' do
+ it "raises a Timeout::Error" do
+ expect { klass.new.popen_with_timeout(%w(sleep 1000), timeout, path) }.to raise_error(Timeout::Error)
+ end
+
+ it "handles processes that do not shutdown correctly" do
+ expect { klass.new.popen_with_timeout(['bash', '-c', "trap -- '' SIGTERM; sleep 1000"], timeout, path) }.to raise_error(Timeout::Error)
+ end
+ end
+
+ context 'timeout period' do
+ let(:time_taken) do
+ begin
+ start = Time.now
+ klass.new.popen_with_timeout(%w(sleep 1000), timeout, path)
+ rescue
+ Time.now - start
+ end
+ end
+
+ it { expect(time_taken).to be >= timeout }
+ end
+
+ context 'clean up' do
+ let(:instance) { klass.new }
+
+ it 'kills the child process' do
+ expect(instance).to receive(:kill_process_group_for_pid).and_wrap_original do |m, *args|
+ # is the PID, and it should not be running at this point
+ m.call(*args)
+
+ pid = args.first
+ begin
+ Process.getpgid(pid)
+ raise "The child process should have been killed"
+ rescue Errno::ESRCH
+ end
+ end
+
+ expect { instance.popen_with_timeout(['bash', '-c', "trap -- '' SIGTERM; sleep 1000"], timeout, path) }.to raise_error(Timeout::Error)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
index 98cf7966dad..68eb93eb9f9 100644
--- a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb
@@ -10,18 +10,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
# Override test-settings for the circuitbreaker with something more realistic
# for these specs.
stub_storage_settings('default' => {
- 'path' => TestEnv.repos_path,
- 'failure_count_threshold' => 10,
- 'failure_wait_time' => 30,
- 'failure_reset_time' => 1800,
- 'storage_timeout' => 5
+ 'path' => TestEnv.repos_path
},
'broken' => {
- 'path' => 'tmp/tests/non-existent-repositories',
- 'failure_count_threshold' => 10,
- 'failure_wait_time' => 30,
- 'failure_reset_time' => 1800,
- 'storage_timeout' => 5
+ 'path' => 'tmp/tests/non-existent-repositories'
},
'nopath' => { 'path' => nil }
)
@@ -75,10 +67,39 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
expect(circuit_breaker.hostname).to eq(hostname)
expect(circuit_breaker.storage).to eq('default')
expect(circuit_breaker.storage_path).to eq(TestEnv.repos_path)
- expect(circuit_breaker.failure_count_threshold).to eq(10)
- expect(circuit_breaker.failure_wait_time).to eq(30)
- expect(circuit_breaker.failure_reset_time).to eq(1800)
- expect(circuit_breaker.storage_timeout).to eq(5)
+ end
+ end
+
+ context 'circuitbreaker settings' do
+ before do
+ stub_application_setting(circuitbreaker_failure_count_threshold: 0,
+ circuitbreaker_failure_wait_time: 1,
+ circuitbreaker_failure_reset_time: 2,
+ circuitbreaker_storage_timeout: 3)
+ end
+
+ describe '#failure_count_threshold' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_count_threshold).to eq(0)
+ end
+ end
+
+ describe '#failure_wait_time' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_wait_time).to eq(1)
+ end
+ end
+
+ describe '#failure_reset_time' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.failure_reset_time).to eq(2)
+ end
+ end
+
+ describe '#storage_timeout' do
+ it 'reads the value from settings' do
+ expect(circuit_breaker.storage_timeout).to eq(3)
+ end
end
end
@@ -151,10 +172,7 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state:
context 'the `failure_wait_time` is set to 0' do
before do
- stub_storage_settings('default' => {
- 'failure_wait_time' => 0,
- 'path' => TestEnv.repos_path
- })
+ stub_application_setting(circuitbreaker_failure_wait_time: 0)
end
it 'is working even when there is a recent failure' do
diff --git a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
index 0e645008c88..7ee6d2f3709 100644
--- a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
+++ b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb
@@ -54,6 +54,10 @@ describe Gitlab::Git::Storage::NullCircuitBreaker do
end
describe '#failure_count_threshold' do
+ before do
+ stub_application_setting(circuitbreaker_failure_count_threshold: 1)
+ end
+
it { expect(breaker.failure_count_threshold).to eq(1) }
end
diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb
index 8dc83a6db7f..30686634af4 100644
--- a/spec/lib/gitlab/group_hierarchy_spec.rb
+++ b/spec/lib/gitlab/group_hierarchy_spec.rb
@@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
expect(relation).to include(parent, child1)
end
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
+
+ expect(relation).to contain_exactly(child2)
+ end
+
it 'uses ancestors_base #initialize argument' do
relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
end
end
+ describe '#descendants' do
+ it 'includes only the descendants' do
+ relation = described_class.new(Group.where(id: parent)).descendants
+
+ expect(relation).to contain_exactly(child1, child2)
+ end
+ end
+
+ describe '#ancestors' do
+ it 'includes only the ancestors' do
+ relation = described_class.new(Group.where(id: child2)).ancestors
+
+ expect(relation).to contain_exactly(child1, parent)
+ end
+
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
+
+ expect(relation).to be_empty
+ end
+ end
+
describe '#all_groups' do
let(:relation) do
described_class.new(Group.where(id: child1.id)).all_groups
diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb
new file mode 100644
index 00000000000..68bd4f93159
--- /dev/null
+++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe Gitlab::MultiCollectionPaginator do
+ subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
+
+ it 'combines both collections' do
+ project = create(:project)
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([project, group])
+ end
+
+ it 'includes elements second collection if first collection is empty' do
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([group])
+ end
+
+ context 'with a full first page' do
+ let!(:all_groups) { create_list(:group, 4) }
+ let!(:all_projects) { create_list(:project, 4) }
+
+ it 'knows the total count of the collection' do
+ expect(paginator.total_count).to eq(8)
+ end
+
+ it 'fills the first page with elements of the first collection' do
+ expect(paginator.paginate(1)).to eq(all_projects.take(3))
+ end
+
+ it 'fils the second page with a mixture of of the first & second collection' do
+ first_collection_element = all_projects.last
+ second_collection_elements = all_groups.take(2)
+
+ expected_collection = [first_collection_element] + second_collection_elements
+
+ expect(paginator.paginate(2)).to eq(expected_collection)
+ end
+
+ it 'fils the last page with elements from the second collection' do
+ expected_collection = all_groups[-2..-1]
+
+ expect(paginator.paginate(3)).to eq(expected_collection)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 1f1c48ee9b5..f1f188cbfb5 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
end
@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/more/')
expect(subject).to match('group_members/more/')
- expect(subject).to match('subgroups/more/')
+ expect(subject).to match('labels/more/')
end
end
end
@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/')
expect(subject).not_to match('root/group_members/')
- expect(subject).not_to match('root/subgroups/')
+ expect(subject).not_to match('root/labels/')
end
end
@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/more/')
expect(subject).not_to match('root/group_members/more/')
- expect(subject).not_to match('root/subgroups/more/')
+ expect(subject).not_to match('root/labels/more/')
end
end
end
@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('root/activity/')
expect(subject).to match('root/group_members/')
- expect(subject).to match('root/subgroups/')
+ expect(subject).to match('root/labels/')
end
it 'is not case sensitive' do
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index 8026fba9f0a..fe6422c32b6 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
expect(union.to_sql).to include('UNION ALL')
end
+
+ it 'returns `NULL` if all relations are empty' do
+ empty_relation = User.none
+ union = described_class.new([empty_relation, empty_relation])
+
+ expect(union.to_sql).to eq('NULL')
+ end
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 777e9c8e21d..a7b65e94706 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -61,6 +61,8 @@ describe Gitlab::UsageData do
deployments
environments
gcp_clusters
+ gcp_clusters_enabled
+ gcp_clusters_disabled
in_review_folder
groups
issues
diff --git a/spec/lib/gitlab/utils/merge_hash_spec.rb b/spec/lib/gitlab/utils/merge_hash_spec.rb
new file mode 100644
index 00000000000..4fa7bb31301
--- /dev/null
+++ b/spec/lib/gitlab/utils/merge_hash_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+describe Gitlab::Utils::MergeHash do
+ describe '.crush' do
+ it 'can flatten a hash to each element' do
+ input = { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"]
+
+ expect(described_class.crush(input)).to eq(expected_result)
+ end
+ end
+
+ describe '.elements' do
+ it 'deep merges an array of elements' do
+ input = [{ hello: ["world"] },
+ { hello: "Everyone" },
+ { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } },
+ "Goodbye", "Hallo"]
+ expected_output = [
+ {
+ hello:
+ [
+ "world",
+ "Everyone",
+ { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] }
+ ]
+ },
+ "Goodbye"
+ ]
+
+ expect(described_class.merge(input)).to eq(expected_output)
+ end
+ end
+end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index eff84c308b5..6945c90cb9b 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -114,6 +114,19 @@ describe ApplicationSetting do
it { expect(setting.repository_storages).to eq(['default']) }
end
+ context 'circuitbreaker settings' do
+ [:circuitbreaker_failure_count_threshold,
+ :circuitbreaker_failure_wait_time,
+ :circuitbreaker_failure_reset_time,
+ :circuitbreaker_storage_timeout].each do |field|
+ it "Validates #{field} as number" do
+ is_expected.to validate_numericality_of(field)
+ .only_integer
+ .is_greater_than_or_equal_to(0)
+ end
+ end
+ end
+
context 'repository storages' do
before do
storages = {
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 06f76b5501e..41ecdb604f1 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1743,19 +1743,34 @@ describe Ci::Build do
end
describe 'state transition when build fails' do
+ let(:service) { MergeRequests::AddTodoWhenBuildFailsService.new(project, user) }
+
+ before do
+ allow(MergeRequests::AddTodoWhenBuildFailsService).to receive(:new).and_return(service)
+ allow(service).to receive(:close)
+ end
+
context 'when build is configured to be retried' do
- subject { create(:ci_build, :running, options: { retry: 3 }) }
+ subject { create(:ci_build, :running, options: { retry: 3 }, project: project, user: user) }
- it 'retries builds and assigns a same user to it' do
+ it 'retries build and assigns the same user to it' do
expect(described_class).to receive(:retry)
- .with(subject, subject.user)
+ .with(subject, user)
+
+ subject.drop!
+ end
+
+ it 'does not try to create a todo' do
+ project.add_developer(user)
+
+ expect(service).not_to receive(:commit_status_merge_requests)
subject.drop!
end
end
context 'when build is not configured to be retried' do
- subject { create(:ci_build, :running) }
+ subject { create(:ci_build, :running, project: project, user: user) }
it 'does not retry build' do
expect(described_class).not_to receive(:retry)
@@ -1770,6 +1785,14 @@ describe Ci::Build do
subject.drop!
end
+
+ it 'creates a todo' do
+ project.add_developer(user)
+
+ expect(service).to receive(:commit_status_merge_requests)
+
+ subject.drop!
+ end
end
end
end
diff --git a/spec/models/concerns/group_descendant_spec.rb b/spec/models/concerns/group_descendant_spec.rb
new file mode 100644
index 00000000000..c163fb01a81
--- /dev/null
+++ b/spec/models/concerns/group_descendant_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendant, :nested_groups do
+ let(:parent) { create(:group) }
+ let(:subgroup) { create(:group, parent: parent) }
+ let(:subsub_group) { create(:group, parent: subgroup) }
+
+ def all_preloaded_groups(*groups)
+ groups + [parent, subgroup, subsub_group]
+ end
+
+ context 'for a group' do
+ describe '#hierarchy' do
+ it 'only queries once for the ancestors' do
+ # make sure the subsub_group does not have anything cached
+ test_group = create(:group, parent: subsub_group).reload
+
+ query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count
+
+ expect(query_count).to eq(1)
+ end
+
+ it 'only queries once for the ancestors when a top is given' do
+ test_group = create(:group, parent: subsub_group).reload
+
+ recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) }
+ expect(recorder.count).to eq(1)
+ end
+
+ it 'builds a hierarchy for a group' do
+ expected_hierarchy = { parent => { subgroup => subsub_group } }
+
+ expect(subsub_group.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subgroup => subsub_group }
+
+ expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if specifying a base that is not part of the tree' do
+ expect { subsub_group.hierarchy(build_stubbed(:group)) }
+ .to raise_error('specified top is not part of the tree')
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group)
+
+ expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = [other_subgroup, subsub_group, other_subsub_group]
+ groups << subgroup # Add the parent as if it was preloaded
+
+ expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }]
+ expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'handles building a tree out of order' do
+ other_subgroup = create(:group, parent: parent)
+ other_subgroup2 = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: other_subgroup)
+
+ groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup)
+ expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if not all elements were preloaded' do
+ expect { described_class.build_hierarchy([subsub_group]) }
+ .to raise_error('parent was not preloaded')
+ end
+ end
+ end
+
+ context 'for a project' do
+ let(:project) { create(:project, namespace: subsub_group) }
+
+ describe '#hierarchy' do
+ it 'builds a hierarchy for a project' do
+ expected_hierarchy = { parent => { subgroup => { subsub_group => project } } }
+
+ expect(project.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subsub_group => project }
+
+ expect(project.hierarchy(subgroup)).to eq(expected_hierarchy)
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project)
+
+ expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] }
+
+ expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = [other_project, subsub_group, other_subgroup_project]
+ elements << subgroup # Added as if it was preloaded
+
+ expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }]
+
+ expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'merges to elements in the same hierarchy' do
+ expected_hierarchy = { parent => subgroup }
+
+ expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy)
+ end
+
+ it 'merges complex hierarchies' do
+ project = create(:project, namespace: parent)
+ sub_project = create(:project, namespace: subgroup)
+ subsubsub_group = create(:group, parent: subsub_group)
+ subsub_project = create(:project, namespace: subsub_group)
+ subsubsub_project = create(:project, namespace: subsubsub_group)
+ other_subgroup = create(:group, parent: parent)
+ other_subproject = create(:project, namespace: other_subgroup)
+
+ elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project]
+ # Add parent groups as if they were preloaded
+ elements += [other_subgroup, subsubsub_group, subsub_group, subgroup]
+
+ expected_hierarchy = [
+ project,
+ {
+ subgroup => [
+ { subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] },
+ sub_project
+ ]
+ },
+ { other_subgroup => other_subproject }
+ ]
+
+ actual_hierarchy = described_class.build_hierarchy(elements, parent)
+
+ expect(actual_hierarchy).to eq(expected_hierarchy)
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb
new file mode 100644
index 00000000000..7a279547a3a
--- /dev/null
+++ b/spec/models/concerns/loaded_in_group_list_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe LoadedInGroupList do
+ let(:parent) { create(:group) }
+ subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) }
+
+ describe '.with_selects_for_list' do
+ it 'includes the preloaded counts for groups' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+ parent.add_developer(create(:user))
+
+ found_group = Group.with_selects_for_list.find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(1)
+ expect(found_group.preloaded_subgroup_count).to eq(1)
+ expect(found_group.preloaded_member_count).to eq(1)
+ end
+
+ context 'with archived projects' do
+ it 'counts including archived projects when `true` is passed' do
+ create(:project, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+
+ it 'counts only archived projects when `only` is passed' do
+ create_list(:project, 2, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+ end
+ end
+
+ describe '#children_count' do
+ it 'counts groups and projects' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+
+ expect(found_group.children_count).to eq(2)
+ end
+ end
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index eb0a3e9e0d3..da972d2d86a 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -105,7 +105,7 @@ describe DiffNote do
describe "#line_code" do
it "returns the correct line code" do
- line_code = Gitlab::Diff::LineCode.generate(position.file_path, position.formatter.new_line, 15)
+ line_code = Gitlab::Git.diff_line_code(position.file_path, position.formatter.new_line, 15)
expect(subject.line_code).to eq(line_code)
end
diff --git a/spec/models/gcp/cluster_spec.rb b/spec/models/gcp/cluster_spec.rb
index 350fbc257d9..8f39fff6394 100644
--- a/spec/models/gcp/cluster_spec.rb
+++ b/spec/models/gcp/cluster_spec.rb
@@ -7,6 +7,30 @@ describe Gcp::Cluster do
it { is_expected.to validate_presence_of(:gcp_cluster_zone) }
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ let!(:cluster) { create(:gcp_cluster, enabled: true) }
+
+ before do
+ create(:gcp_cluster, enabled: false)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ let!(:cluster) { create(:gcp_cluster, enabled: false) }
+
+ before do
+ create(:gcp_cluster, enabled: true)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
describe '#default_value_for' do
let(:cluster) { described_class.new }
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 2ebf6acd42a..1bd8e8a5415 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -153,6 +153,20 @@ describe Namespace do
end
end
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
+
+ it 'returns all ancestors when no namespace is given' do
+ expect(child2.ancestors_upto).to contain_exactly(child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(child2.ancestors_upto(parent)).to contain_exactly(child)
+ end
+ end
+
describe '#move_dir' do
let(:namespace) { create(:namespace) }
let!(:project) { create(:project_empty_repo, namespace: namespace) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index cf26dbfea49..74eba7e33f6 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1761,6 +1761,21 @@ describe Project do
it { expect(project.gitea_import?).to be true }
end
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
+ let(:project) { create(:project, namespace: child2) }
+
+ it 'returns all ancestors when no namespace is given' do
+ expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(project.ancestors_upto(parent)).to contain_exactly(child2, child)
+ end
+ end
+
describe '#lfs_enabled?' do
let(:project) { create(:project) }
@@ -2178,6 +2193,12 @@ describe Project do
it { expect(project.parent).to eq(project.namespace) }
end
+ describe '#parent_id' do
+ let(:project) { create(:project) }
+
+ it { expect(project.parent_id).to eq(project.namespace_id) }
+ end
+
describe '#parent_changed?' do
let(:project) { create(:project) }
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 0b9a4b5c3db..c24de58ee9d 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -23,6 +23,7 @@ describe API::Settings, 'Settings' do
expect(json_response['dsa_key_restriction']).to eq(0)
expect(json_response['ecdsa_key_restriction']).to eq(0)
expect(json_response['ed25519_key_restriction']).to eq(0)
+ expect(json_response['circuitbreaker_failure_count_threshold']).not_to be_nil
end
end
@@ -52,7 +53,8 @@ describe API::Settings, 'Settings' do
rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE,
dsa_key_restriction: 2048,
ecdsa_key_restriction: 384,
- ed25519_key_restriction: 256
+ ed25519_key_restriction: 256,
+ circuitbreaker_failure_wait_time: 2
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
@@ -73,6 +75,7 @@ describe API::Settings, 'Settings' do
expect(json_response['dsa_key_restriction']).to eq(2048)
expect(json_response['ecdsa_key_restriction']).to eq(384)
expect(json_response['ed25519_key_restriction']).to eq(256)
+ expect(json_response['circuitbreaker_failure_wait_time']).to eq(2)
end
end
diff --git a/spec/requests/api/v3/repositories_spec.rb b/spec/requests/api/v3/repositories_spec.rb
index 1a55e2a71cd..67624a0bbea 100644
--- a/spec/requests/api/v3/repositories_spec.rb
+++ b/spec/requests/api/v3/repositories_spec.rb
@@ -97,10 +97,11 @@ describe API::V3::Repositories do
end
end
- {
- 'blobs/:sha' => 'blobs/master',
- 'commits/:sha/blob' => 'commits/master/blob'
- }.each do |desc_path, example_path|
+ [
+ ['blobs/:sha', 'blobs/master'],
+ ['blobs/:sha', 'blobs/v1.1.0'],
+ ['commits/:sha/blob', 'commits/master/blob']
+ ].each do |desc_path, example_path|
describe "GET /projects/:id/repository/#{desc_path}" do
let(:route) { "/projects/#{project.id}/repository/#{example_path}?filepath=README.md" }
shared_examples_for 'repository blob' do
@@ -110,7 +111,7 @@ describe API::V3::Repositories do
end
context 'when sha does not exist' do
it_behaves_like '404 response' do
- let(:request) { get v3_api(route.sub('master', 'invalid_branch_name'), current_user) }
+ let(:request) { get v3_api("/projects/#{project.id}/repository/#{desc_path.sub(':sha', 'invalid_branch_name')}?filepath=README.md", current_user) }
let(:message) { '404 Commit Not Found' }
end
end
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
new file mode 100644
index 00000000000..452754d7a79
--- /dev/null
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe GroupChildEntity do
+ include Gitlab::Routing.url_helpers
+
+ let(:user) { create(:user) }
+ let(:request) { double('request') }
+ let(:entity) { described_class.new(object, request: request) }
+ subject(:json) { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ shared_examples 'group child json' do
+ it 'renders json' do
+ is_expected.not_to be_nil
+ end
+
+ %w[id
+ full_name
+ avatar_url
+ name
+ description
+ visibility
+ type
+ can_edit
+ visibility
+ permission
+ relative_path].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+ end
+
+ describe 'for a project' do
+ let(:object) do
+ create(:project, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_master(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('project')
+ end
+
+ it 'includes the star count' do
+ expect(json[:star_count]).to be_present
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_project_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+
+ describe 'for a group', :nested_groups do
+ let(:object) do
+ create(:group, :nested, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_owner(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('group')
+ end
+
+ it 'counts projects and subgroups as children' do
+ create(:project, namespace: object)
+ create(:group, parent: object)
+
+ expect(json[:children_count]).to eq(2)
+ end
+
+ %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+
+ it 'allows an owner to leave when there is another one' do
+ object.add_owner(create(:user))
+
+ expect(json[:can_leave]).to be_truthy
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_group_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+end
diff --git a/spec/serializers/group_child_serializer_spec.rb b/spec/serializers/group_child_serializer_spec.rb
new file mode 100644
index 00000000000..5541ada3750
--- /dev/null
+++ b/spec/serializers/group_child_serializer_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe GroupChildSerializer do
+ let(:request) { double('request') }
+ let(:user) { create(:user) }
+ subject(:serializer) { described_class.new(current_user: user) }
+
+ describe '#represent' do
+ context 'for groups' do
+ it 'can render a single group' do
+ expect(serializer.represent(build(:group))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of groups' do
+ expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array)
+ end
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'expands the subgroups' do
+ subgroup = create(:group, parent: parent)
+ subsub_group = create(:group, parent: subgroup)
+
+ json = serializer.represent([subgroup, subsub_group]).first
+ subsub_group_json = json[:children].first
+
+ expect(json[:id]).to eq(subgroup.id)
+ expect(subsub_group_json).not_to be_nil
+ expect(subsub_group_json[:id]).to eq(subsub_group.id)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ subsub_group1 = create(:group, parent: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+
+ json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2])
+ subgroup1_json = json.first
+ subsub_group1_json = subgroup1_json[:children].first
+
+ expect(json.size).to eq(2)
+ expect(subgroup1_json[:id]).to eq(subgroup1.id)
+ expect(subsub_group1_json[:id]).to eq(subsub_group1.id)
+ end
+
+ context 'without a specified parent' do
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy
+ end
+
+ it 'can render a tree' do
+ subgroup = create(:group, parent: parent)
+
+ json = serializer.represent([parent, subgroup])
+ parent_json = json.first
+
+ expect(parent_json[:id]).to eq(parent.id)
+ expect(parent_json[:children].first[:id]).to eq(subgroup.id)
+ end
+ end
+ end
+
+ context 'for projects' do
+ it 'can render a single project' do
+ expect(serializer.represent(build(:project))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of projects' do
+ expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array)
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ project1 = create(:project, namespace: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+ project2 = create(:project, namespace: subgroup2)
+
+ json = serializer.represent([project1, project2, subgroup1, subgroup2])
+ project1_json, project2_json = json.map { |group_json| group_json[:children].first }
+
+ expect(json.size).to eq(2)
+ expect(project1_json[:id]).to eq(project1.id)
+ expect(project2_json[:id]).to eq(project2.id)
+ end
+
+ it 'returns an array when an array of a single instance was given' do
+ project = create(:project, namespace: parent)
+
+ json = serializer.represent([project])
+
+ expect(json).to be_kind_of(Array)
+ expect(json.size).to eq(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 9db3568abee..b61d1cb765e 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -160,8 +160,9 @@ describe Ci::RetryBuildService do
expect(new_build).to be_created
end
- it 'does mark old build as retried' do
+ it 'does mark old build as retried in the database and on the instance' do
expect(new_build).to be_latest
+ expect(build).to be_retried
expect(build.reload).to be_retried
end
end
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
index 23982b9e6e1..0b32c51a16f 100644
--- a/spec/services/merge_requests/conflicts/list_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -35,7 +35,7 @@ describe MergeRequests::Conflicts::ListService do
it 'returns a falsey value when the MR has a missing ref after a force push' do
merge_request = create_merge_request('conflict-resolvable')
service = conflicts_service(merge_request)
- allow(service.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError)
+ allow_any_instance_of(Rugged::Repository).to receive(:merge_commits).and_raise(Rugged::OdbError)
expect(service.can_be_resolved_in_ui?).to be_falsey
end
diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
index a1f7dc44d31..5376083e7f5 100644
--- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
@@ -107,25 +107,27 @@ describe MergeRequests::Conflicts::ResolveService do
branch_name: 'conflict-start')
end
- def resolve_conflicts
+ subject do
described_class.new(merge_request_from_fork).execute(user, params)
end
it 'gets conflicts from the source project' do
+ # REFACTOR NOTE: We used to test that `project.repository.rugged` wasn't
+ # used in this case, but since the refactor, for simplification,
+ # we always use that repository for read only operations.
expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original
- expect(project.repository.rugged).not_to receive(:merge_commits)
- resolve_conflicts
+ subject
end
it 'creates a commit with the message' do
- resolve_conflicts
+ subject
expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message])
end
it 'creates a commit with the correct parents' do
- resolve_conflicts
+ subject
expect(merge_request_from_fork.source_branch_head.parents.map(&:id))
.to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head])
@@ -200,14 +202,19 @@ describe MergeRequests::Conflicts::ResolveService do
}
end
- it 'raises a MissingResolution error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
context 'when the content of a file is unchanged' do
- let(:list_service) { MergeRequests::Conflicts::ListService.new(merge_request) }
+ let(:resolver) do
+ MergeRequests::Conflicts::ListService.new(merge_request).conflicts.resolver
+ end
+ let(:regex_conflict) do
+ resolver.conflict_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb')
+ end
let(:invalid_params) do
{
@@ -219,16 +226,16 @@ describe MergeRequests::Conflicts::ResolveService do
}, {
old_path: 'files/ruby/regex.rb',
new_path: 'files/ruby/regex.rb',
- content: list_service.conflicts.file_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb').content
+ content: regex_conflict.content
}
],
commit_message: 'This is a commit message!'
}
end
- it 'raises a MissingResolution error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(Gitlab::Conflict::File::MissingResolution)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
@@ -246,9 +253,9 @@ describe MergeRequests::Conflicts::ResolveService do
}
end
- it 'raises a MissingFiles error' do
+ it 'raises a ResolutionError error' do
expect { service.execute(user, invalid_params) }
- .to raise_error(described_class::MissingFiles)
+ .to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
end
end
end
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 031366d1825..d4ac1f6ad81 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -52,6 +52,11 @@ describe Projects::UpdatePagesService do
expect(project.pages_deployed?).to be_falsey
expect(execute).to eq(:success)
expect(project.pages_deployed?).to be_truthy
+
+ # Check that all expected files are extracted
+ %w[index.html zero .hidden/file].each do |filename|
+ expect(File.exist?(File.join(project.public_pages_path, filename))).to be_truthy
+ end
end
it 'limits pages size' do
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 2dfb4d4a07f..4d448a55978 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -43,10 +43,6 @@ module StubConfiguration
messages['default'] ||= Gitlab.config.repositories.storages.default
messages.each do |storage_name, storage_settings|
storage_settings['path'] = TestEnv.repos_path unless storage_settings.key?('path')
- storage_settings['failure_count_threshold'] ||= 10
- storage_settings['failure_wait_time'] ||= 30
- storage_settings['failure_reset_time'] ||= 1800
- storage_settings['storage_timeout'] ||= 5
end
allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
diff --git a/yarn.lock b/yarn.lock
index 64b27c0556f..b830278eab0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4148,9 +4148,9 @@ moment@2.x:
version "2.17.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82"
-monaco-editor@0.8.3:
- version "0.8.3"
- resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6"
+monaco-editor@0.10.0:
+ version "0.10.0"
+ resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.10.0.tgz#6604932585fe9c1f993f000a503d0d20fbe5896a"
mousetrap@^1.4.6:
version "1.4.6"