Commit ea338a0b authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 1a3d1170 a1614594
88ef3e7f64498ae3574f29b0705c29cf3b4e9311
d0a79053ba4fef55b59543b99327fc89aed64876
<script>
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
import { GlButton, GlIcon } from '@gitlab/ui';
import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { deprecatedCreateFlash as Flash } from '~/flash';
......@@ -34,6 +33,10 @@ export default {
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: {
......@@ -46,8 +49,8 @@ export default {
return {
note: '',
noteType: constants.COMMENT,
noteIsConfidential: false,
isSubmitting: false,
isSubmitButtonDisabled: true,
};
},
computed: {
......@@ -80,6 +83,9 @@ export default {
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
},
canSetConfidential() {
return this.getNoteableData.current_user.can_update;
},
issueActionButtonTitle() {
const openOrClose = this.isOpen ? 'close' : 'reopen';
......@@ -146,13 +152,11 @@ export default {
hasCloseAndCommentButton() {
return !this.glFeatures.removeCommentCloseReopen;
},
},
watch: {
note(newNote) {
this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
confidentialNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
isSubmitting(newValue) {
this.setIsSubmitButtonDisabled(this.note, newValue);
disableSubmitButton() {
return this.note.length === 0 || this.isSubmitting;
},
},
mounted() {
......@@ -173,13 +177,6 @@ export default {
'reopenIssuable',
'toggleIssueLocalState',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
this.isSubmitButtonDisabled = false;
} else {
this.isSubmitButtonDisabled = true;
}
},
handleSave(withIssueAction) {
if (this.note.length) {
const noteData = {
......@@ -189,6 +186,7 @@ export default {
note: {
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
confidential: this.noteIsConfidential,
note: this.note,
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
......@@ -252,6 +250,7 @@ export default {
if (shouldClear) {
this.note = '';
this.noteIsConfidential = false;
this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false;
}
......@@ -340,11 +339,26 @@ export default {
</markdown-field>
</comment-field-layout>
<div class="note-form-actions">
<gl-form-checkbox
v-if="confidentialNotesEnabled && canSetConfidential"
v-model="noteIsConfidential"
class="gl-mb-6"
data-testid="confidential-note-checkbox"
>
{{ s__('Notes|Make this comment confidential') }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="16"
:title="s__('Notes|Confidential comments are only visible to project members')"
class="gl-text-gray-500"
/>
</gl-form-checkbox>
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<gl-button
:disabled="isSubmitButtonDisabled"
:disabled="disableSubmitButton"
class="js-comment-button js-comment-submit-button"
data-qa-selector="comment_button"
data-testid="comment-button"
......@@ -357,7 +371,7 @@ export default {
>{{ commentButtonTitle }}</gl-button
>
<gl-button
:disabled="isSubmitButtonDisabled"
:disabled="disableSubmitButton"
name="button"
category="primary"
variant="success"
......
......@@ -210,9 +210,9 @@ export default {
v-gl-tooltip:tooltipcontainer.bottom
data-testid="confidentialIndicator"
name="eye-slash"
:size="14"
:title="s__('Notes|Private comments are accessible by internal staff only')"
class="gl-ml-1 gl-text-gray-700 align-middle"
:size="16"
:title="s__('Notes|This comment is confidential and only visible to project members')"
class="gl-ml-1 gl-text-orange-700 align-middle"
/>
<slot name="extra-controls"></slot>
<gl-loading-icon
......
import ServerlessBundle from '~/serverless/serverless_bundle';
import initServerlessSurveyBanner from '~/serverless/survey_banner';
document.addEventListener('DOMContentLoaded', () => {
initServerlessSurveyBanner();
new ServerlessBundle(); // eslint-disable-line no-new
});
initServerlessSurveyBanner();
new ServerlessBundle(); // eslint-disable-line no-new
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
// Translations strings
......@@ -35,8 +35,6 @@ export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
'ContainerRegistry|%{title} was successfully scheduled for deletion',
);
export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories');
export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name');
export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
export const EMPTY_RESULT_MESSAGE = s__(
'ContainerRegistry|To widen your search, change or remove the filters above.',
......@@ -47,3 +45,9 @@ export const EMPTY_RESULT_MESSAGE = s__(
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
export const GRAPHQL_PAGE_SIZE = 10;
export const SORT_FIELDS = [
{ orderBy: 'UPDATED', label: __('Updated') },
{ orderBy: 'CREATED', label: __('Created') },
{ orderBy: 'NAME', label: __('Name') },
];
......@@ -6,9 +6,17 @@ query getContainerRepositoriesDetails(
$after: String
$before: String
$isGroupPage: Boolean!
$sort: ContainerRepositorySort
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
containerRepositories(
name: $name
after: $after
before: $before
first: $first
last: $last
sort: $sort
) {
nodes {
id
tagsCount
......@@ -16,7 +24,14 @@ query getContainerRepositoriesDetails(
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
containerRepositories(
name: $name
after: $after
before: $before
first: $first
last: $last
sort: $sort
) {
nodes {
id
tagsCount
......
......@@ -7,12 +7,12 @@ import {
GlLink,
GlAlert,
GlSkeletonLoader,
GlSearchBoxByClick,
} from '@gitlab/ui';
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import Tracking from '~/tracking';
import createFlash from '~/flash';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
import DeleteImage from '../components/delete_image.vue';
......@@ -25,12 +25,11 @@ import {
CONNECTION_ERROR_MESSAGE,
REMOVE_REPOSITORY_MODAL_TEXT,
REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
SORT_FIELDS,
} from '../constants/index';
export default {
......@@ -58,9 +57,9 @@ export default {
GlLink,
GlAlert,
GlSkeletonLoader,
GlSearchBoxByClick,
RegistryHeader,
DeleteImage,
RegistrySearch,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -77,11 +76,10 @@ export default {
CONNECTION_ERROR_MESSAGE,
REMOVE_REPOSITORY_MODAL_TEXT,
REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
searchConfig: SORT_FIELDS,
apollo: {
baseImages: {
query: getContainerRepositoriesQuery,
......@@ -123,7 +121,8 @@ export default {
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
searchValue: null,
filter: [],
sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null,
mutationLoading: false,
fetchAdditionalDetails: false,
......@@ -142,6 +141,7 @@ export default {
queryVariables() {
return {
name: this.name,
sort: this.sortBy,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
isGroupPage: this.config.isGroupPage,
first: GRAPHQL_PAGE_SIZE,
......@@ -166,6 +166,10 @@ export default {
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
sortBy() {
const { orderBy, sort } = this.sorting;
return `${orderBy}_${sort}`.toUpperCase();
},
},
mounted() {
// If the two graphql calls - which are not batched - resolve togheter we will have a race
......@@ -231,6 +235,16 @@ export default {
this.track('confirm_delete');
this.mutationLoading = true;
},
updateSorting(value) {
this.sorting = {
...this.sorting,
...value,
};
},
doFilter() {
const search = this.filter.find((i) => i.type === 'filtered-search-term');
this.name = search?.value?.data;
},
},
};
</script>
......@@ -283,6 +297,16 @@ export default {
</template>
</registry-header>
<registry-search
:filter="filter"
:sorting="sorting"
:tokens="[]"
:sortable-fields="$options.searchConfig"
@sorting:changed="updateSorting"
@filter:changed="filter = $event"
@filter:submit="doFilter"
/>
<div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
......@@ -298,20 +322,6 @@ export default {
</div>
<template v-else>
<template v-if="images.length > 0 || name">
<div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
@clear="name = null"
@submit="name = $event"
/>
</div>
</div>
<image-list
v-if="images.length"
:images="images"
......
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const DEFAULT_RENDER_COUNT = 5;
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
export default {
components: {
......@@ -34,35 +32,21 @@ export default {
data() {
return {
showLess: true,
loading: false,
requestedReviewSuccess: false,
loadingStates: {},
};
},
computed: {
firstUser() {
return this.users[0];
},
hasOneUser() {
return this.users.length === 1;
},
hiddenReviewersLabel() {
const { numberOfHiddenReviewers } = this;
return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers });
},
renderShowMoreSection() {
return this.users.length > DEFAULT_RENDER_COUNT;
},
numberOfHiddenReviewers() {
return this.users.length - DEFAULT_RENDER_COUNT;
},
uncollapsedUsers() {
const uncollapsedLength = this.showLess
? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
: this.users.length;
return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
},
username() {
return `@${this.firstUser.username}`;
watch: {
users: {
handler(users) {
this.loadingStates = users.reduce(
(acc, user) => ({
...acc,
[user.id]: acc[user.id] || null,
}),
this.loadingStates,
);
},
immediate: true,
},
},
methods: {
......@@ -70,21 +54,23 @@ export default {
this.showLess = !this.showLess;
},
reRequestReview(userId) {
this.loading = true;
this.loadingStates[userId] = LOADING_STATE;
this.$emit('request-review', { userId, callback: this.requestReviewComplete });
},
requestReviewComplete(success) {
requestReviewComplete(userId, success) {
if (success) {
this.requestedReviewSuccess = true;
this.loadingStates[userId] = SUCCESS_STATE;
setTimeout(() => {
this.requestedReviewSuccess = false;
this.loadingStates[userId] = null;
}, 1500);
} else {
this.loadingStates[userId] = null;
}
this.loading = false;
},
},
LOADING_STATE,
SUCCESS_STATE,
};
</script>
......@@ -100,20 +86,22 @@ export default {
<div class="gl-ml-3">@{{ user.username }}</div>
</reviewer-avatar-link>
<gl-icon
v-if="requestedReviewSuccess"
v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
:size="24"
name="check"
class="float-right gl-text-green-500"
data-testid="re-request-success"
/>
<gl-button
v-else-if="user.can_update_merge_request && user.reviewed"
v-gl-tooltip.left
:title="__('Re-request review')"
:loading="loading"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
class="float-right gl-text-gray-500!"
size="small"
icon="redo"
variant="link"
data-testid="re-request-button"
@click="reRequestReview(user.id)"
/>
</div>
......
......@@ -58,9 +58,9 @@ export default class SidebarMediator {
.then(() => {
this.store.updateReviewer(userId);
toast(__('Requested review'));
callback(true);
callback(userId, true);
})
.catch(() => callback(false));
.catch(() => callback(userId, false));
}
setMoveToProjectId(projectId) {
......
......@@ -243,7 +243,8 @@ module NotesActions
:type,
:note,
:line_code, # LegacyDiffNote
:position # DiffNote
:position, # DiffNote
:confidential
).tap do |create_params|
create_params.merge!(
params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id)
......
......@@ -7,30 +7,26 @@
#
# include RedisTracking
#
# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :my_feature
#
# if the feature flag is enabled by default you should use
# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :my_feature, feature_default_enabled: true
# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score'
#
# You can also pass custom conditions using `if:`, using the same format as with Rails callbacks.
module RedisTracking
extend ActiveSupport::Concern
class_methods do
def track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false, if: nil)
def track_redis_hll_event(*controller_actions, name:, if: nil)
custom_conditions = Array.wrap(binding.local_variable_get('if'))
conditions = [:trackable_request?, *custom_conditions]
after_action only: controller_actions, if: conditions do
track_unique_redis_hll_event(name, feature, feature_default_enabled)
track_unique_redis_hll_event(name)
end
end
end
private
def track_unique_redis_hll_event(event_name, feature, feature_default_enabled)
return unless metric_feature_enabled?(feature, feature_default_enabled)
def track_unique_redis_hll_event(event_name)
return unless visitor_id
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: visitor_id)
......@@ -40,10 +36,6 @@ module RedisTracking
request.format.html? && request.headers['DNT'] != '1'
end
def metric_feature_enabled?(feature, default_enabled)
Feature.enabled?(feature, default_enabled: default_enabled)
end
def visitor_id
return cookies[:visitor_id] if cookies[:visitor_id].present?
return unless current_user
......
......@@ -15,7 +15,7 @@ module SnippetsActions
skip_before_action :verify_authenticity_token,
if: -> { action_name == 'show' && js_request? }
track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: true
track_redis_hll_event :show, name: 'i_snippets_show'
respond_to :html
end
......
......@@ -36,8 +36,7 @@ module WikiActions
# NOTE: We want to include wiki page views in the same counter as the other
# Event-based wiki actions tracked through TrackUniqueEvents, so we use the same event name.
track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s,
feature: :track_unique_wiki_page_views, feature_default_enabled: true
track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s
helper_method :view_file_button, :diff_file_html_data
......
......@@ -35,7 +35,7 @@ class Projects::BlobController < Projects::ApplicationController
record_experiment_user(:ci_syntax_templates, namespace_id: @project.namespace_id) if params[:file_name] == @project.ci_config_path_or_default
end
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe'
feature_category :source_code_management
......
......@@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController
real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
......
......@@ -5,7 +5,7 @@ class SearchController < ApplicationController
include SearchHelper
include RedisTracking
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
track_redis_hll_event :show, name: 'i_search_total'
around_action :allow_gitaly_ref_name_caching
......
......@@ -6,11 +6,19 @@ query getProjectContainerRepositories(
$after: String
$before: String
$isGroupPage: Boolean!
$sort: ContainerRepositorySort
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
__typename
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
containerRepositories(
name: $name
after: $after
before: $before
first: $first
last: $last
sort: $sort
) {
__typename
nodes {
id
......@@ -35,7 +43,14 @@ query getProjectContainerRepositories(
group(fullPath: $fullPath) @include(if: $isGroupPage) {
__typename
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
containerRepositories(
name: $name
after: $after
before: $before
first: $first
last: $last
sort: $sort
) {
__typename
nodes {
id
......
......@@ -31,6 +31,7 @@ class ProjectStatistics < ApplicationRecord
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) }
scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) }
def total_repository_size
repository_size + lfs_objects_size
......
- page_title _("Container Registry")
- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true} )
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil} )
%section
#js-container-registry{ data: { endpoint: group_container_registries_path(@group),
......
......@@ -28,7 +28,7 @@
= _('GPG Key ID:')
%span.monospace= signature.gpg_key_primary_keyid
= link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
= link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link gl-display-block')
%a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= label
- page_title _("Container Registry")
- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false} )
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil} )
%section
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
......
---
title: Support setting confidential note attribute in UI
merge_request: 52949
author: Lee Tickett @leetickett
type: added
---
title: Add sort to container registry list page
merge_request: 53820
author:
type: changed
---
title: 'BulkImports: Migrate Group Membership'
merge_request: 53083
author:
type: added
---
title: Show helper link on a new line in GPG status popover
merge_request: 52894
author: Yogi (@yo)
type: changed
---
title: Reset CI minutes only for namespaces that used minutes.
merge_request: 53740
author:
type: changed
---
name: confidential_notes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52949
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/207474
milestone: '13.9'
type: development
group: group::product planning
default_enabled: false
......@@ -495,18 +495,17 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
aggregation.
- `aggregation`: may be set to a `:daily` or `:weekly` key. Defines how counting data is stored in Redis.
Aggregation on a `daily` basis does not pull more fine grained data.
- `feature_flag`: optional. For details, see our [GitLab internal Feature flags](feature_flags/) documentation. The feature flags are owned by the group adding the event tracking.
- `feature_flag`: optional `default_enabled: :yaml`. If no feature flag is set then the tracking is enabled. For details, see our [GitLab internal Feature flags](feature_flags/) documentation. The feature flags are owned by the group adding the event tracking.
Use one of the following methods to track events:
1. Track event in controller using `RedisTracking` module with `track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false)`.
1. Track event in controller using `RedisTracking` module with `track_redis_hll_event(*controller_actions, name:, if: nil)`.
Arguments:
- `controller_actions`: controller actions we want to track.
- `name`: event name.
- `feature`: feature name, all metrics we track should be under feature flag.
- `feature_default_enabled`: feature flag is disabled by default, set to `true` for it to be enabled by default.
- `if`: optional custom conditions, using the same format as with Rails callbacks.
Example usage:
......@@ -516,7 +515,7 @@ Use one of the following methods to track events:
include RedisTracking
skip_before_action :authenticate_user!, only: :show
track_redis_hll_event :index, :show, name: 'g_compliance_example_feature_visitors', feature: :compliance_example_feature, feature_default_enabled: true
track_redis_hll_event :index, :show, name: 'g_compliance_example_feature_visitors'
def index
render html: 'index'
......
......@@ -7,12 +7,12 @@ type: reference, howto
# Threads **(FREE)**
The ability to contribute conversationally is offered throughout GitLab.
You can use words to communicate with other users all over GitLab.
You can leave a comment in the following places:
For example, you can leave a comment in the following places:
- Issues
- Epics **(ULTIMATE)**
- Epics
- Merge requests
- Snippets
- Commits
......@@ -281,6 +281,23 @@ edit existing comments. Non-team members are restricted from adding or editing c
Additionally, locked issues and merge requests can not be reopened.
## Confidential Comments
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207473) in GitLab 13.9.
> - It's [deployed behind a feature flag](../feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it. **(FREE SELF)**
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
When creating a comment, you can decide to make it visible only to the project members (users with Reporter and higher permissions).
To create a confidential comment, select the **Make this comment confidential** checkbox before you submit it.
![Confidential comments](img/confidential_comments_v13_9.png)
## Merge Request Reviews
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4213) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.4.
......@@ -418,25 +435,6 @@ the thread will be automatically resolved, and GitLab will create a new commit
and push the suggested change directly into the codebase in the merge request's
branch. [Developer permission](../permissions.md) is required to do so.
### Enable or disable Custom commit messages for suggestions **(FREE SELF)**
Custom commit messages for suggestions is under development but ready for production use. It is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to disable it.
To disable custom commit messages for suggestions:
```ruby
Feature.disable(:suggestions_custom_commit)
```
To enable custom commit messages for suggestions:
```ruby
Feature.enable(:suggestions_custom_commit)
```
### Multi-line Suggestions
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/53310) in GitLab 11.10.
......@@ -532,27 +530,6 @@ to your branch to address your reviewers' requests.
![A code change suggestion displayed, with the button to apply the batch of suggestions highlighted.](img/apply_batch_of_suggestions_v13_1.jpg "Apply a batch of suggestions")
#### Enable or disable Batch Suggestions **(FREE SELF)**
Batch Suggestions is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to disable it for your instance.
To enable it:
```ruby
# Instance-wide
Feature.enable(:batch_suggestions)
```
To disable it:
```ruby
# Instance-wide
Feature.disable(:batch_suggestions)
```
## Start a thread by replying to a standard comment
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/30299) in GitLab 11.9
......@@ -585,3 +562,62 @@ In the comment, click the **More Actions** menu and click **Assign to commenting
Click the button again to unassign the commenter.
![Assign to commenting user](img/quickly_assign_commenter_v13_1.png)
## Enable or disable Confidential Comments **(FREE SELF)**
Confidential Comments is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:confidential_notes)
```
To disable it:
```ruby
Feature.disable(:confidential_notes)
```
## Enable or disable Custom commit messages for suggestions **(FREE SELF)**
Custom commit messages for suggestions is under development but ready for production use. It is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to disable it.
To disable custom commit messages for suggestions:
```ruby
Feature.disable(:suggestions_custom_commit)
```
To enable custom commit messages for suggestions:
```ruby
Feature.enable(:suggestions_custom_commit)
```
## Enable or disable Batch Suggestions **(FREE SELF)**
Batch Suggestions is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to disable it for your instance.
To enable it:
```ruby
# Instance-wide
Feature.enable(:batch_suggestions)
```
To disable it:
```ruby
# Instance-wide
Feature.disable(:batch_suggestions)
```
......@@ -7,13 +7,13 @@ module EE
prepended do
# track unique users of advanced global search
track_redis_hll_event :show, name: 'i_search_advanced', feature: :search_track_unique_users, feature_default_enabled: true,
track_redis_hll_event :show, name: 'i_search_advanced',
if: :track_search_advanced?
# track unique paid users (users who already use elasticsearch and users who could use it if they enable elasticsearch integration)
# for gitlab.com we check if the search uses elasticsearch
# for self-managed we check if the licensed feature available
track_redis_hll_event :show, name: 'i_search_paid', feature: :search_track_unique_users, feature_default_enabled: true,
track_redis_hll_event :show, name: 'i_search_paid',
if: :track_search_paid?
end
......
......@@ -10,7 +10,7 @@ class Groups::Analytics::RepositoryAnalyticsController < Groups::Analytics::Appl
before_action only: [:show] do
push_frontend_feature_flag(:usage_data_i_testing_group_code_coverage_project_click_total, @group, default_enabled: :yaml)
end
track_redis_hll_event :show, name: 'i_testing_group_code_coverage_visit_total', feature: :usage_data_i_testing_group_code_coverage_visit_total, feature_default_enabled: true
track_redis_hll_event :show, name: 'i_testing_group_code_coverage_visit_total'
def show
track_event(**pageview_tracker_params)
......
......@@ -10,8 +10,7 @@ module Projects
include RedisTracking
track_redis_hll_event :index,
name: 'i_ecosystem_jira_service_list_issues',
feature: :usage_data_track_ecosystem_jira_service
name: 'i_ecosystem_jira_service_list_issues'
before_action :check_feature_enabled!
before_action :check_issues_show_enabled!, only: :show
......
......@@ -6,4 +6,5 @@ class NamespaceStatistics < ApplicationRecord
validates :namespace, presence: true
scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) }
scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) }
end
......@@ -99,11 +99,15 @@ module Ci
end
def reset_shared_runners_seconds!(namespaces)
namespace_relation = NamespaceStatistics.for_namespaces(namespaces)
namespace_relation.update_all(shared_runners_seconds: 0, shared_runners_seconds_last_reset: Time.current)
project_relation = ::ProjectStatistics.for_namespaces(namespaces)
project_relation.update_all(shared_runners_seconds: 0, shared_runners_seconds_last_reset: Time.current)
NamespaceStatistics
.for_namespaces(namespaces)
.with_any_ci_minutes_used
.update_all(shared_runners_seconds: 0, shared_runners_seconds_last_reset: Time.current)
::ProjectStatistics
.for_namespaces(namespaces)
.with_any_ci_minutes_used
.update_all(shared_runners_seconds: 0, shared_runners_seconds_last_reset: Time.current)
end
def reset_ci_minutes_notifications!(namespaces)
......
......@@ -10,7 +10,7 @@ class ClearSharedRunnersMinutesWorker # rubocop:disable Scalability/IdempotentWo
feature_category :continuous_integration
LEASE_TIMEOUT = 3600
TIME_SPREAD = 24.hours.seconds.freeze
TIME_SPREAD = 3.hours.seconds.freeze
BATCH_SIZE = 100_000
def perform
......
......@@ -17,7 +17,7 @@ RSpec.describe SearchController do
end
context 'i_search_advanced' do
it_behaves_like 'tracking unique hll events', :search_track_unique_users do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: { scope: 'projects', search: 'term' } }
let(:target_id) { 'i_search_advanced' }
......@@ -37,7 +37,7 @@ RSpec.describe SearchController do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end
it_behaves_like 'tracking unique hll events', :search_track_unique_users do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: request_params }
let(:expected_type) { instance_of(String) }
......@@ -54,7 +54,7 @@ RSpec.describe SearchController do
stub_licensed_features(elastic_search: true)
end
it_behaves_like 'tracking unique hll events', :search_track_unique_users do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: request_params }
let(:expected_type) { instance_of(String) }
......
......@@ -38,7 +38,7 @@ RSpec.describe Groups::Analytics::RepositoryAnalyticsController do
allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
end
it_behaves_like 'tracking unique hll events', :usage_data_i_testing_group_code_coverage_visit_total do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: { group_id: group } }
let(:target_id) { 'i_testing_group_code_coverage_visit_total' }
......
......@@ -18,9 +18,10 @@ RSpec.describe BulkImports::Importers::GroupImporter do
describe '#execute' do
it "starts the entity and run its pipelines" do
expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
expect_to_run_pipeline EE::BulkImports::Groups::Pipelines::EpicsPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
subject.execute
......
......@@ -85,17 +85,6 @@ RSpec.describe Ci::Minutes::BatchResetService do
expect(namespace.last_ci_minutes_usage_notification_level).to be_nil
end
end
it 'touches the shared_runners_seconds_last_reset for all namespaces' do
subject
expect(
[
namespace_1.reload, namespace_2.reload, namespace_3.reload,
namespace_4.reload, namespace_5.reload
].map(&:shared_runners_seconds_last_reset)
).to all(be_within(1.second).of(Time.current))
end
end
context 'when ID range is not provided' do
......@@ -121,17 +110,6 @@ RSpec.describe Ci::Minutes::BatchResetService do
expect(namespace_6.last_ci_minutes_notification_at).to be_nil
expect(namespace_6.last_ci_minutes_usage_notification_level).to be_nil
end
it 'touches the shared_runners_seconds_last_reset for all namespaces' do
subject
expect(
[
namespace_1.reload, namespace_2.reload, namespace_3.reload,
namespace_4.reload, namespace_5.reload, namespace_6.reload
].map(&:shared_runners_seconds_last_reset)
).to all(be_within(1.second).of(Time.current))
end
end
context 'when an ActiveRecordError is raised' do
......
......@@ -147,12 +147,12 @@ RSpec.describe ClearSharedRunnersMinutesWorker do
end
it 'runs a worker per batch', :aggregate_failures do
# Spread evenly accross 24 hours (86,400 seconds)
# Spread evenly accross 3 hours (10,800 seconds)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(0.seconds, 2, 4)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(21600.seconds, 5, 7)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(43200.seconds, 8, 10)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(64800.seconds, 11, 13)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(86400.seconds, 14, 16)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(2700.seconds, 5, 7)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(5400.seconds, 8, 10)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(8100.seconds, 11, 13)
expect(Ci::BatchResetMinutesWorker).to receive(:perform_in).with(10800.seconds, 14, 16)
subject
end
......
# frozen_string_literal: true
module BulkImports
module Groups
module Graphql
module GetMembersQuery
extend self
def to_s
<<-'GRAPHQL'
query($full_path: ID!, $cursor: String) {
group(fullPath: $full_path) {
group_members: groupMembers(relations: DIRECT, first: 100, after: $cursor) {
page_info: pageInfo {
end_cursor: endCursor
has_next_page: hasNextPage
}
nodes {
created_at: createdAt
updated_at: updatedAt
expires_at: expiresAt
access_level: accessLevel {
integer_value: integerValue
}
user {
public_email: publicEmail
}
}
}
}
}
GRAPHQL
end
def variables(entity)
{
full_path: entity.source_full_path,
cursor: entity.next_page_for(:group_members)
}
end
def base_path
%w[data group group_members]
end
def data_path
base_path << 'nodes'
end
def page_info_path
base_path << 'page_info'
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Loaders
class MembersLoader
def initialize(*); end
def load(context, data)
return unless data
context.group.members.create!(data)
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Pipelines
class MembersPipeline
include Pipeline
extractor BulkImports::Common::Extractors::GraphqlExtractor,
query: BulkImports::Groups::Graphql::GetMembersQuery
transformer Common::Transformers::ProhibitedAttributesTransformer
transformer BulkImports::Groups::Transformers::MemberAttributesTransformer
loader BulkImports::Groups::Loaders::MembersLoader
def after_run(context, extracted_data)
context.entity.update_tracker_for(
relation: :group_members,
has_next_page: extracted_data.has_next_page?,
next_page: extracted_data.next_page
)
if extracted_data.has_next_page?
run(context)
end
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Transformers
class MemberAttributesTransformer
def initialize(*); end
def transform(context, data)
data
.then { |data| add_user(data) }
.then { |data| add_access_level(data) }
.then { |data| add_author(data, context) }
end
private
def add_user(data)
user = find_user(data&.dig('user', 'public_email'))
return unless user
data
.except('user')
.merge('user_id' => user.id)
end
def find_user(email)
return unless email
User.find_by_any_email(email, confirmed: true)
end
def add_access_level(data)
access_level = data&.dig('access_level', 'integer_value')
return unless valid_access_level?(access_level)
data.merge('access_level' => access_level)
end
def valid_access_level?(access_level)
Gitlab::Access
.options_with_owner
.value?(access_level)
end
def add_author(data, context)
return unless data
data.merge('created_by_id' => context.current_user.id)
end
end
end
end
end
......@@ -23,6 +23,7 @@ module BulkImports
[
BulkImports::Groups::Pipelines::GroupPipeline,
BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline,
BulkImports::Groups::Pipelines::MembersPipeline,
BulkImports::Groups::Pipelines::LabelsPipeline
]
end
......
......@@ -129,6 +129,8 @@ module Gitlab
event = event_for(event_name)
raise UnknownEvent, "Unknown event #{event_name}" unless event.present?
return unless feature_enabled?(event)
Gitlab::Redis::HLL.add(key: redis_key(event, time, context), value: values, expiry: expiry(event))
end
......@@ -148,6 +150,12 @@ module Gitlab
redis_usage_data { Gitlab::Redis::HLL.count(keys: keys) }
end
def feature_enabled?(event)
return true if event[:feature_flag].blank?
Feature.enabled?(event[:feature_flag], default_enabled: :yaml)
end
# Allow to add totals for events that are in the same redis slot, category and have the same aggregation level
# and if there are more than 1 event
def eligible_for_totals?(events_names)
......
......@@ -1027,9 +1027,6 @@ msgstr ""
msgid "+ %{numberOfHiddenAssignees} more"
msgstr ""
msgid "+ %{numberOfHiddenReviewers} more"
msgstr ""
msgid "+%d more"
msgid_plural "+%d more"
msgstr[0] ""
......@@ -7832,15 +7829,9 @@ msgstr ""
msgid "ContainerRegistry|Expiration policy will run in %{time}"
msgstr ""
msgid "ContainerRegistry|Filter by name"
msgstr ""
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
msgid "ContainerRegistry|Image repository deletion failed"
msgstr ""
......@@ -20257,7 +20248,10 @@ msgstr ""
msgid "Notes|Collapse replies"
msgstr ""
msgid "Notes|Private comments are accessible by internal staff only"
msgid "Notes|Confidential comments are only visible to project members"
msgstr ""
msgid "Notes|Make this comment confidential"
msgstr ""
msgid "Notes|Show all activity"
......@@ -20272,6 +20266,9 @@ msgstr ""
msgid "Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost"
msgstr ""
msgid "Notes|This comment is confidential and only visible to project members"
msgstr ""
msgid "Notes|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options."
msgstr ""
......
......@@ -3,18 +3,13 @@
require "spec_helper"
RSpec.describe RedisTracking do
let(:feature) { 'approval_rule' }
let(:user) { create(:user) }
before do
skip_feature_flags_yaml_validation
end
controller(ApplicationController) do
include RedisTracking
skip_before_action :authenticate_user!, only: :show
track_redis_hll_event :index, :show, name: 'g_compliance_approval_rules', feature: :approval_rule, feature_default_enabled: true,
track_redis_hll_event :index, :show, name: 'g_compliance_approval_rules',
if: [:custom_condition_one?, :custom_condition_two?]
def index
......@@ -49,97 +44,75 @@ RSpec.describe RedisTracking do
expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
end
context 'with feature disabled' do
it 'does not track the event' do
stub_feature_flags(feature => false)
expect_no_tracking
get :index
end
end
context 'with feature enabled' do
context 'when user is logged in' do
before do
stub_feature_flags(feature => true)
sign_in(user)
end
context 'when user is logged in' do
before do
sign_in(user)
end
it 'tracks the event' do
expect_tracking
get :index
end
it 'passes default_enabled flag' do
expect(controller).to receive(:metric_feature_enabled?).with(feature.to_sym, true)
it 'tracks the event' do
expect_tracking
get :index
end
get :index
end
it 'tracks the event if DNT is not enabled' do
request.headers['DNT'] = '0'
it 'tracks the event if DNT is not enabled' do
request.headers['DNT'] = '0'
expect_tracking
expect_tracking
get :index
end
get :index
end
it 'does not track the event if DNT is enabled' do
request.headers['DNT'] = '1'
it 'does not track the event if DNT is enabled' do
request.headers['DNT'] = '1'
expect_no_tracking
expect_no_tracking
get :index
end
get :index
end
it 'does not track the event if the format is not HTML' do
expect_no_tracking
it 'does not track the event if the format is not HTML' do
expect_no_tracking
get :index, format: :json
end
get :index, format: :json
end
it 'does not track the event if a custom condition returns false' do
expect(controller).to receive(:custom_condition_two?).and_return(false)
it 'does not track the event if a custom condition returns false' do
expect(controller).to receive(:custom_condition_two?).and_return(false)
expect_no_tracking
expect_no_tracking
get :index
end
get :index
end
it 'does not track the event for untracked actions' do
expect_no_tracking
it 'does not track the event for untracked actions' do
expect_no_tracking
get :new
end
get :new
end
end
context 'when user is not logged in and there is a visitor_id' do
let(:visitor_id) { SecureRandom.uuid }
context 'when user is not logged in and there is a visitor_id' do
let(:visitor_id) { SecureRandom.uuid }
before do
routes.draw { get 'show' => 'anonymous#show' }
end
before do
routes.draw { get 'show' => 'anonymous#show' }
end
it 'tracks the event' do
cookies[:visitor_id] = { value: visitor_id, expires: 24.months }
it 'tracks the event' do
cookies[:visitor_id] = { value: visitor_id, expires: 24.months }
expect_tracking
expect_tracking
get :show
end
get :show
end
end
context 'when user is not logged in and there is no visitor_id' do
it 'does not track the event' do
expect_no_tracking
context 'when user is not logged in and there is no visitor_id' do
it 'does not track the event' do
expect_no_tracking
get :index
end
get :index
end
end
end
......@@ -424,7 +424,7 @@ RSpec.describe Projects::BlobController do
end
end
it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do
it_behaves_like 'tracking unique hll events' do
subject(:request) { put :update, params: default_params }
let(:target_id) { 'g_edit_by_sfe' }
......@@ -540,7 +540,7 @@ RSpec.describe Projects::BlobController do
sign_in(user)
end
it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do
it_behaves_like 'tracking unique hll events' do
subject(:request) { post :create, params: default_params }
let(:target_id) { 'g_edit_by_sfe' }
......
......@@ -315,7 +315,7 @@ RSpec.describe Projects::NotesController do
let(:note_text) { 'some note' }
let(:request_params) do
{
note: { note: note_text, noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
note: { note: note_text, noteable_id: merge_request.id, noteable_type: 'MergeRequest' }.merge(extra_note_params),
namespace_id: project.namespace,
project_id: project,
merge_request_diff_head_sha: 'sha',
......@@ -325,6 +325,7 @@ RSpec.describe Projects::NotesController do
end
let(:extra_request_params) { {} }
let(:extra_note_params) { {} }
let(:project_visibility) { Gitlab::VisibilityLevel::PUBLIC }
let(:merge_requests_access_level) { ProjectFeature::ENABLED }
......@@ -423,6 +424,41 @@ RSpec.describe Projects::NotesController do
end
end
context 'when creating a confidential note' do
let(:extra_request_params) { { format: :json } }
context 'when `confidential` parameter is not provided' do
it 'sets `confidential` to `false` in JSON response' do
create!
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['confidential']).to be false
end
end
context 'when `confidential` parameter is `false`' do
let(:extra_note_params) { { confidential: false } }
it 'sets `confidential` to `false` in JSON response' do
create!
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['confidential']).to be false
end
end
context 'when `confidential` parameter is `true`' do
let(:extra_note_params) { { confidential: true } }
it 'sets `confidential` to `true` in JSON response' do
create!
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['confidential']).to be true
end
end
end
context 'when creating a note with quick actions' do
context 'with commands that return changes' do
let(:note_text) { "/award :thumbsup:\n/estimate 1d\n/spend 3h" }
......
......@@ -183,7 +183,7 @@ RSpec.describe SearchController do
allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
end
it_behaves_like 'tracking unique hll events', :search_track_unique_users do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: { scope: 'projects', search: 'term' } }
let(:target_id) { 'i_search_total' }
......
......@@ -173,7 +173,7 @@ RSpec.describe SnippetsController do
expect(response).to have_gitlab_http_status(:ok)
end
it_behaves_like 'tracking unique hll events', :usage_data_i_snippets_show do
it_behaves_like 'tracking unique hll events' do
subject(:request) { get :show, params: { id: public_snippet.to_param } }
let(:target_id) { 'i_snippets_show' }
......
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Autosize from 'autosize';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores';
......@@ -21,11 +22,25 @@ describe('issue_comment_form component', () => {
let wrapper;
let axiosMock;
const findCloseReopenButton = () => wrapper.find('[data-testid="close-reopen-button"]');
const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button');
const findCommentButton = () => wrapper.findByTestId('comment-button');
const findTextArea = () => wrapper.findByTestId('comment-field');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox');
const createNotableDataMock = (data = {}) => {
return {
...noteableDataMock,
...data,
};
};
const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
const notableDataMockCanUpdateIssuable = createNotableDataMock({
current_user: { can_update: true, can_create_note: true },
});
const findTextArea = () => wrapper.find('[data-testid="comment-field"]');
const notableDataMockCannotUpdateIssuable = createNotableDataMock({
current_user: { can_update: false, can_create_note: true },
});
const mountComponent = ({
initialData = {},
......@@ -33,23 +48,29 @@ describe('issue_comment_form component', () => {
noteableData = noteableDataMock,
notesData = notesDataMock,
userData = userDataMock,
features = {},
mountFunction = shallowMount,
} = {}) => {
store.dispatch('setNoteableData', noteableData);
store.dispatch('setNotesData', notesData);
store.dispatch('setUserData', userData);
wrapper = mountFunction(CommentForm, {
propsData: {
noteableType,
},
data() {
return {
...initialData,
};
},
store,
});
wrapper = extendedWrapper(
mountFunction(CommentForm, {
propsData: {
noteableType,
},
data() {
return {
...initialData,
};
},
store,
provide: {
glFeatures: features,
},
}),
);
};
beforeEach(() => {
......@@ -359,6 +380,83 @@ describe('issue_comment_form component', () => {
});
});
});
describe('confidential notes checkbox', () => {
describe('when confidentialNotes feature flag is `false`', () => {
const features = { confidentialNotes: false };
it('should not render checkbox', () => {
mountComponent({
mountFunction: mount,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCanUpdateIssuable },
features,
});
const checkbox = findConfidentialNoteCheckbox();
expect(checkbox.exists()).toBe(false);
});
});
describe('when confidentialNotes feature flag is `true`', () => {
const features = { confidentialNotes: true };
it('should render checkbox as unchecked by default', () => {
mountComponent({
mountFunction: mount,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCanUpdateIssuable },
features,
});
const checkbox = findConfidentialNoteCheckbox();
expect(checkbox.exists()).toBe(true);
expect(checkbox.element.checked).toBe(false);
});
describe.each`
shouldCheckboxBeChecked
${true}
${false}
`('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => {
it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => {
mountComponent({
mountFunction: mount,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCanUpdateIssuable },
features,
});
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({});
const checkbox = findConfidentialNoteCheckbox();
// check checkbox
checkbox.element.checked = shouldCheckboxBeChecked;
checkbox.trigger('change');
await wrapper.vm.$nextTick();
// submit comment
wrapper.findByTestId('comment-button').trigger('click');
const [providedData] = wrapper.vm.saveNote.mock.calls[0];
expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked);
});
});
describe('when user cannot update issuable', () => {
it('should not render checkbox', () => {
mountComponent({
mountFunction: mount,
noteableData: { ...notableDataMockCannotUpdateIssuable },
features,
});
expect(findConfidentialNoteCheckbox().exists()).toBe(false);
});
});
});
});
});
describe('user is not logged in', () => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
......@@ -13,12 +13,12 @@ import RegistryHeader from '~/registry/explorer/components/list_page/registry_he
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import DeleteImage from '~/registry/explorer/components/delete_image.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
IMAGE_REPOSITORY_LIST_LABEL,
SEARCH_PLACEHOLDER_TEXT,
SORT_FIELDS,
} from '~/registry/explorer/constants';
import getContainerRepositoriesDetails from '~/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
......@@ -55,8 +55,7 @@ describe('List Page', () => {
const findDeleteAlert = () => wrapper.find(GlAlert);
const findImageList = () => wrapper.find(ImageList);
const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findRegistrySearch = () => wrapper.find(RegistrySearch);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const findDeleteImage = () => wrapper.find(DeleteImage);
......@@ -229,14 +228,6 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false);
});
it('list header is not visible', async () => {
mountComponent({ resolver, config });
await waitForApolloRequestRender();
expect(findListHeader().exists()).toBe(false);
});
});
});
......@@ -258,16 +249,6 @@ describe('List Page', () => {
expect(findImageList().exists()).toBe(true);
});
it('list header is visible', async () => {
mountComponent();
await waitForApolloRequestRender();
const header = findListHeader();
expect(header.exists()).toBe(true);
expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('additional metadata', () => {
it('is called on component load', async () => {
const detailsResolver = jest
......@@ -360,10 +341,15 @@ describe('List Page', () => {
});
});
describe('search', () => {
describe('search and sorting', () => {
const doSearch = async () => {
await waitForApolloRequestRender();
findSearchBox().vm.$emit('submit', 'centos6');
findRegistrySearch().vm.$emit('filter:changed', [
{ type: 'filtered-search-term', value: { data: 'centos6' } },
]);
findRegistrySearch().vm.$emit('filter:submit');
await wrapper.vm.$nextTick();
};
......@@ -372,9 +358,26 @@ describe('List Page', () => {
await waitForApolloRequestRender();
const searchBox = findSearchBox();
expect(searchBox.exists()).toBe(true);
expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
const registrySearch = findRegistrySearch();
expect(registrySearch.exists()).toBe(true);
expect(registrySearch.props()).toMatchObject({
filter: [],
sorting: { orderBy: 'UPDATED', sort: 'desc' },
sortableFields: SORT_FIELDS,
tokens: [],
});
});
it('performs sorting', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
await wrapper.vm.$nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
});
it('performs a search', async () => {
......
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue';
import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue';
import userDataMock from '../../user_data_mock';
describe('UncollapsedReviewerList component', () => {
let wrapper;
function createComponent(props = {}) {
const propsData = {
users: [],
rootPath: TEST_HOST,
...props,
};
wrapper = shallowMount(UncollapsedReviewerList, {
propsData,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('single reviewer', () => {
beforeEach(() => {
const user = userDataMock();
createComponent({
users: [user],
});
});
it('only has one user', () => {
expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(1);
});
it('shows one user with avatar, username and author name', () => {
expect(wrapper.text()).toContain(`@root`);
});
it('renders re-request loading icon', async () => {
await wrapper.setData({ loadingStates: { 1: 'loading' } });
expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true);
});
it('renders re-request success icon', async () => {
await wrapper.setData({ loadingStates: { 1: 'success' } });
expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true);
});
});
describe('multiple reviewers', () => {
beforeEach(() => {
const user = userDataMock();
createComponent({
users: [user, { ...user, id: 2, username: 'hello-world' }],
});
});
it('only has one user', () => {
expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2);
});
it('shows one user with avatar, username and author name', () => {
expect(wrapper.text()).toContain(`@root`);
expect(wrapper.text()).toContain(`@hello-world`);
});
it('renders re-request loading icon', async () => {
await wrapper.setData({ loadingStates: { 2: 'loading' } });
expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2);
expect(wrapper.findAll('[data-testid="re-request-button"]').at(1).props('loading')).toBe(
true,
);
});
it('renders re-request success icon', async () => {
await wrapper.setData({ loadingStates: { 2: 'success' } });
expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1);
expect(wrapper.findAll('[data-testid="re-request-success"]').length).toBe(1);
expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true);
});
});
});
......@@ -8,4 +8,6 @@ export default () => ({
username: 'root',
web_url: `${TEST_HOST}/root`,
can_merge: true,
can_update_merge_request: true,
reviewed: true,
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Graphql::GetMembersQuery do
it 'has a valid query' do
entity = create(:bulk_import_entity)
query = GraphQL::Query.new(
GitlabSchema,
described_class.to_s,
variables: described_class.variables(entity)
)
result = GitlabSchema.static_validator.validate(query)
expect(result[:errors]).to be_empty
end
describe '#data_path' do
it 'returns data path' do
expected = %w[data group group_members nodes]
expect(described_class.data_path).to eq(expected)
end
end
describe '#page_info_path' do
it 'returns pagination information path' do
expected = %w[data group group_members page_info]
expect(described_class.page_info_path).to eq(expected)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Loaders::MembersLoader do
describe '#load' do
let_it_be(:user_importer) { create(:user) }
let_it_be(:user_member) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:bulk_import) { create(:bulk_import, user: user_importer) }
let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) }
let_it_be(:data) do
{
'user_id' => user_member.id,
'created_by_id' => user_importer.id,
'access_level' => 30,
'created_at' => '2020-01-01T00:00:00Z',
'updated_at' => '2020-01-01T00:00:00Z',
'expires_at' => nil
}
end
it 'does nothing when there is no data' do
expect { subject.load(context, nil) }.not_to change(GroupMember, :count)
end
it 'creates the member' do
expect { subject.load(context, data) }.to change(GroupMember, :count).by(1)
member = group.members.last
expect(member.user).to eq(user_member)
expect(member.created_by).to eq(user_importer)
expect(member.access_level).to eq(30)
expect(member.created_at).to eq('2020-01-01T00:00:00Z')
expect(member.updated_at).to eq('2020-01-01T00:00:00Z')
expect(member.expires_at).to eq(nil)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do
let_it_be(:member_user1) { create(:user, email: 'email1@email.com') }
let_it_be(:member_user2) { create(:user, email: 'email2@email.com') }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:cursor) { 'cursor' }
let_it_be(:bulk_import) { create(:bulk_import, user: user) }
let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) }
describe '#run' do
it 'maps existing users to the imported group' do
first_page = member_data(email: member_user1.email, has_next_page: true, cursor: cursor)
last_page = member_data(email: member_user2.email, has_next_page: false)
allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
allow(extractor)
.to receive(:extract)
.and_return(first_page, last_page)
end
expect { subject.run(context) }.to change(GroupMember, :count).by(2)
members = group.members.map { |m| m.slice(:user_id, :access_level) }
expect(members).to contain_exactly(
{ user_id: member_user1.id, access_level: 30 },
{ user_id: member_user2.id, access_level: 30 }
)
end
end
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
it 'has extractors' do
expect(described_class.get_extractor)
.to eq(
klass: BulkImports::Common::Extractors::GraphqlExtractor,
options: {
query: BulkImports::Groups::Graphql::GetMembersQuery
}
)
end
it 'has transformers' do
expect(described_class.transformers)
.to contain_exactly(
{ klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil },
{ klass: BulkImports::Groups::Transformers::MemberAttributesTransformer, options: nil }
)
end
it 'has loaders' do
expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::MembersLoader, options: nil)
end
end
def member_data(email:, has_next_page:, cursor: nil)
data = {
'created_at' => '2020-01-01T00:00:00Z',
'updated_at' => '2020-01-01T00:00:00Z',
'expires_at' => nil,
'access_level' => {
'integer_value' => 30
},
'user' => {
'public_email' => email
}
}
page_info = {
'end_cursor' => cursor,
'has_next_page' => has_next_page
}
BulkImports::Pipeline::ExtractedData.new(data: data, page_info: page_info)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Transformers::MemberAttributesTransformer do
let_it_be(:user) { create(:user) }
let_it_be(:secondary_email) { 'secondary@email.com' }
let_it_be(:group) { create(:group) }
let_it_be(:bulk_import) { create(:bulk_import, user: user) }
let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) }
it 'returns nil when receives no data' do
expect(subject.transform(context, nil)).to eq(nil)
end
it 'returns nil when no user is found' do
expect(subject.transform(context, member_data)).to eq(nil)
expect(subject.transform(context, member_data(email: 'inexistent@email.com'))).to eq(nil)
end
context 'when the user is not confirmed' do
before do
user.update!(confirmed_at: nil)
end
it 'returns nil even when the primary email match' do
data = member_data(email: user.email)
expect(subject.transform(context, data)).to eq(nil)
end
it 'returns nil even when a secondary email match' do
user.emails << Email.new(email: secondary_email)
data = member_data(email: secondary_email)
expect(subject.transform(context, data)).to eq(nil)
end
end
context 'when the user is confirmed' do
before do
user.update!(confirmed_at: Time.now.utc)
end
it 'finds the user by the primary email' do
data = member_data(email: user.email)
expect(subject.transform(context, data)).to eq(
'access_level' => 30,
'user_id' => user.id,
'created_by_id' => user.id,
'created_at' => '2020-01-01T00:00:00Z',
'updated_at' => '2020-01-01T00:00:00Z',
'expires_at' => nil
)
end
it 'finds the user by the secondary email' do
user.emails << Email.new(email: secondary_email, confirmed_at: Time.now.utc)
data = member_data(email: secondary_email)
expect(subject.transform(context, data)).to eq(
'access_level' => 30,
'user_id' => user.id,
'created_by_id' => user.id,
'created_at' => '2020-01-01T00:00:00Z',
'updated_at' => '2020-01-01T00:00:00Z',
'expires_at' => nil
)
end
context 'format access level' do
it 'ignores record if no access level is given' do
data = member_data(email: user.email, access_level: nil)
expect(subject.transform(context, data)).to be_nil
end
it 'ignores record if is not a valid access level' do
data = member_data(email: user.email, access_level: 999)
expect(subject.transform(context, data)).to be_nil
end
end
end
def member_data(email: '', access_level: 30)
{
'created_at' => '2020-01-01T00:00:00Z',
'updated_at' => '2020-01-01T00:00:00Z',
'expires_at' => nil,
'access_level' => {
'integer_value' => access_level
},
'user' => {
'public_email' => email
}
}
end
end
......@@ -18,9 +18,10 @@ RSpec.describe BulkImports::Importers::GroupImporter do
describe '#execute' do
it 'starts the entity and run its pipelines' do
expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) if Gitlab.ee?
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
subject.execute
......
......@@ -8,7 +8,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do
describe '.track_unique_project_event' do
described_class::TEMPLATE_TO_EVENT.keys.each do |template|
context "when given template #{template}" do
it_behaves_like 'tracking unique hll events', :usage_data_track_ci_templates_unique_projects do
it_behaves_like 'tracking unique hll events' do
subject(:request) { described_class.track_unique_project_event(project_id: project_id, template: template) }
let(:target_id) { "p_ci_templates_#{described_class::TEMPLATE_TO_EVENT[template]}" }
......
......@@ -48,6 +48,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
describe 'known_events' do
let(:feature) { 'test_hll_redis_counter_ff_check' }
let(:weekly_event) { 'g_analytics_contribution' }
let(:daily_event) { 'g_analytics_search' }
let(:analytics_slot_event) { 'g_analytics_contribution' }
......@@ -67,7 +69,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:known_events) do
[
{ name: weekly_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "weekly" },
{ name: weekly_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "weekly", feature_flag: feature },
{ name: daily_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "daily" },
{ name: category_productivity_event, redis_slot: "analytics", category: productivity_category, aggregation: "weekly" },
{ name: compliance_slot_event, redis_slot: "compliance", category: compliance_category, aggregation: "weekly" },
......@@ -78,6 +80,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
before do
skip_feature_flags_yaml_validation
skip_default_enabled_yaml_check
allow(described_class).to receive(:known_events).and_return(known_events)
end
......@@ -88,6 +92,32 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
describe '.track_event' do
context 'with feature flag set' do
it 'tracks the event when feature enabled' do
stub_feature_flags(feature => true)
expect(Gitlab::Redis::HLL).to receive(:add)
described_class.track_event(weekly_event, values: 1)
end
it 'does not track the event with feature flag disabled' do
stub_feature_flags(feature => false)
expect(Gitlab::Redis::HLL).not_to receive(:add)
described_class.track_event(weekly_event, values: 1)
end
end
context 'with no feature flag set' do
it 'tracks the event' do
expect(Gitlab::Redis::HLL).to receive(:add)
described_class.track_event(daily_event, values: 1)
end
end
context 'when usage_ping is disabled' do
it 'does not track the event' do
stub_application_setting(usage_ping_enabled: false)
......
......@@ -6,7 +6,7 @@ RSpec.describe API::NpmInstancePackages do
include_context 'npm api setup'
describe 'GET /api/v4/packages/npm/*package_name' do
it_behaves_like 'handling get metadata requests' do
it_behaves_like 'handling get metadata requests', scope: :instance do
let(:url) { api("/packages/npm/#{package_name}") }
end
end
......
......@@ -6,25 +6,25 @@ RSpec.describe API::NpmProjectPackages do
include_context 'npm api setup'
describe 'GET /api/v4/projects/:id/packages/npm/*package_name' do
it_behaves_like 'handling get metadata requests' do
it_behaves_like 'handling get metadata requests', scope: :project do
let(:url) { api("/projects/#{project.id}/packages/npm/#{package_name}") }
end
end
describe 'GET /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags' do
it_behaves_like 'handling get dist tags requests' do
it_behaves_like 'handling get dist tags requests', scope: :project do
let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags") }
end
end
describe 'PUT /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do
it_behaves_like 'handling create dist tag requests' do
it_behaves_like 'handling create dist tag requests', scope: :project do
let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
end
end
describe 'DELETE /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do
it_behaves_like 'handling delete dist tag requests' do
it_behaves_like 'handling delete dist tag requests', scope: :project do
let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
end
end
......@@ -32,10 +32,14 @@ RSpec.describe API::NpmProjectPackages do
describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
let_it_be(:package_file) { package.package_files.first }
let(:params) { {} }
let(:url) { api("/projects/#{project.id}/packages/npm/#{package_file.package.name}/-/#{package_file.file_name}") }
let(:headers) { {} }
let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") }
subject { get(url, params: params) }
subject { get(url, headers: headers) }
before do
project.add_developer(user)
end
shared_examples 'a package file that requires auth' do
it 'denies download with no token' do
......@@ -45,7 +49,7 @@ RSpec.describe API::NpmProjectPackages do
end
context 'with access token' do
let(:params) { { access_token: token.token } }
let(:headers) { build_token_auth_header(token.token) }
it 'returns the file' do
subject
......@@ -56,7 +60,7 @@ RSpec.describe API::NpmProjectPackages do
end
context 'with job token' do
let(:params) { { job_token: job.token } }
let(:headers) { build_token_auth_header(job.token) }
it 'returns the file' do
subject
......@@ -86,7 +90,7 @@ RSpec.describe API::NpmProjectPackages do
it_behaves_like 'a package file that requires auth'
context 'with guest' do
let(:params) { { access_token: token.token } }
let(:headers) { build_token_auth_header(token.token) }
it 'denies download when not enough permissions' do
project.add_guest(user)
......@@ -108,7 +112,11 @@ RSpec.describe API::NpmProjectPackages do
end
describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do
RSpec.shared_examples 'handling invalid record with 400 error' do
before do
project.add_developer(user)
end
shared_examples 'handling invalid record with 400 error' do
it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
expect { upload_package_with_token(package_name, params) }
.not_to change { project.packages.count }
......@@ -261,7 +269,9 @@ RSpec.describe API::NpmProjectPackages do
end
def upload_package(package_name, params = {})
put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params
token = params.delete(:access_token) || params.delete(:job_token)
headers = build_token_auth_header(token)
put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params, headers: headers
end
def upload_package_with_token(package_name, params = {})
......
......@@ -39,7 +39,7 @@ RSpec.describe API::Terraform::State do
context 'with maintainer permissions' do
let(:current_user) { maintainer }
it_behaves_like 'tracking unique hll events', :usage_data_p_terraform_state_api_unique_users do
it_behaves_like 'tracking unique hll events' do
let(:target_id) { 'p_terraform_state_api_unique_users' }
let(:expected_type) { instance_of(Integer) }
end
......
......@@ -4,10 +4,10 @@ RSpec.shared_context 'npm api setup' do
include PackagesManagerApiSpecHelpers
include HttpBasicAuthHelpers
let_it_be(:user) { create(:user) }
let_it_be(:user, reload: true) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project, reload: true) { create(:project, :public, namespace: group) }
let_it_be(:package, reload: true) { create(:npm_package, project: project) }
let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package") }
let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) }
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) }
......@@ -15,8 +15,15 @@ RSpec.shared_context 'npm api setup' do
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let(:package_name) { package.name }
end
before do
project.add_developer(user)
RSpec.shared_context 'set package name from package name type' do
let(:package_name) do
case package_name_type
when :scoped_naming_convention
"@#{group.path}/scoped-package"
when :non_existing
'non-existing-package'
end
end
end
......@@ -5,7 +5,7 @@
# - expected_type
# - target_id
RSpec.shared_examples 'tracking unique hll events' do |feature_flag|
RSpec.shared_examples 'tracking unique hll events' do
it 'tracks unique event' do
expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(
receive(:track_event)
......@@ -15,14 +15,4 @@ RSpec.shared_examples 'tracking unique hll events' do |feature_flag|
request
end
context 'when feature flag is disabled' do
it 'does not track unique event' do
stub_feature_flags(feature_flag => false)
expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
request
end
end
end
......@@ -218,7 +218,7 @@ RSpec.shared_examples 'wiki controller actions' do
end
context 'page view tracking' do
it_behaves_like 'tracking unique hll events', :track_unique_wiki_page_views do
it_behaves_like 'tracking unique hll events' do
let(:target_id) { 'wiki_action' }
let(:expected_type) { instance_of(String) }
end
......
# frozen_string_literal: true
RSpec.shared_examples 'rejects package tags access' do |user_type, status|
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) unless user_type == :no_type
end
it_behaves_like 'returning response status', status
RSpec.shared_examples 'rejects package tags access' do |status:|
before do
package.update!(name: package_name) unless package_name == 'non-existing-package'
end
it_behaves_like 'returning response status', status
end
RSpec.shared_examples 'returns package tags' do |user_type|
RSpec.shared_examples 'accept package tags request' do |status:|
using RSpec::Parameterized::TableSyntax
before do
stub_application_setting(npm_package_requests_forwarding: false)
project.send("add_#{user_type}", user) unless user_type == :no_type
end
it_behaves_like 'returning response status', :success
context 'with valid package name' do
before do
package.update!(name: package_name) unless package_name == 'non-existing-package'
end
it 'returns a valid json response' do
subject
it_behaves_like 'returning response status', status
expect(response.media_type).to eq('application/json')
expect(json_response).to be_a(Hash)
end
it 'returns a valid json response' do
subject
it 'returns two package tags' do
subject
expect(response.media_type).to eq('application/json')
expect(json_response).to be_a(Hash)
end
expect(json_response).to match_schema('public_api/v4/packages/npm_package_tags')
expect(json_response.length).to eq(3) # two tags + latest (auto added)
expect(json_response[package_tag1.name]).to eq(package.version)
expect(json_response[package_tag2.name]).to eq(package.version)
expect(json_response['latest']).to eq(package.version)
it 'returns two package tags' do
subject
expect(json_response).to match_schema('public_api/v4/packages/npm_package_tags')
expect(json_response.length).to eq(3) # two tags + latest (auto added)
expect(json_response[package_tag1.name]).to eq(package.version)
expect(json_response[package_tag2.name]).to eq(package.version)
expect(json_response['latest']).to eq(package.version)
end
end
context 'with invalid package name' do
......@@ -49,47 +52,49 @@ RSpec.shared_examples 'returns package tags' do |user_type|
end
end
RSpec.shared_examples 'create package tag' do |user_type|
RSpec.shared_examples 'accept create package tag request' do |user_type|
using RSpec::Parameterized::TableSyntax
before do
project.send("add_#{user_type}", user) unless user_type == :no_type
end
context 'with valid package name' do
before do
package.update!(name: package_name) unless package_name == 'non-existing-package'
end
it_behaves_like 'returning response status', :no_content
it_behaves_like 'returning response status', :no_content
it 'creates the package tag' do
expect { subject }.to change { Packages::Tag.count }.by(1)
it 'creates the package tag' do
expect { subject }.to change { Packages::Tag.count }.by(1)
last_tag = Packages::Tag.last
expect(last_tag.name).to eq(tag_name)
expect(last_tag.package).to eq(package)
end
last_tag = Packages::Tag.last
expect(last_tag.name).to eq(tag_name)
expect(last_tag.package).to eq(package)
end
it 'returns a valid response' do
subject
it 'returns a valid response' do
subject
expect(response.body).to be_empty
end
expect(response.body).to be_empty
end
context 'with already existing tag' do
let_it_be(:package2) { create(:npm_package, project: project, name: package.name, version: '5.5.55') }
let_it_be(:tag) { create(:packages_tag, package: package2, name: tag_name) }
context 'with already existing tag' do
let_it_be(:package2) { create(:npm_package, project: project, name: package.name, version: '5.5.55') }
let_it_be(:tag) { create(:packages_tag, package: package2, name: tag_name) }
it_behaves_like 'returning response status', :no_content
it_behaves_like 'returning response status', :no_content
it 'reuses existing tag' do
expect(package.tags).to be_empty
expect(package2.tags).to eq([tag])
expect { subject }.to not_change { Packages::Tag.count }
expect(package.reload.tags).to eq([tag])
expect(package2.reload.tags).to be_empty
end
it 'reuses existing tag' do
expect(package.tags).to be_empty
expect(package2.tags).to eq([tag])
expect { subject }.to not_change { Packages::Tag.count }
expect(package.reload.tags).to eq([tag])
expect(package2.reload.tags).to be_empty
end
it 'returns a valid response' do
subject
it 'returns a valid response' do
subject
expect(response.body).to be_empty
expect(response.body).to be_empty
end
end
end
......@@ -129,14 +134,14 @@ RSpec.shared_examples 'create package tag' do |user_type|
end
end
RSpec.shared_examples 'delete package tag' do |user_type|
RSpec.shared_examples 'accept delete package tag request' do |user_type|
using RSpec::Parameterized::TableSyntax
before do
project.send("add_#{user_type}", user) unless user_type == :no_type
end
context 'with valid package name' do
before do
package.update!(name: package_name) unless package_name == 'non-existing-package'
end
context "for #{user_type} user" do
it_behaves_like 'returning response status', :no_content
it 'returns a valid response' do
......@@ -157,29 +162,29 @@ RSpec.shared_examples 'delete package tag' do |user_type|
it_behaves_like 'returning response status', :not_found
end
end
context 'with invalid package name' do
where(:package_name, :status) do
'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
context 'with invalid package name' do
where(:package_name, :status) do
'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
end
context 'with invalid tag name' do
where(:tag_name, :status) do
'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
context 'with invalid tag name' do
where(:tag_name, :status) do
'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment