Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-02 18:08:24 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-02 18:08:24 +0300
commiteea1fbf9f980fed108601412b63e627d3eebd46d (patch)
tree11df1e2bab7dcf8c0127890a5b5f6767c5c7b786
parent810bd2a662abaa60663ec19bcb55f883d329eb07 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/jobs/components/stuck_block.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue252
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue114
-rw-r--r--app/finders/issuable_finder/params.rb10
-rw-r--r--app/graphql/mutations/alert_management/base.rb2
-rw-r--r--app/graphql/mutations/branches/create.rb2
-rw-r--r--app/graphql/mutations/commits/create.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_issuable.rb7
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_project.rb15
-rw-r--r--app/graphql/mutations/jira_import/start.rb2
-rw-r--r--app/graphql/mutations/merge_requests/create.rb2
-rw-r--r--app/graphql/mutations/snippets/create.rb2
-rw-r--r--app/graphql/resolvers/assigned_merge_requests_resolver.rb9
-rw-r--r--app/graphql/resolvers/authored_merge_requests_resolver.rb9
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb4
-rw-r--r--app/graphql/resolvers/concerns/resolves_project.rb15
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb7
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb6
-rw-r--r--app/graphql/resolvers/user_merge_requests_resolver.rb68
-rw-r--r--app/graphql/resolvers/user_resolver.rb27
-rw-r--r--app/graphql/types/query_type.rb4
-rw-r--r--app/graphql/types/user_state_enum.rb12
-rw-r--r--app/graphql/types/user_type.rb16
-rw-r--r--app/models/issue.rb16
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb4
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb4
-rw-r--r--app/views/projects/environments/metrics.html.haml2
-rw-r--r--changelogs/unreleased/214281-modify-dashboard-title.yml5
-rw-r--r--changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml5
-rw-r--r--changelogs/unreleased/ajk-GQL-user-mrs.yml5
-rw-r--r--changelogs/unreleased/calebw-update-stuck-runner-message.yml5
-rw-r--r--danger/commit_messages/Dangerfile16
-rw-r--r--doc/administration/reference_architectures/25k_users.md2
-rw-r--r--doc/administration/reference_architectures/2k_users.md2
-rw-r--r--doc/administration/reference_architectures/50k_users.md2
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql146
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json347
-rw-r--r--doc/api/graphql/reference/index.md2
-rw-r--r--doc/development/fe_guide/graphql.md8
-rw-r--r--doc/development/i18n/proofreader.md2
-rw-r--r--doc/user/admin_area/license.md2
-rw-r--r--doc/user/project/operations/alert_management.md3
-rw-r--r--lib/gitlab/graphql/loaders/full_path_model_loader.rb26
-rw-r--r--locale/gitlab.pot26
-rw-r--r--spec/controllers/concerns/metrics_dashboard_spec.rb2
-rw-r--r--spec/fast_spec_helper.rb1
-rw-r--r--spec/features/projects/jobs_spec.rb4
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js56
-rw-r--r--spec/frontend/monitoring/store_utils.js11
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js259
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js64
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js150
-rw-r--r--spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb22
-rw-r--r--spec/graphql/resolvers/concerns/resolves_project_spec.rb37
-rw-r--r--spec/graphql/resolvers/user_resolver_spec.rb2
-rw-r--r--spec/graphql/types/user_type_spec.rb1
-rw-r--r--spec/lib/gitlab/metrics/dashboard/finder_spec.rb4
-rw-r--r--spec/models/issue_spec.rb29
-rw-r--r--spec/requests/api/graphql/user_query_spec.rb260
-rw-r--r--spec/support/helpers/graphql_helpers.rb10
-rw-r--r--spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb10
-rw-r--r--spec/tooling/lib/tooling/test_file_finder_spec.rb4
64 files changed, 2033 insertions, 133 deletions
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue
index ac1e5b31802..da01269a50c 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/stuck_block.vue
@@ -29,7 +29,7 @@ export default {
<p v-if="tags.length" class="js-stuck-with-tags gl-mb-0">
{{
s__(`This job is stuck because you don't have
- any active runners online with any of these tags assigned to them:`)
+ any active runners online or available with any of these tags assigned to them:`)
}}
<span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4">
{{ tag }}
@@ -48,9 +48,9 @@ export default {
}}
</p>
- {{ __('Go to') }}
+ {{ __('Go to project') }}
<gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path">
- {{ __('Runners page') }}
+ {{ __('CI settings') }}
</gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 6bd72a93f1c..0ca7ec24a12 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -147,6 +147,7 @@ export default {
return {
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
isRearrangingPanels: false,
+ originalDocumentTitle: document.title,
};
},
computed: {
@@ -192,6 +193,9 @@ export default {
},
deep: true,
},
+ selectedDashboard(dashboard) {
+ this.prependToDocumentTitle(dashboard?.display_name);
+ },
},
created() {
window.addEventListener('keyup', this.onKeyup);
@@ -258,6 +262,11 @@ export default {
// Collapse group if no data is available
return !this.getMetricStates(groupKey).includes(metricStates.OK);
},
+ prependToDocumentTitle(text) {
+ if (text) {
+ document.title = `${text} · ${this.originalDocumentTitle}`;
+ }
+ },
onTimeRangeZoom({ start, end }) {
updateHistory({
url: mergeUrlParams({ start, end }, window.location.href),
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
new file mode 100644
index 00000000000..6665a5754b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -0,0 +1,8 @@
+export const ANY_AUTHOR = 'Any';
+
+export const DEBOUNCE_DELAY = 200;
+
+export const SortDirection = {
+ descending: 'descending',
+ ascending: 'ascending',
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
new file mode 100644
index 00000000000..14252c50d03
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -0,0 +1,252 @@
+<script>
+import {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown as GlDropdown,
+ GlNewDropdownItem as GlDropdownItem,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+
+import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
+
+import { SortDirection } from './constants';
+
+export default {
+ components: {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ namespace: {
+ type: String,
+ required: true,
+ },
+ recentSearchesStorageKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ sortOptions: {
+ type: Array,
+ required: true,
+ },
+ initialFilterValue: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ initialSortBy: {
+ type: String,
+ required: false,
+ default: '',
+ validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
+ },
+ searchInputPlaceholder: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ let selectedSortOption = this.sortOptions[0].sortDirection.descending;
+ let selectedSortDirection = SortDirection.descending;
+
+ // Extract correct sortBy value based on initialSortBy
+ if (this.initialSortBy) {
+ selectedSortOption = this.sortOptions
+ .filter(
+ sortBy =>
+ sortBy.sortDirection.ascending === this.initialSortBy ||
+ sortBy.sortDirection.descending === this.initialSortBy,
+ )
+ .pop();
+ selectedSortDirection = this.initialSortBy.endsWith('_desc')
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ }
+
+ return {
+ initialRender: true,
+ recentSearchesPromise: null,
+ filterValue: this.initialFilterValue,
+ selectedSortOption,
+ selectedSortDirection,
+ };
+ },
+ computed: {
+ tokenSymbols() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.symbol,
+ }),
+ {},
+ );
+ },
+ sortDirectionIcon() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? 'sort-lowest'
+ : 'sort-highest';
+ },
+ sortDirectionTooltip() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? __('Sort direction: Ascending')
+ : __('Sort direction: Descending');
+ },
+ },
+ watch: {
+ /**
+ * GlFilteredSearch currently doesn't emit any event when
+ * search field is cleared, but we still want our parent
+ * component to know that filters were cleared and do
+ * necessary data refetch, so this watcher is basically
+ * a dirty hack/workaround to identify if filter input
+ * was cleared. :(
+ */
+ filterValue(value) {
+ const [firstVal] = value;
+ if (
+ !this.initialRender &&
+ value.length === 1 &&
+ firstVal.type === 'filtered-search-term' &&
+ !firstVal.value.data
+ ) {
+ this.$emit('onFilter', []);
+ }
+
+ // Set initial render flag to false
+ // as we don't want to emit event
+ // on initial load when value is empty already.
+ this.initialRender = false;
+ },
+ },
+ created() {
+ if (this.recentSearchesStorageKey) this.setupRecentSearch();
+ },
+ methods: {
+ /**
+ * Initialize service and store instances for
+ * getting Recent Search functional.
+ */
+ setupRecentSearch() {
+ this.recentSearchesService = new RecentSearchesService(
+ `${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`,
+ );
+
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.tokens.map(token => token.type),
+ });
+
+ this.recentSearchesPromise = this.recentSearchesService
+ .fetch()
+ .catch(error => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
+
+ createFlash(__('An error occurred while parsing recent searches'));
+
+ // Gracefully fail to empty array
+ return [];
+ })
+ .then(searches => {
+ if (!searches) return;
+
+ // Put any searches that may have come in before
+ // we fetched the saved searches ahead of the already saved ones
+ const resultantSearches = this.recentSearchesStore.setRecentSearches(
+ this.recentSearchesStore.state.recentSearches.concat(searches),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ });
+ },
+ getRecentSearches() {
+ return this.recentSearchesStore?.state.recentSearches;
+ },
+ handleSortOptionClick(sortBy) {
+ this.selectedSortOption = sortBy;
+ this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
+ },
+ handleSortDirectionClick() {
+ this.selectedSortDirection =
+ this.selectedSortDirection === SortDirection.ascending
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
+ },
+ handleFilterSubmit(filters) {
+ if (this.recentSearchesStorageKey) {
+ this.recentSearchesPromise
+ .then(() => {
+ if (filters.length) {
+ const searchTokens = filters.map(filter => {
+ // check filter was plain text search
+ if (typeof filter === 'string') {
+ return filter;
+ }
+ // filter was a token.
+ return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
+ filter.value.data
+ }`;
+ });
+
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(
+ searchTokens.join(' '),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ }
+ })
+ .catch(() => {
+ // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
+ });
+ }
+ this.$emit('onFilter', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="vue-filtered-search-bar-container d-flex">
+ <gl-filtered-search
+ v-model="filterValue"
+ :placeholder="searchInputPlaceholder"
+ :available-tokens="tokens"
+ :history-items="getRecentSearches()"
+ class="flex-grow-1"
+ @submit="handleFilterSubmit"
+ />
+ <gl-button-group class="ml-2">
+ <gl-dropdown :text="selectedSortOption.title" :right="true">
+ <gl-dropdown-item
+ v-for="sortBy in sortOptions"
+ :key="sortBy.id"
+ :is-check-item="true"
+ :is-checked="sortBy.id === selectedSortOption.id"
+ @click="handleSortOptionClick(sortBy)"
+ >{{ sortBy.title }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ <gl-button
+ v-gl-tooltip
+ :title="sortDirectionTooltip"
+ :icon="sortDirectionIcon"
+ @click="handleSortDirectionClick"
+ />
+ </gl-button-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
new file mode 100644
index 00000000000..412bfa5aa7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -0,0 +1,114 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants';
+
+export default {
+ anyAuthor: ANY_AUTHOR,
+ components: {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ authors: this.config.initialAuthors || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeAuthor() {
+ return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
+ },
+ },
+ methods: {
+ fetchAuthorBySearchTerm(searchTerm) {
+ const fetchPromise = this.config.fetchPath
+ ? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
+ : this.config.fetchAuthors(searchTerm);
+
+ fetchPromise
+ .then(res => {
+ // We'd want to avoid doing this check but
+ // users.json and /groups/:id/members & /projects/:id/users
+ // return response differently.
+ this.authors = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching users.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchAuthors: debounce(function debouncedSearch({ data }) {
+ this.fetchAuthorBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchAuthors"
+ >
+ <template #view="{ inputValue }">
+ <gl-avatar
+ v-if="activeAuthor"
+ :size="16"
+ :src="activeAuthor.avatar_url"
+ shape="circle"
+ class="gl-mr-2"
+ />
+ <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
+ __('Any')
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="author in authors"
+ :key="author.username"
+ :value="author.username"
+ >
+ <div class="d-flex">
+ <gl-avatar :size="32" :src="author.avatar_url" />
+ <div>
+ <div>{{ author.name }}</div>
+ <div>@{{ author.username }}</div>
+ </div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index adf9f1ca9d8..5b48d0817e3 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -105,7 +105,7 @@ class IssuableFinder
end
def project?
- params[:project_id].present?
+ project_id.present?
end
def group
@@ -132,15 +132,19 @@ class IssuableFinder
def project
strong_memoize(:project) do
- next nil unless params[:project_id].present?
+ next nil unless project?
- project = Project.find(params[:project_id])
+ project = project_id.is_a?(Project) ? project_id : Project.find(project_id)
project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
project
end
end
+ def project_id
+ params[:project_id]
+ end
+
def projects
strong_memoize(:projects) do
next [project] if project?
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index ca2057d4845..a2942968b70 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -3,7 +3,7 @@
module Mutations
module AlertManagement
class Base < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb
index 127d5447d0a..214fead2e80 100644
--- a/app/graphql/mutations/branches/create.rb
+++ b/app/graphql/mutations/branches/create.rb
@@ -3,7 +3,7 @@
module Mutations
module Branches
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'CreateBranch'
diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb
index d91ea3a06e0..9ed1bb819c8 100644
--- a/app/graphql/mutations/commits/create.rb
+++ b/app/graphql/mutations/commits/create.rb
@@ -3,7 +3,7 @@
module Mutations
module Commits
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'CommitCreate'
diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
index f3ed3565b03..13a56f2e709 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
@@ -3,7 +3,10 @@
module Mutations
module ResolvesIssuable
extend ActiveSupport::Concern
- include Mutations::ResolvesProject
+
+ included do
+ include ResolvesProject
+ end
def resolve_issuable(type:, parent_path:, iid:)
parent = resolve_issuable_parent(type, parent_path)
@@ -29,7 +32,7 @@ module Mutations
def resolve_issuable_parent(type, parent_path)
return unless type == :issue || type == :merge_request
- resolve_project(full_path: parent_path)
+ resolve_project(full_path: parent_path) if parent_path.present?
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_project.rb b/app/graphql/mutations/concerns/mutations/resolves_project.rb
deleted file mode 100644
index e223e3edd94..00000000000
--- a/app/graphql/mutations/concerns/mutations/resolves_project.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module ResolvesProject
- extend ActiveSupport::Concern
-
- def resolve_project(full_path:)
- project_resolver.resolve(full_path: full_path)
- end
-
- def project_resolver
- Resolvers::ProjectResolver.new(object: nil, context: context, field: nil)
- end
- end
-end
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index 6b80c9f8ca4..6c75fe42b0b 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -3,7 +3,7 @@
module Mutations
module JiraImport
class Start < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'JiraImportStart'
diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb
index 95d6fb100e7..e210987f259 100644
--- a/app/graphql/mutations/merge_requests/create.rb
+++ b/app/graphql/mutations/merge_requests/create.rb
@@ -3,7 +3,7 @@
module Mutations
module MergeRequests
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'MergeRequestCreate'
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index b5ee38857fb..e1022358c09 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -3,7 +3,7 @@
module Mutations
module Snippets
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'CreateSnippet'
diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
new file mode 100644
index 00000000000..fa08b142a7e
--- /dev/null
+++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AssignedMergeRequestsResolver < UserMergeRequestsResolver
+ def user_role
+ :assignee
+ end
+ end
+end
diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb
new file mode 100644
index 00000000000..e19bc9e8715
--- /dev/null
+++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
+ def user_role
+ :author
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index 779ff0b50d4..0b834b3d558 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -13,10 +13,10 @@ module ResolvesMergeRequests
args[:iids] = Array.wrap(args[:iids]) if args[:iids]
args.compact!
- if args.keys == [:iids]
+ if project && args.keys == [:iids]
batch_load_merge_requests(args[:iids])
else
- args[:project_id] = project.id
+ args[:project_id] ||= project
MergeRequestsFinder.new(current_user, args).execute
end.then(&(single? ? :first : :itself))
diff --git a/app/graphql/resolvers/concerns/resolves_project.rb b/app/graphql/resolvers/concerns/resolves_project.rb
new file mode 100644
index 00000000000..3c5ce3dab01
--- /dev/null
+++ b/app/graphql/resolvers/concerns/resolves_project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module ResolvesProject
+ def resolve_project(full_path: nil, project_id: nil)
+ unless full_path.present? ^ project_id.present?
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: projectId, projectPath.'
+ end
+
+ if full_path.present?
+ ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(Project, full_path).find
+ else
+ ::GitlabSchema.object_from_id(project_id, expected_type: Project)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index 46d3360baae..cbb0bf998a6 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -11,12 +11,7 @@ module Resolvers
end
def model_by_full_path(model, full_path)
- BatchLoader::GraphQL.for(full_path).batch(key: model) do |full_paths, loader, args|
- # `with_route` avoids an N+1 calculating full_path
- args[:key].where_full_path_in(full_paths).with_route.each do |model_instance|
- loader.call(model_instance.full_path, model_instance)
- end
- end
+ ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find
end
end
end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 44fc4e17cd4..3aa52341eec 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -34,7 +34,11 @@ module Resolvers
end
def no_results_possible?(args)
- project.nil? || args.values.any? { |v| v.is_a?(Array) && v.empty? }
+ project.nil? || some_argument_is_empty?(args)
+ end
+
+ def some_argument_is_empty?(args)
+ args.values.any? { |v| v.is_a?(Array) && v.empty? }
end
end
end
diff --git a/app/graphql/resolvers/user_merge_requests_resolver.rb b/app/graphql/resolvers/user_merge_requests_resolver.rb
new file mode 100644
index 00000000000..b0d6e159f73
--- /dev/null
+++ b/app/graphql/resolvers/user_merge_requests_resolver.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UserMergeRequestsResolver < MergeRequestsResolver
+ include ResolvesProject
+
+ argument :project_path, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.'
+
+ argument :project_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.'
+
+ attr_reader :project
+ alias_method :user, :synchronized_object
+
+ def ready?(project_id: nil, project_path: nil, **args)
+ return early_return unless can_read_profile?
+
+ if project_id || project_path
+ load_project(project_path, project_id)
+ return early_return unless can_read_project?
+ elsif args[:iids].present?
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ 'iids requires projectPath or projectId'
+ end
+
+ super(**args)
+ end
+
+ def resolve(**args)
+ prepare_args(args)
+ key = :"#{user_role}_id"
+ super(key => user.id, **args)
+ end
+
+ def user_role
+ raise NotImplementedError
+ end
+
+ private
+
+ def can_read_profile?
+ Ability.allowed?(current_user, :read_user_profile, user)
+ end
+
+ def can_read_project?
+ Ability.allowed?(current_user, :read_merge_request, project)
+ end
+
+ def load_project(project_path, project_id)
+ @project = resolve_project(full_path: project_path, project_id: project_id)
+ @project = @project.sync if @project.respond_to?(:sync)
+ end
+
+ def no_results_possible?(args)
+ some_argument_is_empty?(args)
+ end
+
+ # These arguments are handled in load_project, and should not be passed to
+ # the finder directly.
+ def prepare_args(args)
+ args.delete(:project_id)
+ args.delete(:project_path)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/user_resolver.rb b/app/graphql/resolvers/user_resolver.rb
index 9696988f3b4..a34cecba491 100644
--- a/app/graphql/resolvers/user_resolver.rb
+++ b/app/graphql/resolvers/user_resolver.rb
@@ -4,6 +4,8 @@ module Resolvers
class UserResolver < BaseResolver
description 'Retrieve a single user'
+ type Types::UserType, null: true
+
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'ID of the User'
@@ -12,13 +14,6 @@ module Resolvers
required: false,
description: 'Username of the User'
- def resolve(id: nil, username: nil)
- id_or_username = GitlabSchema.parse_gid(id, expected_type: ::User).model_id if id
- id_or_username ||= username
-
- ::UserFinder.new(id_or_username).find_by_id_or_username
- end
-
def ready?(id: nil, username: nil)
unless id.present? ^ username.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id'
@@ -26,5 +21,23 @@ module Resolvers
super
end
+
+ def resolve(id: nil, username: nil)
+ if id
+ GitlabSchema.object_from_id(id, expected_type: User)
+ else
+ batch_load(username)
+ end
+ end
+
+ private
+
+ def batch_load(username)
+ BatchLoader::GraphQL.for(username).batch do |usernames, loader|
+ User.by_username(usernames).each do |user|
+ loader.call(user.username, user)
+ end
+ end
+ end
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index bedb68d989b..5184d17e94e 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -56,6 +56,10 @@ module Types
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
+ field :user, Types::UserType, null: true,
+ description: 'Find a user on this instance',
+ resolver: Resolvers::UserResolver
+
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb
new file mode 100644
index 00000000000..d34936b4c48
--- /dev/null
+++ b/app/graphql/types/user_state_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class UserStateEnum < BaseEnum
+ graphql_name 'UserState'
+ description 'Possible states of a user'
+
+ value 'active', 'The user is active and is able to use the system', value: 'active'
+ value 'blocked', 'The user has been blocked and is prevented from using the system', value: 'blocked'
+ value 'deactivated', 'The user is no longer active and is unable to use the system', value: 'deactivated'
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 29a3f5d452f..04c125592d8 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -12,12 +12,12 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user'
- field :name, GraphQL::STRING_TYPE, null: false,
- description: 'Human-readable name of the user'
- field :state, GraphQL::STRING_TYPE, null: false,
- description: 'State of the issue'
field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab'
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Human-readable name of the user'
+ field :state, Types::UserStateEnum, null: false,
+ description: 'State of the user'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false,
@@ -26,6 +26,14 @@ module Types
resolver: Resolvers::TodoResolver,
description: 'Todos of the user'
+ # Merge request field: MRs can be either authored or assigned:
+ field :authored_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ resolver: Resolvers::AuthoredMergeRequestsResolver,
+ description: 'Merge Requests authored by the user'
+ field :assigned_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ resolver: Resolvers::AssignedMergeRequestsResolver,
+ description: 'Merge Requests assigned to the user'
+
field :snippets,
Types::SnippetType.connection_type,
null: true,
diff --git a/app/models/issue.rb b/app/models/issue.rb
index f978a1e43c1..9acd51ac9f2 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -139,6 +139,10 @@ class Issue < ApplicationRecord
issue.closed_at = nil
issue.closed_by = nil
end
+
+ after_transition any => :closed do |issue|
+ issue.resolve_associated_alert_management_alert
+ end
end
# Alias to state machine .with_state_id method
@@ -352,6 +356,18 @@ class Issue < ApplicationRecord
@design_collection ||= ::DesignManagement::DesignCollection.new(self)
end
+ def resolve_associated_alert_management_alert
+ return unless alert_management_alert
+ return if alert_management_alert.resolve
+
+ Gitlab::AppLogger.warn(
+ message: 'Cannot resolve an associated Alert Management alert',
+ issue_id: id,
+ alert_id: alert_management_alert.id,
+ alert_errors: alert_management_alert.errors.messages
+ )
+ end
+
private
def ensure_metrics
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
index d97668d1c7c..8599c23c206 100644
--- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
+++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
@@ -6,7 +6,7 @@ module Metrics
module Dashboard
class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
- DASHBOARD_NAME = 'Default'
+ DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [
STAGES::CustomMetricsInserter,
@@ -23,7 +23,7 @@ module Metrics
def all_dashboard_paths(_project)
[{
path: DASHBOARD_PATH,
- display_name: DASHBOARD_NAME,
+ display_name: _(DASHBOARD_NAME),
default: true,
system_dashboard: false
}]
diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb
index ed4b78ba159..db5599b4def 100644
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ b/app/services/metrics/dashboard/system_dashboard_service.rb
@@ -6,7 +6,7 @@ module Metrics
module Dashboard
class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
- DASHBOARD_NAME = 'Default'
+ DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [
STAGES::CommonMetricsInserter,
@@ -22,7 +22,7 @@ module Metrics
def all_dashboard_paths(_project)
[{
path: DASHBOARD_PATH,
- display_name: DASHBOARD_NAME,
+ display_name: _(DASHBOARD_NAME),
default: true,
system_dashboard: true
}]
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index aab30af5ed4..cd7339edd1a 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,4 +1,4 @@
-- page_title _("Metrics for environment"), @environment.name
+- page_title _("Metrics Dashboard"), @environment.name
.prometheus-container
#prometheus-graphs{ data: metrics_data(@project, @environment) }
diff --git a/changelogs/unreleased/214281-modify-dashboard-title.yml b/changelogs/unreleased/214281-modify-dashboard-title.yml
new file mode 100644
index 00000000000..19919dd664b
--- /dev/null
+++ b/changelogs/unreleased/214281-modify-dashboard-title.yml
@@ -0,0 +1,5 @@
+---
+title: Add metrics dashboard name to document title
+merge_request: 30392
+author:
+type: added
diff --git a/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml b/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml
new file mode 100644
index 00000000000..834fef6f01b
--- /dev/null
+++ b/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml
@@ -0,0 +1,5 @@
+---
+title: Automatically resolve alert when associated issue closes
+merge_request: 33278
+author:
+type: added
diff --git a/changelogs/unreleased/ajk-GQL-user-mrs.yml b/changelogs/unreleased/ajk-GQL-user-mrs.yml
new file mode 100644
index 00000000000..103ae1d33dd
--- /dev/null
+++ b/changelogs/unreleased/ajk-GQL-user-mrs.yml
@@ -0,0 +1,5 @@
+---
+title: Add GraphQL support for authored and assigned Merge Requests
+merge_request: 31227
+author:
+type: added
diff --git a/changelogs/unreleased/calebw-update-stuck-runner-message.yml b/changelogs/unreleased/calebw-update-stuck-runner-message.yml
new file mode 100644
index 00000000000..57c48377ae5
--- /dev/null
+++ b/changelogs/unreleased/calebw-update-stuck-runner-message.yml
@@ -0,0 +1,5 @@
+---
+title: Clarify verbiage for stuck job messages.
+merge_request: 32250
+author:
+type: other
diff --git a/danger/commit_messages/Dangerfile b/danger/commit_messages/Dangerfile
index 59d42082de9..174fc10eef3 100644
--- a/danger/commit_messages/Dangerfile
+++ b/danger/commit_messages/Dangerfile
@@ -6,6 +6,16 @@ COMMIT_MESSAGE_GUIDELINES = "https://docs.gitlab.com/ee/development/contributing
MORE_INFO = "For more information, take a look at our [Commit message guidelines](#{COMMIT_MESSAGE_GUIDELINES})."
THE_DANGER_JOB_TEXT = "the `danger-review` job"
MAX_COMMITS_COUNT = 10
+MAX_COMMITS_COUNT_EXCEEDED_MESSAGE = <<~MSG
+This merge request includes more than %<max_commits_count>d commits. Each commit should meet the following criteria:
+
+1. Have a well-written commit message.
+1. Has all tests passing when used on its own (e.g. when using git checkout SHA).
+1. Can be reverted on its own without also requiring the revert of commit that came before it.
+1. Is small enough that it can be reviewed in isolation in under 30 minutes or so.
+
+If this merge request contains commits that do not meet this criteria and/or contains intermediate work, please rebase these commits into a smaller number of commits or split this merge request into multiple smaller merge requests.
+MSG
def gitlab_danger
@gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper)
@@ -94,11 +104,7 @@ def lint_commits(commits)
warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?)
if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT
- level = squash_mr? ? :warn : :fail
- self.__send__(level, # rubocop:disable GitlabSecurity/PublicSend
- "This merge request includes more than #{MAX_COMMITS_COUNT} commits. " \
- 'Please rebase these commits into a smaller number of commits or split ' \
- 'this merge request into multiple smaller merge requests.')
+ self.warn(format(MAX_COMMITS_COUNT_EXCEEDED_MESSAGE, max_commits_count: MAX_COMMITS_COUNT))
end
if squash_mr?
diff --git a/doc/administration/reference_architectures/25k_users.md b/doc/administration/reference_architectures/25k_users.md
index d0eb3fe8db1..c81445434be 100644
--- a/doc/administration/reference_architectures/25k_users.md
+++ b/doc/administration/reference_architectures/25k_users.md
@@ -23,7 +23,7 @@ For a full list of reference architectures, see
| Object Storage ([4](#footnotes)) | - | - | - | - | - |
| NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| External load balancing node ([6](#footnotes)) | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 |
+| External load balancing node ([6](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Internal load balancing node ([6](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
## Footnotes
diff --git a/doc/administration/reference_architectures/2k_users.md b/doc/administration/reference_architectures/2k_users.md
index accde72931c..5cf217d5816 100644
--- a/doc/administration/reference_architectures/2k_users.md
+++ b/doc/administration/reference_architectures/2k_users.md
@@ -16,7 +16,7 @@ For a full list of reference architectures, see
| PostgreSQL | 1 | 2 vCPU, 7.5GB Memory | n1-standard-2 | m5.large | D2s v3 |
| Redis ([3](#footnotes)) | 1 | 1 vCPU, 3.75GB Memory | n1-standard-1 | m5.large | D2s v3 |
| Gitaly ([5](#footnotes)) ([7](#footnotes)) | X ([2](#footnotes)) | 4 vCPU, 15GB Memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| GitLab Rails ([1](#footnotes)) | 2 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| GitLab Rails ([1](#footnotes)), Sidekiq | 2 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 |
## Setup instructions
diff --git a/doc/administration/reference_architectures/50k_users.md b/doc/administration/reference_architectures/50k_users.md
index 92153499109..47c5b3e2bce 100644
--- a/doc/administration/reference_architectures/50k_users.md
+++ b/doc/administration/reference_architectures/50k_users.md
@@ -23,7 +23,7 @@ For a full list of reference architectures, see
| NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object Storage ([4](#footnotes)) | - | - | - | - | - |
| Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| External load balancing node ([6](#footnotes)) | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 |
+| External load balancing node ([6](#footnotes)) | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Internal load balancing node ([6](#footnotes)) | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
## Footnotes
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index b847e927846..c3fd02d8c9c 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -9348,7 +9348,7 @@ type Query {
): SnippetConnection
"""
- Find a user
+ Find a user on this instance
"""
user(
"""
@@ -12054,6 +12054,126 @@ scalar Upload
type User {
"""
+ Merge Requests assigned to the user
+ """
+ assignedMergeRequests(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Array of IIDs of merge requests, for example `[1, 2]`
+ """
+ iids: [String!]
+
+ """
+ Array of label names. All resolved merge requests will have all of these labels.
+ """
+ labels: [String!]
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+
+ """
+ The global ID of the project the authored merge requests should be in. Incompatible with projectPath.
+ """
+ projectId: ID
+
+ """
+ The full-path of the project the authored merge requests should be in. Incompatible with projectId.
+ """
+ projectPath: String
+
+ """
+ Array of source branch names. All resolved merge requests will have one of these branches as their source.
+ """
+ sourceBranches: [String!]
+
+ """
+ A merge request state. If provided, all resolved merge requests will have this state.
+ """
+ state: MergeRequestState
+
+ """
+ Array of target branch names. All resolved merge requests will have one of these branches as their target.
+ """
+ targetBranches: [String!]
+ ): MergeRequestConnection
+
+ """
+ Merge Requests authored by the user
+ """
+ authoredMergeRequests(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Array of IIDs of merge requests, for example `[1, 2]`
+ """
+ iids: [String!]
+
+ """
+ Array of label names. All resolved merge requests will have all of these labels.
+ """
+ labels: [String!]
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+
+ """
+ The global ID of the project the authored merge requests should be in. Incompatible with projectPath.
+ """
+ projectId: ID
+
+ """
+ The full-path of the project the authored merge requests should be in. Incompatible with projectId.
+ """
+ projectPath: String
+
+ """
+ Array of source branch names. All resolved merge requests will have one of these branches as their source.
+ """
+ sourceBranches: [String!]
+
+ """
+ A merge request state. If provided, all resolved merge requests will have this state.
+ """
+ state: MergeRequestState
+
+ """
+ Array of target branch names. All resolved merge requests will have one of these branches as their target.
+ """
+ targetBranches: [String!]
+ ): MergeRequestConnection
+
+ """
URL of the user's avatar
"""
avatarUrl: String
@@ -12109,9 +12229,9 @@ type User {
): SnippetConnection
"""
- State of the issue
+ State of the user
"""
- state: String!
+ state: UserState!
"""
Todos of the user
@@ -12226,6 +12346,26 @@ type UserPermissions {
createSnippet: Boolean!
}
+"""
+Possible states of a user
+"""
+enum UserState {
+ """
+ The user is active and is able to use the system
+ """
+ active
+
+ """
+ The user has been blocked and is prevented from using the system
+ """
+ blocked
+
+ """
+ The user is no longer active and is unable to use the system
+ """
+ deactivated
+}
+
enum VisibilityLevelsEnum {
internal
private
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index b980075b560..2ac543cb14a 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -27422,7 +27422,7 @@
},
{
"name": "user",
- "description": "Find a user",
+ "description": "Find a user on this instance",
"args": [
{
"name": "id",
@@ -35623,6 +35623,316 @@
"description": null,
"fields": [
{
+ "name": "assignedMergeRequests",
+ "description": "Merge Requests assigned to the user",
+ "args": [
+ {
+ "name": "iids",
+ "description": "Array of IIDs of merge requests, for example `[1, 2]`",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "sourceBranches",
+ "description": "Array of source branch names. All resolved merge requests will have one of these branches as their source.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "targetBranches",
+ "description": "Array of target branch names. All resolved merge requests will have one of these branches as their target.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "state",
+ "description": "A merge request state. If provided, all resolved merge requests will have this state.",
+ "type": {
+ "kind": "ENUM",
+ "name": "MergeRequestState",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "labels",
+ "description": "Array of label names. All resolved merge requests will have all of these labels.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "projectPath",
+ "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "projectId",
+ "description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "MergeRequestConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "authoredMergeRequests",
+ "description": "Merge Requests authored by the user",
+ "args": [
+ {
+ "name": "iids",
+ "description": "Array of IIDs of merge requests, for example `[1, 2]`",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "sourceBranches",
+ "description": "Array of source branch names. All resolved merge requests will have one of these branches as their source.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "targetBranches",
+ "description": "Array of target branch names. All resolved merge requests will have one of these branches as their target.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "state",
+ "description": "A merge request state. If provided, all resolved merge requests will have this state.",
+ "type": {
+ "kind": "ENUM",
+ "name": "MergeRequestState",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "labels",
+ "description": "Array of label names. All resolved merge requests will have all of these labels.",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "projectPath",
+ "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "projectId",
+ "description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "MergeRequestConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "avatarUrl",
"description": "URL of the user's avatar",
"args": [
@@ -35765,7 +36075,7 @@
},
{
"name": "state",
- "description": "State of the issue",
+ "description": "State of the user",
"args": [
],
@@ -35773,8 +36083,8 @@
"kind": "NON_NULL",
"name": null,
"ofType": {
- "kind": "SCALAR",
- "name": "String",
+ "kind": "ENUM",
+ "name": "UserState",
"ofType": null
}
},
@@ -36153,6 +36463,35 @@
},
{
"kind": "ENUM",
+ "name": "UserState",
+ "description": "Possible states of a user",
+ "fields": null,
+ "inputFields": null,
+ "interfaces": null,
+ "enumValues": [
+ {
+ "name": "active",
+ "description": "The user is active and is able to use the system",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "blocked",
+ "description": "The user has been blocked and is prevented from using the system",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "deactivated",
+ "description": "The user is no longer active and is unable to use the system",
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "possibleTypes": null
+ },
+ {
+ "kind": "ENUM",
"name": "VisibilityLevelsEnum",
"description": null,
"fields": null,
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 2ed8dfd0dd3..452d0059cca 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user |
| `name` | String! | Human-readable name of the user |
-| `state` | String! | State of the issue |
+| `state` | UserState! | State of the user |
| `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab |
| `webUrl` | String! | Web URL of the user |
diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md
index fa11da62363..44f8ebbdd00 100644
--- a/doc/development/fe_guide/graphql.md
+++ b/doc/development/fe_guide/graphql.md
@@ -15,11 +15,11 @@
- [🎬 GraphQL at GitLab: Deep Dive](../api_graphql_styleguide.md#deep-dive) (video) by Nick Thomas
- An overview of the history of GraphQL at GitLab (not frontend-specific)
- [🎬 GitLab Feature Walkthrough with GraphQL and Vue Apollo](https://www.youtube.com/watch?v=6yYp2zB7FrM) (video) by Natalia Tepluhina
- - A real-life example of implmenting a frontend feature in GitLab using GraphQL
+ - A real-life example of implementing a frontend feature in GitLab using GraphQL
- [🎬 History of client-side GraphQL at GitLab](https://www.youtube.com/watch?v=mCKRJxvMnf0) (video) Illya Klymov and Natalia Tepluhina
- [🎬 From Vuex to Apollo](https://www.youtube.com/watch?v=9knwu87IfU8) (video) by Natalia Tepluhina
- A useful overview of when Apollo might be a better choice than Vuex, and how one could go about the transition
-- [🛠 Vuex-> Apollo Migration: a proof-of-concept project](https://gitlab.com/ntepluhina/vuex-to-apollo/blob/master/README.md)
+- [🛠 Vuex -> Apollo Migration: a proof-of-concept project](https://gitlab.com/ntepluhina/vuex-to-apollo/blob/master/README.md)
- A collection of examples that show the possible approaches for state management with Vue+GraphQL+(Vuex or Apollo) apps
### Libraries
@@ -30,7 +30,7 @@ when using GraphQL for frontend development.
If you are using GraphQL within a Vue application, the [Usage in Vue](#usage-in-vue) section
can help you learn how to integrate Vue Apollo.
-For other usecases, check out the [Usage outside of Vue](#usage-outside-of-vue) section.
+For other use cases, check out the [Usage outside of Vue](#usage-outside-of-vue) section.
### Tooling
@@ -650,7 +650,7 @@ When [using Vuex](#Using-with-Vuex), disable the cache when:
- The data is being cached elsewhere
- The use case does not need caching
-if the data is being cached elsewhere, or if there is simply no need for it for the given usecase.
+if the data is being cached elsewhere, or if there is simply no need for it for the given use case.
```javascript
import createDefaultClient from '~/lib/graphql';
diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md
index 52be7451b1e..bbce5e38fee 100644
--- a/doc/development/i18n/proofreader.md
+++ b/doc/development/i18n/proofreader.md
@@ -33,7 +33,7 @@ are very appreciative of the work done by translators and proofreaders!
- Dutch
- Emily Hendle - [GitLab](https://gitlab.com/pundachan), [CrowdIn](https://crowdin.com/profile/pandachan)
- Esperanto
-- Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv)
+ - Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv)
- Estonian
- Proofreaders needed.
- Filipino
diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md
index 59b71b05b16..a9c670247a0 100644
--- a/doc/user/admin_area/license.md
+++ b/doc/user/admin_area/license.md
@@ -64,7 +64,7 @@ export GITLAB_LICENSE_FILE="/path/to/license/file"
Omnibus installations should add this entry to `gitlab.rb`:
```ruby
-gitlab_rails['license_file'] = "/path/to/license/file"
+gitlab_rails['initial_license_file'] = "/path/to/license/file"
```
CAUTION: **Caution:**
diff --git a/doc/user/project/operations/alert_management.md b/doc/user/project/operations/alert_management.md
index fae834e589b..7aaebd0da08 100644
--- a/doc/user/project/operations/alert_management.md
+++ b/doc/user/project/operations/alert_management.md
@@ -89,3 +89,6 @@ The Alert Management detail view enables you to create an issue with a
description automatically populated from an alert. To create the issue,
click the **Create Issue** button. You can then view the issue from the
alert by clicking the **View Issue** button.
+
+Closing a GitLab issue associated with an alert changes the alert's status to Resolved.
+See [Alert Management statuses](#alert-management-statuses) for more details about statuses.
diff --git a/lib/gitlab/graphql/loaders/full_path_model_loader.rb b/lib/gitlab/graphql/loaders/full_path_model_loader.rb
new file mode 100644
index 00000000000..0aa237c78de
--- /dev/null
+++ b/lib/gitlab/graphql/loaders/full_path_model_loader.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Graphql
+ module Loaders
+ # Suitable for use to find resources that expose `where_full_path_in`,
+ # such as Project, Group, Namespace
+ class FullPathModelLoader
+ attr_reader :model_class, :full_path
+
+ def initialize(model_class, full_path)
+ @model_class, @full_path = model_class, full_path
+ end
+
+ def find
+ BatchLoader::GraphQL.for(full_path).batch(key: model_class) do |full_paths, loader, args|
+ # `with_route` avoids an N+1 calculating full_path
+ args[:key].where_full_path_in(full_paths).with_route.each do |model_instance|
+ loader.call(model_instance.full_path, model_instance)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 6f55945829e..30928816cc1 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3642,6 +3642,9 @@ msgstr ""
msgid "CI Lint"
msgstr ""
+msgid "CI settings"
+msgstr ""
+
msgid "CI variables"
msgstr ""
@@ -6988,6 +6991,9 @@ msgstr ""
msgid "Default classification label"
msgstr ""
+msgid "Default dashboard"
+msgstr ""
+
msgid "Default deletion adjourned period"
msgstr ""
@@ -10606,9 +10612,6 @@ msgstr ""
msgid "Go full screen"
msgstr ""
-msgid "Go to"
-msgstr ""
-
msgid "Go to %{link_to_google_takeout}."
msgstr ""
@@ -13756,9 +13759,6 @@ msgstr ""
msgid "Metrics and profiling"
msgstr ""
-msgid "Metrics for environment"
-msgstr ""
-
msgid "Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time"
msgstr ""
@@ -18807,9 +18807,6 @@ msgstr ""
msgid "Runners currently online: %{active_runners_count}"
msgstr ""
-msgid "Runners page"
-msgstr ""
-
msgid "Runners page."
msgstr ""
@@ -20470,6 +20467,12 @@ msgstr ""
msgid "Sort direction"
msgstr ""
+msgid "Sort direction: Ascending"
+msgstr ""
+
+msgid "Sort direction: Descending"
+msgstr ""
+
msgid "SortOptions|Access level, ascending"
msgstr ""
@@ -22057,6 +22060,9 @@ msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
+msgid "There was a problem fetching users."
+msgstr ""
+
msgid "There was a problem refreshing the data, please try again"
msgstr ""
@@ -22483,7 +22489,7 @@ msgstr ""
msgid "This job is preparing to start"
msgstr ""
-msgid "This job is stuck because you don't have any active runners online with any of these tags assigned to them:"
+msgid "This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:"
msgstr ""
msgid "This job is stuck because you don't have any active runners that can run this job."
diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb
index e2fa03670d9..fb6b0f656b7 100644
--- a/spec/controllers/concerns/metrics_dashboard_spec.rb
+++ b/spec/controllers/concerns/metrics_dashboard_spec.rb
@@ -134,7 +134,7 @@ describe MetricsDashboard do
it 'adds starred dashboard information and sorts the list' do
all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') }
expected_response = [
- { "display_name" => "Default", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) },
+ { "display_name" => "Default dashboard", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) },
{ "display_name" => "anomaly.yml", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/anomaly.yml' }) },
{ "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/errors.yml' }) },
{ "display_name" => "test.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/test.yml' }) }
diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb
index 8ab2c7ffd64..cbab809cddb 100644
--- a/spec/fast_spec_helper.rb
+++ b/spec/fast_spec_helper.rb
@@ -13,6 +13,5 @@ require 'active_support/all'
ActiveSupport::Dependencies.autoload_paths << 'lib'
ActiveSupport::Dependencies.autoload_paths << 'ee/lib'
-ActiveSupport::Dependencies.autoload_paths << 'tooling/lib'
ActiveSupport::XmlMini.backend = 'Nokogiri'
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index a17793bc6d6..6b7ea9215b0 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -940,7 +940,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
it 'renders message about job being stuck because of no runners with the specified tags' do
expect(page).to have_css('.js-stuck-with-tags')
- expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:")
+ expect(page).to have_content("This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:")
end
end
@@ -950,7 +950,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
it 'renders message about job being stuck because of no runners with the specified tags' do
expect(page).to have_css('.js-stuck-with-tags')
- expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:")
+ expect(page).to have_content("This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:")
end
end
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 21f02efc640..c4084ab6ca1 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -851,6 +851,62 @@ describe('Dashboard', () => {
});
});
+ describe('document title', () => {
+ const originalTitle = 'Original Title';
+ const defaultDashboardName = dashboardGitResponse[0].display_name;
+
+ beforeEach(() => {
+ document.title = originalTitle;
+ createShallowWrapper({ hasMetrics: true });
+ });
+
+ afterAll(() => {
+ document.title = '';
+ });
+
+ it('is prepended with default dashboard name by default', () => {
+ setupAllDashboards(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true);
+ });
+ });
+
+ it('is prepended with dashboard name if path is known', () => {
+ const dashboard = dashboardGitResponse[1];
+ const currentDashboard = dashboard.path;
+
+ setupAllDashboards(store, currentDashboard);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(document.title.startsWith(`${dashboard.display_name} · `)).toBe(true);
+ });
+ });
+
+ it('is prepended with default dashboard name is path is not known', () => {
+ setupAllDashboards(store, 'unknown/path');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true);
+ });
+ });
+
+ it('is not modified when dashboard name is not provided', () => {
+ const dashboard = { ...dashboardGitResponse[1], display_name: null };
+ const currentDashboard = dashboard.path;
+
+ store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, [dashboard]);
+
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(document.title).toBe(originalTitle);
+ });
+ });
+ });
+
describe('Dashboard dropdown', () => {
beforeEach(() => {
createMountedWrapper({ hasMetrics: true });
diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js
index 338af79dbbe..8ac8b4367c6 100644
--- a/spec/frontend/monitoring/store_utils.js
+++ b/spec/frontend/monitoring/store_utils.js
@@ -16,8 +16,13 @@ const setEnvironmentData = store => {
store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData);
};
-export const setupAllDashboards = store => {
+export const setupAllDashboards = (store, path) => {
store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse);
+ if (path) {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: path,
+ });
+ }
};
export const setupStoreWithDashboard = store => {
@@ -25,10 +30,6 @@ export const setupStoreWithDashboard = store => {
`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
metricsDashboardPayload,
);
- store.commit(
- `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
- metricsDashboardPayload,
- );
};
export const setupStoreWithVariable = store => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
new file mode 100644
index 00000000000..eded5b87abc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -0,0 +1,259 @@
+import { shallowMount } from '@vue/test-utils';
+import {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown as GlDropdown,
+ GlNewDropdownItem as GlDropdownItem,
+} from '@gitlab/ui';
+
+import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants';
+
+import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+
+import { mockAvailableTokens, mockSortOptions } from './mock_data';
+
+const createComponent = ({
+ namespace = 'gitlab-org/gitlab-test',
+ recentSearchesStorageKey = 'requirements',
+ tokens = mockAvailableTokens,
+ sortOptions = mockSortOptions,
+ searchInputPlaceholder = 'Filter requirements',
+} = {}) =>
+ shallowMount(FilteredSearchBarRoot, {
+ propsData: {
+ namespace,
+ recentSearchesStorageKey,
+ tokens,
+ sortOptions,
+ searchInputPlaceholder,
+ },
+ });
+
+describe('FilteredSearchBarRoot', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('data', () => {
+ it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => {
+ expect(wrapper.vm.filterValue).toEqual([]);
+ expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending);
+ expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
+ });
+ });
+
+ describe('computed', () => {
+ describe('tokenSymbols', () => {
+ it('returns array of map containing type and symbols from `tokens` prop', () => {
+ expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' });
+ });
+ });
+
+ describe('sortDirectionIcon', () => {
+ it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => {
+ wrapper.setData({
+ selectedSortDirection: SortDirection.ascending,
+ });
+
+ expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest');
+ });
+
+ it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => {
+ wrapper.setData({
+ selectedSortDirection: SortDirection.descending,
+ });
+
+ expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest');
+ });
+ });
+
+ describe('sortDirectionTooltip', () => {
+ it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => {
+ wrapper.setData({
+ selectedSortDirection: SortDirection.ascending,
+ });
+
+ expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending');
+ });
+
+ it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => {
+ wrapper.setData({
+ selectedSortDirection: SortDirection.descending,
+ });
+
+ expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending');
+ });
+ });
+ });
+
+ describe('watchers', () => {
+ describe('filterValue', () => {
+ it('emits component event `onFilter` with empty array when `filterValue` is cleared by GlFilteredSearch', () => {
+ wrapper.setData({
+ initialRender: false,
+ filterValue: [
+ {
+ type: 'filtered-search-term',
+ value: { data: '' },
+ },
+ ],
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.emitted('onFilter')[0]).toEqual([[]]);
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('setupRecentSearch', () => {
+ it('initializes `recentSearchesService` and `recentSearchesStore` props when `recentSearchesStorageKey` is available', () => {
+ expect(wrapper.vm.recentSearchesService instanceof RecentSearchesService).toBe(true);
+ expect(wrapper.vm.recentSearchesStore instanceof RecentSearchesStore).toBe(true);
+ });
+
+ it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => {
+ jest
+ .spyOn(wrapper.vm.recentSearchesService, 'fetch')
+ .mockReturnValue(new Promise(() => []));
+
+ wrapper.vm.setupRecentSearch();
+
+ expect(wrapper.vm.recentSearchesPromise instanceof Promise).toBe(true);
+ });
+ });
+
+ describe('getRecentSearches', () => {
+ it('returns array of strings representing recent searches', () => {
+ wrapper.vm.recentSearchesStore.setRecentSearches(['foo']);
+
+ expect(wrapper.vm.getRecentSearches()).toEqual(['foo']);
+ });
+ });
+
+ describe('handleSortOptionClick', () => {
+ it('emits component event `onSort` with selected sort by value', () => {
+ wrapper.vm.handleSortOptionClick(mockSortOptions[1]);
+
+ expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]);
+ expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]);
+ });
+ });
+
+ describe('handleSortDirectionClick', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ selectedSortOption: mockSortOptions[0],
+ });
+ });
+
+ it('sets `selectedSortDirection` to be opposite of its current value', () => {
+ expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
+
+ wrapper.vm.handleSortDirectionClick();
+
+ expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.ascending);
+ });
+
+ it('emits component event `onSort` with opposite of currently selected sort by value', () => {
+ wrapper.vm.handleSortDirectionClick();
+
+ expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]);
+ });
+ });
+
+ describe('handleFilterSubmit', () => {
+ const mockFilters = [
+ {
+ type: 'author_username',
+ value: {
+ data: 'root',
+ operator: '=',
+ },
+ },
+ 'foo',
+ ];
+
+ it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => {
+ jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch');
+ // jest.spyOn(wrapper.vm.recentSearchesService, 'save');
+
+ wrapper.vm.handleFilterSubmit(mockFilters);
+
+ return wrapper.vm.recentSearchesPromise.then(() => {
+ expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(
+ 'author_username:=@root foo',
+ );
+ });
+ });
+
+ it('calls `recentSearchesService.save` with array of searches', () => {
+ jest.spyOn(wrapper.vm.recentSearchesService, 'save');
+
+ wrapper.vm.handleFilterSubmit(mockFilters);
+
+ return wrapper.vm.recentSearchesPromise.then(() => {
+ expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([
+ 'author_username:=@root foo',
+ ]);
+ });
+ });
+
+ it('emits component event `onFilter` with provided filters param', () => {
+ wrapper.vm.handleFilterSubmit(mockFilters);
+
+ expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ selectedSortOption: mockSortOptions[0],
+ selectedSortDirection: SortDirection.descending,
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders gl-filtered-search component', () => {
+ const glFilteredSearchEl = wrapper.find(GlFilteredSearch);
+
+ expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
+ expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens);
+ });
+
+ it('renders sort dropdown component', () => {
+ expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
+ expect(wrapper.find(GlDropdown).exists()).toBe(true);
+ expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title);
+ });
+
+ it('renders dropdown items', () => {
+ const dropdownItemsEl = wrapper.findAll(GlDropdownItem);
+
+ expect(dropdownItemsEl).toHaveLength(mockSortOptions.length);
+ expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title);
+ expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true);
+ expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title);
+ });
+
+ it('renders sort direction button', () => {
+ const sortButtonEl = wrapper.find(GlButton);
+
+ expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending');
+ expect(sortButtonEl.props('icon')).toBe('sort-highest');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
new file mode 100644
index 00000000000..edc0f119262
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -0,0 +1,64 @@
+import Api from '~/api';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+
+export const mockAuthor1 = {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ web_url: 'http://0.0.0.0:3000/root',
+};
+
+export const mockAuthor2 = {
+ id: 2,
+ name: 'Claudio Beer',
+ username: 'ericka_terry',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/12a89d115b5a398d5082897ebbcba9c2?s=80&d=identicon',
+ web_url: 'http://0.0.0.0:3000/ericka_terry',
+};
+
+export const mockAuthor3 = {
+ id: 6,
+ name: 'Shizue Hartmann',
+ username: 'junita.weimann',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/9da1abb41b1d4c9c9e81030b71ea61a0?s=80&d=identicon',
+ web_url: 'http://0.0.0.0:3000/junita.weimann',
+};
+
+export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
+
+export const mockAuthorToken = {
+ type: 'author_username',
+ icon: 'user',
+ title: 'Author',
+ unique: false,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: 'gitlab-org/gitlab-test',
+ fetchAuthors: Api.projectUsers.bind(Api),
+};
+
+export const mockAvailableTokens = [mockAuthorToken];
+
+export const mockSortOptions = [
+ {
+ id: 1,
+ title: 'Created date',
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: 'Last updated',
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
new file mode 100644
index 00000000000..3650ef79136
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -0,0 +1,150 @@
+import { mount } from '@vue/test-utils';
+import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+
+import createFlash from '~/flash';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+
+import { mockAuthorToken, mockAuthors } from '../mock_data';
+
+jest.mock('~/flash');
+
+const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) =>
+ mount(AuthorToken, {
+ propsData: {
+ config,
+ value,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ },
+ stubs: {
+ Portal: {
+ template: '<div><slot></slot></div>',
+ },
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+ },
+ });
+
+describe('AuthorToken', () => {
+ let mock;
+ let wrapper;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('currentValue', () => {
+ it('returns lowercase string for `value.data`', () => {
+ wrapper.setProps({
+ value: { data: 'FOO' },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.currentValue).toBe('foo');
+ });
+ });
+ });
+
+ describe('activeAuthor', () => {
+ it('returns object for currently present `value.data`', () => {
+ wrapper.setData({
+ authors: mockAuthors,
+ });
+
+ wrapper.setProps({
+ value: { data: mockAuthors[0].username },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
+ });
+ });
+ });
+ });
+
+ describe('fetchAuthorBySearchTerm', () => {
+ it('calls `config.fetchAuthors` with provided searchTerm param', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchAuthors');
+
+ wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username);
+
+ expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith(
+ mockAuthorToken.fetchPath,
+ mockAuthors[0].username,
+ );
+ });
+
+ it('sets response to `authors` when request is succesful', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors);
+
+ wrapper.vm.fetchAuthorBySearchTerm('root');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.authors).toEqual(mockAuthors);
+ });
+ });
+
+ it('calls `createFlash` with flash error message when request fails', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
+
+ wrapper.vm.fetchAuthorBySearchTerm('root');
+
+ return waitForPromises().then(() => {
+ expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.');
+ });
+ });
+
+ it('sets `loading` to false when request completes', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
+
+ wrapper.vm.fetchAuthorBySearchTerm('root');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.loading).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ authors: mockAuthors,
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders gl-filtered-search-token component', () => {
+ expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
+ });
+
+ it('renders token item when value is selected', () => {
+ wrapper.setProps({
+ value: { data: mockAuthors[0].username },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator"
+ expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator"
+ });
+ });
+ });
+});
diff --git a/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb b/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb
deleted file mode 100644
index b5c349f6284..00000000000
--- a/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Mutations::ResolvesProject do
- let(:mutation_class) do
- Class.new(Mutations::BaseMutation) do
- include Mutations::ResolvesProject
- end
- end
-
- let(:context) { double }
-
- subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
-
- it 'uses the ProjectsResolver to resolve projects by path' do
- project = create(:project)
-
- expect(Resolvers::ProjectResolver).to receive(:new).with(object: nil, context: context, field: nil).and_call_original
- expect(mutation.resolve_project(full_path: project.full_path).sync).to eq(project)
- end
-end
diff --git a/spec/graphql/resolvers/concerns/resolves_project_spec.rb b/spec/graphql/resolvers/concerns/resolves_project_spec.rb
new file mode 100644
index 00000000000..f29f54483d6
--- /dev/null
+++ b/spec/graphql/resolvers/concerns/resolves_project_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ResolvesProject do
+ include GraphqlHelpers
+
+ let(:implementing_class) do
+ Class.new do
+ include ResolvesProject
+ end
+ end
+
+ subject(:instance) { implementing_class.new }
+
+ let_it_be(:project) { create(:project) }
+
+ it 'can resolve projects by path' do
+ expect(sync(instance.resolve_project(full_path: project.full_path))).to eq(project)
+ end
+
+ it 'can resolve projects by id' do
+ expect(sync(instance.resolve_project(project_id: global_id_of(project)))).to eq(project)
+ end
+
+ it 'complains when both are present' do
+ expect do
+ instance.resolve_project(full_path: project.full_path, project_id: global_id_of(project))
+ end.to raise_error(::Gitlab::Graphql::Errors::ArgumentError)
+ end
+
+ it 'complains when neither is present' do
+ expect do
+ instance.resolve_project(full_path: nil, project_id: nil)
+ end.to raise_error(::Gitlab::Graphql::Errors::ArgumentError)
+ end
+end
diff --git a/spec/graphql/resolvers/user_resolver_spec.rb b/spec/graphql/resolvers/user_resolver_spec.rb
index 1bfede41567..45a8816bf26 100644
--- a/spec/graphql/resolvers/user_resolver_spec.rb
+++ b/spec/graphql/resolvers/user_resolver_spec.rb
@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do
private
def resolve_user(args = {})
- resolve(described_class, args: args)
+ sync(resolve(described_class, args: args))
end
end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index cf1e91afb80..a49fb0ef627 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do
expected_fields = %w[
id user_permissions snippets name username avatarUrl webUrl todos state
+ authoredMergeRequests assignedMergeRequests
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
index d772b0c7a5f..2703339d89c 100644
--- a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
@@ -142,7 +142,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi
describe '.find_all_paths' do
let(:all_dashboard_paths) { described_class.find_all_paths(project) }
- let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default', default: true, system_dashboard: true } }
+ let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default dashboard', default: true, system_dashboard: true } }
it 'includes only the system dashboard by default' do
expect(all_dashboard_paths).to eq([system_dashboard])
@@ -163,7 +163,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi
let(:self_monitoring_dashboard) do
{
path: self_monitoring_dashboard_path,
- display_name: 'Default',
+ display_name: 'Default dashboard',
default: true,
system_dashboard: false
}
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 811266021fe..81a3d1ebc49 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -186,6 +186,35 @@ describe Issue do
expect { issue.close }.to change { issue.state_id }.from(open_state).to(closed_state)
end
+
+ context 'when there is an associated Alert Management Alert' do
+ context 'when alert can be resolved' do
+ let!(:alert) { create(:alert_management_alert, project: issue.project, issue: issue) }
+
+ it 'resolves an alert' do
+ expect { issue.close }.to change { alert.reload.resolved? }.to(true)
+ end
+ end
+
+ context 'when alert cannot be resolved' do
+ let!(:alert) { create(:alert_management_alert, :with_validation_errors, project: issue.project, issue: issue) }
+
+ before do
+ allow(Gitlab::AppLogger).to receive(:warn).and_call_original
+ end
+
+ it 'writes a warning into the log' do
+ issue.close
+
+ expect(Gitlab::AppLogger).to have_received(:warn).with(
+ message: 'Cannot resolve an associated Alert Management alert',
+ issue_id: issue.id,
+ alert_id: alert.id,
+ alert_errors: { hosts: ['hosts array is over 255 chars'] }
+ )
+ end
+ end
+ end
end
describe '#reopen' do
diff --git a/spec/requests/api/graphql/user_query_spec.rb b/spec/requests/api/graphql/user_query_spec.rb
new file mode 100644
index 00000000000..5ac94bc7323
--- /dev/null
+++ b/spec/requests/api/graphql/user_query_spec.rb
@@ -0,0 +1,260 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'getting user information' do
+ include GraphqlHelpers
+
+ let(:query) do
+ graphql_query_for(:user, user_params, user_fields)
+ end
+
+ let(:user_fields) { all_graphql_fields_for('User', max_depth: 2) }
+
+ context 'no parameters are provided' do
+ let(:user_params) { nil }
+
+ it 'mentions the missing required parameters' do
+ post_graphql(query)
+
+ expect_graphql_errors_to_include(/username/)
+ end
+ end
+
+ context 'looking up a user by username' do
+ let_it_be(:project_a) { create(:project, :repository) }
+ let_it_be(:project_b) { create(:project, :repository) }
+ let_it_be(:user, reload: true) { create(:user, developer_projects: [project_a, project_b]) }
+ let_it_be(:authorised_user) { create(:user, developer_projects: [project_a, project_b]) }
+ let_it_be(:unauthorized_user) { create(:user) }
+
+ let_it_be(:assigned_mr) do
+ create(:merge_request, :unique_branches,
+ source_project: project_a, assignees: [user])
+ end
+ let_it_be(:assigned_mr_b) do
+ create(:merge_request, :unique_branches,
+ source_project: project_b, assignees: [user])
+ end
+ let_it_be(:assigned_mr_c) do
+ create(:merge_request, :unique_branches,
+ source_project: project_b, assignees: [user])
+ end
+ let_it_be(:authored_mr) do
+ create(:merge_request, :unique_branches,
+ source_project: project_a, author: user)
+ end
+ let_it_be(:authored_mr_b) do
+ create(:merge_request, :unique_branches,
+ source_project: project_b, author: user)
+ end
+ let_it_be(:authored_mr_c) do
+ create(:merge_request, :unique_branches,
+ source_project: project_b, author: user)
+ end
+
+ let(:current_user) { authorised_user }
+ let(:authored_mrs) { graphql_data_at(:user, :authored_merge_requests, :nodes) }
+ let(:assigned_mrs) { graphql_data_at(:user, :assigned_merge_requests, :nodes) }
+ let(:user_params) { { username: user.username } }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'the user is an active user' do
+ it_behaves_like 'a working graphql query'
+
+ it 'can access user profile fields' do
+ presenter = UserPresenter.new(user)
+
+ expect(graphql_data['user']).to match(
+ a_hash_including(
+ 'id' => global_id_of(user),
+ 'state' => presenter.state,
+ 'name' => presenter.name,
+ 'username' => presenter.username,
+ 'webUrl' => presenter.web_url,
+ 'avatarUrl' => presenter.avatar_url
+ ))
+ end
+
+ describe 'assignedMergeRequests' do
+ let(:user_fields) do
+ query_graphql_field(:assigned_merge_requests, mr_args, 'nodes { id }')
+ end
+ let(:mr_args) { nil }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'can be found' do
+ expect(assigned_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(assigned_mr)),
+ a_hash_including('id' => global_id_of(assigned_mr_b)),
+ a_hash_including('id' => global_id_of(assigned_mr_c))
+ )
+ end
+
+ context 'applying filters' do
+ context 'filtering by IID without specifying a project' do
+ let(:mr_args) do
+ { iids: [assigned_mr_b.iid.to_s] }
+ end
+
+ it 'return an argument error that mentions the missing fields' do
+ expect_graphql_errors_to_include(/projectPath/)
+ end
+ end
+
+ context 'filtering by project path and IID' do
+ let(:mr_args) do
+ { project_path: project_b.full_path, iids: [assigned_mr_b.iid.to_s] }
+ end
+
+ it 'selects the correct MRs' do
+ expect(assigned_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(assigned_mr_b))
+ )
+ end
+ end
+
+ context 'filtering by project path' do
+ let(:mr_args) do
+ { project_path: project_b.full_path }
+ end
+
+ it 'selects the correct MRs' do
+ expect(assigned_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(assigned_mr_b)),
+ a_hash_including('id' => global_id_of(assigned_mr_c))
+ )
+ end
+ end
+ end
+
+ context 'the current user does not have access' do
+ let(:current_user) { unauthorized_user }
+
+ it 'cannot be found' do
+ expect(assigned_mrs).to be_empty
+ end
+ end
+ end
+
+ describe 'authoredMergeRequests' do
+ let(:user_fields) do
+ query_graphql_field(:authored_merge_requests, mr_args, 'nodes { id }')
+ end
+ let(:mr_args) { nil }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'can be found' do
+ expect(authored_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(authored_mr)),
+ a_hash_including('id' => global_id_of(authored_mr_b)),
+ a_hash_including('id' => global_id_of(authored_mr_c))
+ )
+ end
+
+ context 'applying filters' do
+ context 'filtering by IID without specifying a project' do
+ let(:mr_args) do
+ { iids: [authored_mr_b.iid.to_s] }
+ end
+
+ it 'return an argument error that mentions the missing fields' do
+ expect_graphql_errors_to_include(/projectPath/)
+ end
+ end
+
+ context 'filtering by project path and IID' do
+ let(:mr_args) do
+ { project_path: project_b.full_path, iids: [authored_mr_b.iid.to_s] }
+ end
+
+ it 'selects the correct MRs' do
+ expect(authored_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(authored_mr_b))
+ )
+ end
+ end
+
+ context 'filtering by project path' do
+ let(:mr_args) do
+ { project_path: project_b.full_path }
+ end
+
+ it 'selects the correct MRs' do
+ expect(authored_mrs).to contain_exactly(
+ a_hash_including('id' => global_id_of(authored_mr_b)),
+ a_hash_including('id' => global_id_of(authored_mr_c))
+ )
+ end
+ end
+ end
+
+ context 'the current user does not have access' do
+ let(:current_user) { unauthorized_user }
+
+ it 'cannot be found' do
+ expect(authored_mrs).to be_empty
+ end
+ end
+ end
+ end
+
+ context 'the user is private' do
+ before do
+ user.update(private_profile: true)
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'we only request basic fields' do
+ let(:user_fields) { %i[id name username state web_url avatar_url] }
+
+ it_behaves_like 'a working graphql query'
+ end
+
+ context 'we request the authoredMergeRequests' do
+ let(:user_fields) { 'authoredMergeRequests { nodes { id } }' }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'cannot be found' do
+ expect(authored_mrs).to be_empty
+ end
+
+ context 'the current user is the user' do
+ let(:current_user) { user }
+
+ it 'can be found' do
+ expect(authored_mrs).to include(
+ a_hash_including('id' => global_id_of(authored_mr))
+ )
+ end
+ end
+ end
+
+ context 'we request the assignedMergeRequests' do
+ let(:user_fields) { 'assignedMergeRequests { nodes { id } }' }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'cannot be found' do
+ expect(assigned_mrs).to be_empty
+ end
+
+ context 'the current user is the user' do
+ let(:current_user) { user }
+
+ it 'can be found' do
+ expect(assigned_mrs).to include(
+ a_hash_including('id' => global_id_of(assigned_mr))
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 51d34e1a02a..bd5e151ba03 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -153,7 +153,15 @@ module GraphqlHelpers
end
def wrap_fields(fields)
- fields = Array.wrap(fields).join("\n")
+ fields = Array.wrap(fields).map do |field|
+ case field
+ when Symbol
+ GraphqlHelpers.fieldnamerize(field)
+ else
+ field
+ end
+ end.join("\n")
+
return unless fields.present?
<<~FIELDS
diff --git a/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb b/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb
index 1befc459023..58cd3d21f66 100644
--- a/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb
@@ -29,16 +29,6 @@ RSpec.shared_examples 'resolving an issuable in GraphQL' do |type|
subject
end
- it 'uses correct Resolver to resolve issuable parent' do
- resolver_class = type == :epic ? 'Resolvers::GroupResolver' : 'Resolvers::ProjectResolver'
-
- expect(resolver_class.constantize).to receive(:new)
- .with(object: nil, context: context, field: nil)
- .and_call_original
-
- subject
- end
-
it 'returns nil if issuable is not found' do
result = mutation.resolve_issuable(type: type, parent_path: parent.full_path, iid: "100")
result = result.respond_to?(:sync) ? result.sync : result
diff --git a/spec/tooling/lib/tooling/test_file_finder_spec.rb b/spec/tooling/lib/tooling/test_file_finder_spec.rb
index 7a5d39a014a..a56600709c6 100644
--- a/spec/tooling/lib/tooling/test_file_finder_spec.rb
+++ b/spec/tooling/lib/tooling/test_file_finder_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require_relative '../../../../tooling/lib/tooling/test_file_finder'
-describe Tooling::TestFileFinder do
+RSpec.describe Tooling::TestFileFinder do
subject { Tooling::TestFileFinder.new(file) }
describe '#test_files' do