Commit 729e3765 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 6f7881ee
...@@ -202,7 +202,6 @@ GitlabSecurity/PublicSend: ...@@ -202,7 +202,6 @@ GitlabSecurity/PublicSend:
Gitlab/DuplicateSpecLocation: Gitlab/DuplicateSpecLocation:
Exclude: Exclude:
- ee/spec/controllers/groups_controller_spec.rb
- ee/spec/controllers/projects/jobs_controller_spec.rb - ee/spec/controllers/projects/jobs_controller_spec.rb
- ee/spec/helpers/auth_helper_spec.rb - ee/spec/helpers/auth_helper_spec.rb
- ee/spec/lib/gitlab/gl_repository_spec.rb - ee/spec/lib/gitlab/gl_repository_spec.rb
...@@ -215,7 +214,6 @@ Gitlab/DuplicateSpecLocation: ...@@ -215,7 +214,6 @@ Gitlab/DuplicateSpecLocation:
- ee/spec/services/merge_requests/refresh_service_spec.rb - ee/spec/services/merge_requests/refresh_service_spec.rb
- ee/spec/services/merge_requests/update_service_spec.rb - ee/spec/services/merge_requests/update_service_spec.rb
- ee/spec/services/system_hooks_service_spec.rb - ee/spec/services/system_hooks_service_spec.rb
- ee/spec/controllers/ee/groups_controller_spec.rb
- ee/spec/controllers/ee/projects/jobs_controller_spec.rb - ee/spec/controllers/ee/projects/jobs_controller_spec.rb
- ee/spec/helpers/ee/auth_helper_spec.rb - ee/spec/helpers/ee/auth_helper_spec.rb
- ee/spec/lib/ee/gitlab/gl_repository_spec.rb - ee/spec/lib/ee/gitlab/gl_repository_spec.rb
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import discussionNavigation from '../mixins/discussion_navigation'; import discussionNavigation from '../mixins/discussion_navigation';
...@@ -18,13 +18,11 @@ export default { ...@@ -18,13 +18,11 @@ export default {
'getNoteableData', 'getNoteableData',
'resolvableDiscussionsCount', 'resolvableDiscussionsCount',
'unresolvedDiscussionsCount', 'unresolvedDiscussionsCount',
'discussions',
]), ]),
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
}, },
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
allResolved() { allResolved() {
return this.unresolvedDiscussionsCount === 0; return this.unresolvedDiscussionsCount === 0;
}, },
...@@ -34,6 +32,21 @@ export default { ...@@ -34,6 +32,21 @@ export default {
resolvedDiscussionsCount() { resolvedDiscussionsCount() {
return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
}, },
toggeableDiscussions() {
return this.discussions.filter(discussion => !discussion.individual_note);
},
allExpanded() {
return this.toggeableDiscussions.every(discussion => discussion.expanded);
},
},
methods: {
...mapActions(['setExpandDiscussions']),
handleExpandDiscussions() {
this.setExpandDiscussions({
discussionIds: this.toggeableDiscussions.map(discussion => discussion.id),
expanded: !this.allExpanded,
});
},
}, },
}; };
</script> </script>
...@@ -44,8 +57,8 @@ export default { ...@@ -44,8 +57,8 @@ export default {
ref="discussionCounter" ref="discussionCounter"
class="line-resolve-all-container full-width-mobile" class="line-resolve-all-container full-width-mobile"
> >
<div class="full-width-mobile d-flex d-sm-block"> <div class="full-width-mobile d-flex d-sm-flex">
<div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <div class="line-resolve-all">
<span <span
:class="{ 'is-active': allResolved }" :class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled" class="line-resolve-btn is-disabled"
...@@ -75,7 +88,7 @@ export default { ...@@ -75,7 +88,7 @@ export default {
<div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button <button
v-gl-tooltip v-gl-tooltip
title="Jump to next unresolved thread" :title="__('Jump to next unresolved thread')"
class="btn btn-default discussion-next-btn" class="btn btn-default discussion-next-btn"
data-track-event="click_button" data-track-event="click_button"
data-track-label="mr_next_unresolved_thread" data-track-label="mr_next_unresolved_thread"
...@@ -85,6 +98,16 @@ export default { ...@@ -85,6 +98,16 @@ export default {
<icon name="comment-next" /> <icon name="comment-next" />
</button> </button>
</div> </div>
<div v-if="isLoggedIn" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
:title="__('Toggle all threads')"
class="btn btn-default toggle-all-discussions-btn"
@click="handleExpandDiscussions"
>
<icon :name="allExpanded ? 'angle-up' : 'angle-down'" />
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -46,6 +46,10 @@ export const setNotesFetchedState = ({ commit }, state) => ...@@ -46,6 +46,10 @@ export const setNotesFetchedState = ({ commit }, state) =>
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const setExpandDiscussions = ({ commit }, { discussionIds, expanded }) => {
commit(types.SET_EXPAND_DISCUSSIONS, { discussionIds, expanded });
};
export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => { export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => {
const config = const config =
filter !== undefined filter !== undefined
...@@ -54,6 +58,7 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFi ...@@ -54,6 +58,7 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFi
return axios.get(path, config).then(({ data }) => { return axios.get(path, config).then(({ data }) => {
commit(types.SET_INITIAL_DISCUSSIONS, data); commit(types.SET_INITIAL_DISCUSSIONS, data);
dispatch('updateResolvableDiscussionsCounts'); dispatch('updateResolvableDiscussionsCounts');
}); });
}; };
......
...@@ -24,6 +24,7 @@ export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; ...@@ -24,6 +24,7 @@ export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const SET_EXPAND_DISCUSSIONS = 'SET_EXPAND_DISCUSSIONS';
export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID'; export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID';
......
...@@ -190,6 +190,15 @@ export default { ...@@ -190,6 +190,15 @@ export default {
}); });
}, },
[types.SET_EXPAND_DISCUSSIONS](state, { discussionIds, expanded }) {
if (discussionIds?.length) {
discussionIds.forEach(discussionId => {
const discussion = utils.findNoteObjectById(state.discussions, discussionId);
Object.assign(discussion, { expanded });
});
}
},
[types.UPDATE_NOTE](state, note) { [types.UPDATE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
......
<script> <script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import dateFormat from 'dateformat';
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import Icon from '~/vue_shared/components/icon.vue'; import { getTimeago } from '~/lib/utils/datetime_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue'; import ExpandButton from '~/vue_shared/components/expand_button.vue';
...@@ -12,7 +13,7 @@ export default { ...@@ -12,7 +13,7 @@ export default {
ClipboardButton, ClipboardButton,
ExpandButton, ExpandButton,
GlLink, GlLink,
Icon, GlIcon,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -24,17 +25,33 @@ export default { ...@@ -24,17 +25,33 @@ export default {
}, },
}, },
computed: { computed: {
evidenceTitle() { evidences() {
return sprintf(__('%{tag}-evidence.json'), { tag: this.release.tagName }); return this.release.evidences;
}, },
evidenceUrl() { },
return this.release.assets && this.release.assets.evidenceFilePath; methods: {
evidenceTitle(index) {
const [tag, evidence, filename] = this.release.evidences[index].filepath.split('/').slice(-3);
return sprintf(__('%{tag}-%{evidence}-%{filename}'), { tag, evidence, filename });
},
evidenceUrl(index) {
return this.release.evidences[index].filepath;
},
sha(index) {
return this.release.evidences[index].sha;
}, },
shortSha() { shortSha(index) {
return truncateSha(this.sha); return truncateSha(this.release.evidences[index].sha);
}, },
sha() { collectedAt(index) {
return this.release.evidenceSha; return dateFormat(this.release.evidences[index].collectedAt, 'mmmm dS, yyyy, h:MM TT');
},
timeSummary(index) {
const { format } = getTimeago();
const summary = sprintf(__(' Collected %{time}'), {
time: format(this.release.evidences[index].collectedAt),
});
return summary;
}, },
}, },
}; };
...@@ -43,34 +60,45 @@ export default { ...@@ -43,34 +60,45 @@ export default {
<template> <template>
<div> <div>
<div class="card-text prepend-top-default"> <div class="card-text prepend-top-default">
<b> <b>{{ __('Evidence collection') }}</b>
{{ __('Evidence collection') }}
</b>
</div> </div>
<div class="d-flex align-items-baseline"> <div v-for="(evidence, index) in evidences" :key="evidenceTitle(index)" class="mb-2">
<gl-link <div class="d-flex align-items-center">
v-gl-tooltip <gl-link
class="monospace" v-gl-tooltip
:title="__('Download evidence JSON')" class="d-flex align-items-center monospace"
:download="evidenceTitle" :title="__('Download evidence JSON')"
:href="evidenceUrl" :download="evidenceTitle(index)"
> :href="evidenceUrl(index)"
<icon name="review-list" class="align-top append-right-4" /><span>{{ evidenceTitle }}</span> >
</gl-link> <gl-icon name="review-list" class="align-middle append-right-8" />
<span>{{ evidenceTitle(index) }}</span>
</gl-link>
<expand-button>
<template slot="short">
<span class="js-short monospace">{{ shortSha(index) }}</span>
</template>
<template slot="expanded">
<span class="js-expanded monospace gl-pl-1">{{ sha(index) }}</span>
</template>
</expand-button>
<clipboard-button
:title="__('Copy evidence SHA')"
:text="sha(index)"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<expand-button> <div class="d-flex align-items-center text-muted">
<template slot="short"> <gl-icon
<span class="js-short monospace">{{ shortSha }}</span> v-gl-tooltip
</template> name="clock"
<template slot="expanded"> class="align-middle append-right-8"
<span class="js-expanded monospace gl-pl-1">{{ sha }}</span> :title="collectedAt(index)"
</template> />
</expand-button> <span>{{ timeSummary(index) }}</span>
<clipboard-button </div>
:title="__('Copy evidence SHA')"
:text="sha"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -44,7 +44,7 @@ export default { ...@@ -44,7 +44,7 @@ export default {
return this.release.assets || {}; return this.release.assets || {};
}, },
hasEvidence() { hasEvidence() {
return Boolean(this.release.evidenceSha); return Boolean(this.release.evidences && this.release.evidences.length);
}, },
milestones() { milestones() {
return this.release.milestones || []; return this.release.milestones || [];
......
...@@ -68,6 +68,23 @@ ...@@ -68,6 +68,23 @@
.header-user-avatar { .header-user-avatar {
border-color: $search-and-nav-links; border-color: $search-and-nav-links;
} }
.header-user-notification-dot {
border: 2px solid $nav-svg-color;
}
}
&:focus:hover,
&:focus {
&.header-user-dropdown-toggle .header-user-notification-dot {
border-color: $white-light;
}
}
&:hover {
&.header-user-dropdown-toggle .header-user-notification-dot {
border-color: $nav-svg-color + 33;
}
} }
&:hover, &:hover,
......
...@@ -567,6 +567,14 @@ ...@@ -567,6 +567,14 @@
border: 1px solid $gray-normal; border: 1px solid $gray-normal;
} }
.header-user-notification-dot {
background-color: $orange-500;
height: 10px;
width: 10px;
right: 8px;
top: -8px;
}
.with-performance-bar .navbar-gitlab { .with-performance-bar .navbar-gitlab {
top: $performance-bar-height; top: $performance-bar-height;
} }
......
...@@ -842,11 +842,11 @@ $note-form-margin-left: 72px; ...@@ -842,11 +842,11 @@ $note-form-margin-left: 72px;
white-space: nowrap; white-space: nowrap;
} }
.btn-group { .discussion-next-btn {
margin-left: -4px; border-radius: 0;
} }
.discussion-next-btn { .toggle-all-discussions-btn {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
...@@ -859,7 +859,6 @@ $note-form-margin-left: 72px; ...@@ -859,7 +859,6 @@ $note-form-margin-left: 72px;
} }
&.discussion-create-issue-btn { &.discussion-create-issue-btn {
margin-left: -4px;
border-radius: 0; border-radius: 0;
border-right: 0; border-right: 0;
...@@ -873,6 +872,10 @@ $note-form-margin-left: 72px; ...@@ -873,6 +872,10 @@ $note-form-margin-left: 72px;
} }
} }
} }
&.discussion-next-btn {
border-right: 0;
}
} }
} }
...@@ -884,12 +887,9 @@ $note-form-margin-left: 72px; ...@@ -884,12 +887,9 @@ $note-form-margin-left: 72px;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-default; border-radius: $border-radius-default;
font-size: $gl-btn-small-font-size; font-size: $gl-btn-small-font-size;
border-top-right-radius: 0;
&.has-next-btn { border-bottom-right-radius: 0;
border-top-right-radius: 0; border-right: 0;
border-bottom-right-radius: 0;
border-right: 0;
}
.line-resolve-btn { .line-resolve-btn {
margin-right: 5px; margin-right: 5px;
......
# frozen_string_literal: true
module Projects
module Releases
class EvidencesController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :release
before_action :authorize_read_release_evidence!
def show
respond_to do |format|
format.json do
render json: evidence.summary
end
end
end
private
def authorize_read_release_evidence!
access_denied! unless Feature.enabled?(:release_evidence, project, default_enabled: true)
access_denied! unless can?(current_user, :read_release_evidence, evidence)
end
def release
@release ||= project.releases.find_by_tag!(sanitized_tag_name)
end
def evidence
release.evidences.find(params[:id])
end
def sanitized_tag_name
CGI.unescape(params[:tag])
end
end
end
end
...@@ -11,7 +11,6 @@ class Projects::ReleasesController < Projects::ApplicationController ...@@ -11,7 +11,6 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_show_page, project, default_enabled: true) push_frontend_feature_flag(:release_show_page, project, default_enabled: true)
end end
before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_read_release_evidence!, only: [:evidence]
def index def index
respond_to do |format| respond_to do |format|
...@@ -22,14 +21,6 @@ class Projects::ReleasesController < Projects::ApplicationController ...@@ -22,14 +21,6 @@ class Projects::ReleasesController < Projects::ApplicationController
end end
end end
def evidence
respond_to do |format|
format.json do
render json: release.evidence_summary
end
end
end
def show def show
return render_404 unless Feature.enabled?(:release_show_page, project, default_enabled: true) return render_404 unless Feature.enabled?(:release_show_page, project, default_enabled: true)
...@@ -64,11 +55,6 @@ class Projects::ReleasesController < Projects::ApplicationController ...@@ -64,11 +55,6 @@ class Projects::ReleasesController < Projects::ApplicationController
access_denied! unless can?(current_user, :update_release, release) access_denied! unless can?(current_user, :update_release, release)
end end
def authorize_read_release_evidence!
access_denied! unless Feature.enabled?(:release_evidence, project, default_enabled: true)
access_denied! unless can?(current_user, :read_release_evidence, release)
end
def release def release
@release ||= project.releases.find_by_tag!(sanitized_tag_name) @release ||= project.releases.find_by_tag!(sanitized_tag_name)
end end
......
...@@ -52,10 +52,17 @@ class EventsFinder ...@@ -52,10 +52,17 @@ class EventsFinder
if current_user && scope == 'all' if current_user && scope == 'all'
EventCollection.new(current_user.authorized_projects).all_project_events EventCollection.new(current_user.authorized_projects).all_project_events
else else
source.events # EventCollection is responsible for applying the feature flag
apply_feature_flags(source.events)
end end
end end
def apply_feature_flags(events)
return events if ::Feature.enabled?(:wiki_events)
events.not_wiki_page
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events) def by_current_user_access(events)
events.merge(Project.public_or_visible_to_user(current_user)) events.merge(Project.public_or_visible_to_user(current_user))
......
...@@ -56,12 +56,17 @@ module Resolvers ...@@ -56,12 +56,17 @@ module Resolvers
# The project could have been loaded in batch by `BatchLoader`. # The project could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project to query for issues, so # At this point we need the `id` of the project to query for issues, so
# make sure it's loaded and not `nil` before continuing. # make sure it's loaded and not `nil` before continuing.
project = object.respond_to?(:sync) ? object.sync : object parent = object.respond_to?(:sync) ? object.sync : object
return Issue.none if project.nil? return Issue.none if parent.nil?
if parent.is_a?(Group)
args[:group_id] = parent.id
else
args[:project_id] = parent.id
end
# Will need to be be made group & namespace aware with # Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520
args[:project_id] = project.id
args[:iids] ||= [args[:iid]].compact args[:iids] ||= [args[:iid]].compact
args[:attempt_project_search_optimizations] = args[:search].present? args[:attempt_project_search_optimizations] = args[:search].present?
......
...@@ -43,6 +43,12 @@ module Types ...@@ -43,6 +43,12 @@ module Types
description: 'Parent group', description: 'Parent group',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
field :issues,
Types::IssueType.connection_type,
null: true,
description: 'Issues of the group',
resolver: Resolvers::IssuesResolver
field :milestones, Types::MilestoneType.connection_type, null: true, field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Find milestones', description: 'Find milestones',
resolver: Resolvers::MilestoneResolver resolver: Resolvers::MilestoneResolver
......
...@@ -65,6 +65,10 @@ module NavHelper ...@@ -65,6 +65,10 @@ module NavHelper
%w(groups#issues labels#index milestones#index boards#index boards#show) %w(groups#issues labels#index milestones#index boards#index boards#show)
end end
def show_user_notification_dot?
experiment_enabled?(:ci_notification_dot)
end
private private
def get_header_links def get_header_links
......
...@@ -36,6 +36,8 @@ class Event < ApplicationRecord ...@@ -36,6 +36,8 @@ class Event < ApplicationRecord
expired: EXPIRED expired: EXPIRED
).freeze ).freeze
WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze
TARGET_TYPES = HashWithIndifferentAccess.new( TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue, issue: Issue,
milestone: Milestone, milestone: Milestone,
...@@ -81,7 +83,10 @@ class Event < ApplicationRecord ...@@ -81,7 +83,10 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) } scope :code_push, -> { where(action: PUSHED) }
scope :merged, -> { where(action: MERGED) } scope :merged, -> { where(action: MERGED) }
scope :for_wiki_page, -> { where(target_type: WikiPage::Meta.name) } scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
# Needed to implement feature flag: can be removed when feature flag is removed
scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') }
scope :with_associations, -> do scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association # We're using preload for "push_event_payload" as otherwise the association
...@@ -229,7 +234,7 @@ class Event < ApplicationRecord ...@@ -229,7 +234,7 @@ class Event < ApplicationRecord
end end
def wiki_page? def wiki_page?
target_type == WikiPage::Meta.name target_type == 'WikiPage::Meta'
end end
def milestone def milestone
......
...@@ -33,16 +33,23 @@ class EventCollection ...@@ -33,16 +33,23 @@ class EventCollection
project_events project_events
end end
relation = apply_feature_flags(relation)
relation = paginate_events(relation) relation = paginate_events(relation)
relation.with_associations.to_a relation.with_associations.to_a
end end
def all_project_events def all_project_events
Event.from_union([project_events]).recent apply_feature_flags(Event.from_union([project_events]).recent)
end end
private private
def apply_feature_flags(events)
return events if ::Feature.enabled?(:wiki_events)
events.not_wiki_page
end
def project_events def project_events
relation_with_join_lateral('project_id', projects) relation_with_join_lateral('project_id', projects)
end end
......
...@@ -78,8 +78,6 @@ class Issue < ApplicationRecord ...@@ -78,8 +78,6 @@ class Issue < ApplicationRecord
scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
ignore_column :state, remove_with: '12.10', remove_after: '2020-03-22'
after_commit :expire_etag_cache, unless: :importing? after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing? after_save :ensure_metrics, unless: :importing?
......
...@@ -261,8 +261,6 @@ class MergeRequest < ApplicationRecord ...@@ -261,8 +261,6 @@ class MergeRequest < ApplicationRecord
includes(:metrics) includes(:metrics)
end end
ignore_column :state, remove_with: '12.10', remove_after: '2020-03-22'
after_save :keep_around_commit, unless: :importing? after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project alias_attribute :project, :target_project
......
...@@ -16,7 +16,7 @@ class Release < ApplicationRecord ...@@ -16,7 +16,7 @@ class Release < ApplicationRecord
has_many :milestone_releases has_many :milestone_releases
has_many :milestones, through: :milestone_releases has_many :milestones, through: :milestone_releases
has_one :evidence has_many :evidences, inverse_of: :release, class_name: 'Releases::Evidence'
default_value_for :released_at, allows_nil: false do default_value_for :released_at, allows_nil: false do
Time.zone.now Time.zone.now
...@@ -28,7 +28,7 @@ class Release < ApplicationRecord ...@@ -28,7 +28,7 @@ class Release < ApplicationRecord
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
scope :sorted, -> { order(released_at: :desc) } scope :sorted, -> { order(released_at: :desc) }
scope :preloaded, -> { includes(project: :namespace) } scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) }
scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :with_project_and_namespace, -> { includes(project: :namespace) }
scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) }
...@@ -66,27 +66,27 @@ class Release < ApplicationRecord ...@@ -66,27 +66,27 @@ class Release < ApplicationRecord
end end
def upcoming_release? def upcoming_release?
released_at.present? && released_at > Time.zone.now released_at.present? && released_at.to_i > Time.zone.now.to_i
end end
def historical_release? def historical_release?
released_at.present? && released_at < created_at released_at.present? && released_at.to_i < created_at.to_i
end end
def name def name
self.read_attribute(:name) || tag self.read_attribute(:name) || tag
end end
def evidence_sha def milestone_titles
evidence&.summary_sha self.milestones.map {|m| m.title }.sort.join(", ")
end end
def evidence_summary def evidence_sha
evidence&.summary || {} evidences.first&.summary_sha
end end
def milestone_titles def evidence_summary
self.milestones.map {|m| m.title }.sort.join(", ") evidences.first&.summary || {}
end end
private private
......
# frozen_string_literal: true # frozen_string_literal: true
class Evidence < ApplicationRecord class Releases::Evidence < ApplicationRecord
include ShaAttribute include ShaAttribute
include Presentable
belongs_to :release belongs_to :release, inverse_of: :evidences
before_validation :generate_summary_and_sha before_validation :generate_summary_and_sha
default_scope { order(created_at: :asc) } default_scope { order(created_at: :asc) }
sha_attribute :summary_sha sha_attribute :summary_sha
alias_attribute :collected_at, :created_at
def milestones def milestones
@milestones ||= release.milestones.includes(:issues) @milestones ||= release.milestones.includes(:issues)
......
...@@ -2,31 +2,4 @@ ...@@ -2,31 +2,4 @@
class ReleasePolicy < BasePolicy class ReleasePolicy < BasePolicy
delegate { @subject.project } delegate { @subject.project }
rule { allowed_to_read_evidence & external_authorization_service_disabled }.policy do
enable :read_release_evidence
end
##
# evidence.summary includes the following entities:
# - Release
# - git-tag (Repository)
# - Project
# - Milestones
# - Issues
condition(:allowed_to_read_evidence) do
can?(:read_release) &&
can?(:download_code) &&
can?(:read_project) &&
can?(:read_milestone) &&
can?(:read_issue)
end
##
# Currently, we don't support release evidence for the GitLab instances
# that enables external authorization services.
# See https://gitlab.com/gitlab-org/gitlab/issues/121930.
condition(:external_authorization_service_disabled) do
!Gitlab::ExternalAuthorization::Config.enabled?
end
end end
# frozen_string_literal: true
module Releases
class EvidencePolicy < BasePolicy
delegate { @subject.release.project }
rule { allowed_to_read_evidence & external_authorization_service_disabled }.policy do
enable :read_release_evidence
end
##
# evidence.summary includes the following entities:
# - Release
# - git-tag (Repository)
# - Project
# - Milestones
# - Issues
condition(:allowed_to_read_evidence) do
can?(:read_release) &&
can?(:download_code) &&
can?(:read_project) &&
can?(:read_milestone) &&
can?(:read_issue)
end
##
# Currently, we don't support release evidence for the GitLab instances
# that enables external authorization services.
# See https://gitlab.com/gitlab-org/gitlab/issues/121930.
condition(:external_authorization_service_disabled) do
!Gitlab::ExternalAuthorization::Config.enabled?
end
end
end
...@@ -44,9 +44,10 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated ...@@ -44,9 +44,10 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
end end
def evidence_file_path def evidence_file_path
return unless release.evidence.present? evidence = release.evidences.first
return unless evidence
evidence_project_release_url(project, release.to_param, format: :json) project_evidence_url(project, release, evidence, format: :json)
end end
private private
......
# frozen_string_literal: true
module Releases
class EvidencePresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
presents :evidence
def filepath
release = evidence.release
project = release.project
project_evidence_url(project, release, evidence, format: :json)
end
end
end
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
# EventCreateService.new.new_issue(issue, current_user) # EventCreateService.new.new_issue(issue, current_user)
# #
class EventCreateService class EventCreateService
IllegalActionError = Class.new(StandardError)
def open_issue(issue, current_user) def open_issue(issue, current_user)
create_record_event(issue, current_user, Event::CREATED) create_record_event(issue, current_user, Event::CREATED)
end end
...@@ -80,6 +82,19 @@ class EventCreateService ...@@ -80,6 +82,19 @@ class EventCreateService
create_push_event(BulkPushEventPayloadService, project, current_user, push_data) create_push_event(BulkPushEventPayloadService, project, current_user, push_data)
end end
# Create a new wiki page event
#
# @param [WikiPage::Meta] wiki_page_meta The event target
# @param [User] current_user The event author
# @param [Integer] action One of the Event::WIKI_ACTIONS
def wiki_event(wiki_page_meta, current_user, action)
return unless Feature.enabled?(:wiki_events)
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
create_record_event(wiki_page_meta, current_user, action)
end
private private
def create_record_event(record, current_user, status) def create_record_event(record, current_user, status)
......
# frozen_string_literal: true # frozen_string_literal: true
module WikiPages module WikiPages
# There are 3 notions of 'action' that inheriting classes must implement:
#
# - external_action: the action we report to external clients with webhooks
# - usage_counter_action: the action that we count in out internal counters
# - event_action: what we record as the value of `Event#action`
class BaseService < ::BaseService class BaseService < ::BaseService
private private
def execute_hooks(page, action = 'create') def execute_hooks(page)
page_data = Gitlab::DataBuilder::WikiPage.build(page, current_user, action) page_data = payload(page)
@project.execute_hooks(page_data, :wiki_page_hooks) @project.execute_hooks(page_data, :wiki_page_hooks)
@project.execute_services(page_data, :wiki_page_hooks) @project.execute_services(page_data, :wiki_page_hooks)
increment_usage(action) increment_usage
create_wiki_event(page)
end
# Passed to web-hooks, and send to external consumers.
def external_action
raise NotImplementedError
end
# Passed to the WikiPageCounter to count events.
# Must be one of WikiPageCounter::KNOWN_EVENTS
def usage_counter_action
raise NotImplementedError
end
# Used to create `Event` records.
# Must be a valid value for `Event#action`
def event_action
raise NotImplementedError
end
def payload(page)
Gitlab::DataBuilder::WikiPage.build(page, current_user, external_action)
end end
# This method throws an error if the action is an unanticipated value. # This method throws an error if the action is an unanticipated value.
def increment_usage(action) def increment_usage
Gitlab::UsageDataCounters::WikiPageCounter.count(action) Gitlab::UsageDataCounters::WikiPageCounter.count(usage_counter_action)
end
def create_wiki_event(page)
return unless ::Feature.enabled?(:wiki_events)
slug = slug_for_page(page)
Event.transaction do
wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action)
end
end
def slug_for_page(page)
page.slug
end end
end end
end end
......
...@@ -7,10 +7,22 @@ module WikiPages ...@@ -7,10 +7,22 @@ module WikiPages
page = WikiPage.new(project_wiki) page = WikiPage.new(project_wiki)
if page.create(@params) if page.create(@params)
execute_hooks(page, 'create') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:create
end
def external_action
'create'
end
def event_action
Event::CREATED
end
end end
end end
...@@ -4,10 +4,22 @@ module WikiPages ...@@ -4,10 +4,22 @@ module WikiPages
class DestroyService < WikiPages::BaseService class DestroyService < WikiPages::BaseService
def execute(page) def execute(page)
if page&.delete if page&.delete
execute_hooks(page, 'delete') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:delete
end
def external_action
'delete'
end
def event_action
Event::DESTROYED
end
end end
end end
...@@ -3,11 +3,30 @@ ...@@ -3,11 +3,30 @@
module WikiPages module WikiPages
class UpdateService < WikiPages::BaseService class UpdateService < WikiPages::BaseService
def execute(page) def execute(page)
# this class is not thread safe!
@old_slug = page.slug
if page.update(@params) if page.update(@params)
execute_hooks(page, 'update') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:update
end
def external_action
'update'
end
def event_action
Event::UPDATED
end
def slug_for_page(page)
@old_slug.presence || super
end
end end
end end
...@@ -68,6 +68,8 @@ ...@@ -68,6 +68,8 @@
%li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) } %li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
- if show_user_notification_dot?
%span.header-user-notification-dot.rounded-circle.position-relative
= sprite_icon('angle-down', css_class: 'caret-down') = sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right .dropdown-menu.dropdown-menu-right
= render 'layouts/header/current_user_dropdown' = render 'layouts/header/current_user_dropdown'
......
...@@ -10,6 +10,6 @@ class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker ...@@ -10,6 +10,6 @@ class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
release = Release.find_by_id(release_id) release = Release.find_by_id(release_id)
return unless release return unless release
Evidence.create!(release: release) Releases::Evidence.create!(release: release)
end end
end end
---
title: Add issues to graphQL group endpoint
merge_request: 27789
author:
type: added
---
title: Support multiple Evidences for a Release
merge_request: 26509
author:
type: changed
---
title: Cache ES enabled namespaces and projects
merge_request: 27348
author:
type: performance
---
title: Expose created_at property in Groups API
merge_request: 27824
author:
type: added
---
title: Add toggle all discussions button to MRs
merge_request: 24670
author: Martin Hobert & Diego Louzán
type: added
---
title: "Run SAST using awk to pass env variables directly to docker without creating .env file"
merge_request: 21174
author: Florian Gaultier
type: fixed
...@@ -170,8 +170,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -170,8 +170,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :releases, only: [:index, :show, :edit], param: :tag, constraints: { tag: %r{[^/]+} } do resources :releases, only: [:index, :show, :edit], param: :tag, constraints: { tag: %r{[^/]+} } do
member do member do
get :evidence
get :downloads, path: 'downloads/*filepath', format: false get :downloads, path: 'downloads/*filepath', format: false
scope module: :releases do
resources :evidences, only: [:show]
end
end end
end end
......
...@@ -3219,6 +3219,106 @@ type Group { ...@@ -3219,6 +3219,106 @@ type Group {
""" """
id: ID! id: ID!
"""
Issues of the group
"""
issues(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
ID of a user assigned to the issues, "none" and "any" values supported
"""
assigneeId: String
"""
Username of a user assigned to the issues
"""
assigneeUsername: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Issues closed after this date
"""
closedAfter: Time
"""
Issues closed before this date
"""
closedBefore: Time
"""
Issues created after this date
"""
createdAfter: Time
"""
Issues created before this date
"""
createdBefore: Time
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
IID of the issue. For example, "1"
"""
iid: String
"""
List of IIDs of issues. For example, [1, 2]
"""
iids: [String!]
"""
Labels applied to this issue
"""
labelName: [String]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Milestones applied to this issue
"""
milestoneTitle: [String]
"""
Search query for finding issues by title or description
"""
search: String
"""
Sort issues by this criteria
"""
sort: IssueSort = created_desc
"""
Current state of this issue
"""
state: IssuableState
"""
Issues updated after this date
"""
updatedAfter: Time
"""
Issues updated before this date
"""
updatedBefore: Time
): IssueConnection
""" """
Indicates if Large File Storage (LFS) is enabled for namespace Indicates if Large File Storage (LFS) is enabled for namespace
""" """
......
...@@ -9242,6 +9242,225 @@ ...@@ -9242,6 +9242,225 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "issues",
"description": "Issues of the group",
"args": [
{
"name": "iid",
"description": "IID of the issue. For example, \"1\"",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "iids",
"description": "List of IIDs of issues. 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": "state",
"description": "Current state of this issue",
"type": {
"kind": "ENUM",
"name": "IssuableState",
"ofType": null
},
"defaultValue": null
},
{
"name": "labelName",
"description": "Labels applied to this issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "milestoneTitle",
"description": "Milestones applied to this issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "createdBefore",
"description": "Issues created before this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "createdAfter",
"description": "Issues created after this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "updatedBefore",
"description": "Issues updated before this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "updatedAfter",
"description": "Issues updated after this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "closedBefore",
"description": "Issues closed before this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "closedAfter",
"description": "Issues closed after this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "search",
"description": "Search query for finding issues by title or description",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort issues by this criteria",
"type": {
"kind": "ENUM",
"name": "IssueSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"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": "IssueConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "lfsEnabled", "name": "lfsEnabled",
"description": "Indicates if Large File Storage (LFS) is enabled for namespace", "description": "Indicates if Large File Storage (LFS) is enabled for namespace",
......
...@@ -49,7 +49,8 @@ GET /groups ...@@ -49,7 +49,8 @@ GET /groups
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1, "file_template_project_id": 1,
"parent_id": null "parent_id": null,
"created_at": "2020-01-15T12:36:29.590Z"
} }
] ]
``` ```
...@@ -85,6 +86,7 @@ GET /groups?statistics=true ...@@ -85,6 +86,7 @@ GET /groups?statistics=true
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1, "file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"created_at": "2020-01-15T12:36:29.590Z",
"statistics": { "statistics": {
"storage_size" : 212, "storage_size" : 212,
"repository_size" : 33, "repository_size" : 33,
...@@ -157,7 +159,8 @@ GET /groups/:id/subgroups ...@@ -157,7 +159,8 @@ GET /groups/:id/subgroups
"full_name": "Foobar Group", "full_name": "Foobar Group",
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1, "file_template_project_id": 1,
"parent_id": 123 "parent_id": 123,
"created_at": "2020-01-15T12:36:29.590Z"
} }
] ]
``` ```
...@@ -282,6 +285,7 @@ Example response: ...@@ -282,6 +285,7 @@ Example response:
"runners_token": "ba324ca7b1c77fc20bb9", "runners_token": "ba324ca7b1c77fc20bb9",
"file_template_project_id": 1, "file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"created_at": "2020-01-15T12:36:29.590Z",
"projects": [ "projects": [
{ {
"id": 7, "id": 7,
...@@ -591,6 +595,7 @@ Example response: ...@@ -591,6 +595,7 @@ Example response:
"full_path": "foo-bar", "full_path": "foo-bar",
"file_template_project_id": 1, "file_template_project_id": 1,
"parent_id": null, "parent_id": null,
"created_at": "2020-01-15T12:36:29.590Z",
"projects": [ "projects": [
{ {
"id": 9, "id": 9,
......
...@@ -426,6 +426,15 @@ There are several rake tasks available to you via the command line: ...@@ -426,6 +426,15 @@ There are several rake tasks available to you via the command line:
- Performs an Elasticsearch import that indexes the snippets data. - Performs an Elasticsearch import that indexes the snippets data.
- [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake) - [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake)
- Displays which projects are not indexed. - Displays which projects are not indexed.
- [`sudo gitlab-rake gitlab:elastic:reindex_to_another_cluster[<SOURCE_CLUSTER_URL>,<DESTINATION_CLUSTER_URL>]`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake)
- Creates a new index in the destination cluster and triggers a [reindex from
remote](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html#reindex-from-remote)
such that the index is fully copied from the source index. This can be
useful when you wish to perform a migration to a new cluster as this
reindexing should be quicker than reindexing via GitLab. Note that remote
reindex requires your source cluster to be whitelisted in your destination
cluster in Elasticsearch settings as per [the
documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html#reindex-from-remote).
### Environment Variables ### Environment Variables
......
...@@ -9,6 +9,7 @@ module API ...@@ -9,6 +9,7 @@ module API
expose :created_at expose :created_at
expose :note, using: Entities::Note, if: ->(event, options) { event.note? } expose :note, using: Entities::Note, if: ->(event, options) { event.note? }
expose :author, using: Entities::UserBasic, if: ->(event, options) { event.author } expose :author, using: Entities::UserBasic, if: ->(event, options) { event.author }
expose :wiki_page, using: Entities::WikiPageBasic, if: ->(event, _options) { event.wiki_page? }
expose :push_event_payload, expose :push_event_payload,
as: :push_data, as: :push_data,
......
...@@ -19,6 +19,7 @@ module API ...@@ -19,6 +19,7 @@ module API
end end
expose :request_access_enabled expose :request_access_enabled
expose :full_name, :full_path expose :full_name, :full_path
expose :created_at
expose :parent_id expose :parent_id
expose :custom_attributes, using: 'API::Entities::CustomAttribute', if: :with_custom_attributes expose :custom_attributes, using: 'API::Entities::CustomAttribute', if: :with_custom_attributes
......
...@@ -22,6 +22,7 @@ module API ...@@ -22,6 +22,7 @@ module API
expose :commit_path, expose_nil: false expose :commit_path, expose_nil: false
expose :tag_path, expose_nil: false expose :tag_path, expose_nil: false
expose :evidence_sha, expose_nil: false, if: ->(_, _) { can_download_code? } expose :evidence_sha, expose_nil: false, if: ->(_, _) { can_download_code? }
expose :assets do expose :assets do
expose :assets_count, as: :count do |release, _| expose :assets_count, as: :count do |release, _|
assets_to_exclude = can_download_code? ? [] : [:sources] assets_to_exclude = can_download_code? ? [] : [:sources]
...@@ -33,6 +34,7 @@ module API ...@@ -33,6 +34,7 @@ module API
end end
expose :evidence_file_path, expose_nil: false, if: ->(_, _) { can_download_code? } expose :evidence_file_path, expose_nil: false, if: ->(_, _) { can_download_code? }
end end
expose :evidences, using: Entities::Releases::Evidence, expose_nil: false, if: ->(_, _) { can_download_code? }
expose :_links do expose :_links do
expose :self_url, as: :self, expose_nil: false expose :self_url, as: :self, expose_nil: false
expose :merge_requests_url, expose_nil: false expose :merge_requests_url, expose_nil: false
......
# frozen_string_literal: true
module API
module Entities
module Releases
class Evidence < Grape::Entity
include ::API::Helpers::Presentable
expose :summary_sha, as: :sha
expose :filepath
expose :collected_at
end
end
end
end
...@@ -4,7 +4,7 @@ module API ...@@ -4,7 +4,7 @@ module API
module Helpers module Helpers
## ##
# This module makes it possible to use `app/presenters` with # This module makes it possible to use `app/presenters` with
# Grape Entities. It instantiates model presenter and passes # Grape Entities. It instantiates the model presenter and passes
# options defined in the API endpoint to the presenter itself. # options defined in the API endpoint to the presenter itself.
# #
# present object, with: Entities::Something, # present object, with: Entities::Something,
...@@ -22,6 +22,7 @@ module API ...@@ -22,6 +22,7 @@ module API
extend ActiveSupport::Concern extend ActiveSupport::Concern
def initialize(object, options = {}) def initialize(object, options = {})
options = options.opts_hash if options.is_a?(Grape::Entity::Options)
super(object.present(options), options) super(object.present(options), options)
end end
end end
......
...@@ -9,6 +9,7 @@ class EventFilter ...@@ -9,6 +9,7 @@ class EventFilter
ISSUE = 'issue' ISSUE = 'issue'
COMMENTS = 'comments' COMMENTS = 'comments'
TEAM = 'team' TEAM = 'team'
WIKI = 'wiki'
def initialize(filter) def initialize(filter)
# Split using comma to maintain backward compatibility Ex/ "filter1,filter2" # Split using comma to maintain backward compatibility Ex/ "filter1,filter2"
...@@ -22,6 +23,8 @@ class EventFilter ...@@ -22,6 +23,8 @@ class EventFilter
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def apply_filter(events) def apply_filter(events)
events = apply_feature_flags(events)
case filter case filter
when PUSH when PUSH
events.where(action: Event::PUSHED) events.where(action: Event::PUSHED)
...@@ -33,6 +36,8 @@ class EventFilter ...@@ -33,6 +36,8 @@ class EventFilter
events.where(action: [Event::JOINED, Event::LEFT, Event::EXPIRED]) events.where(action: [Event::JOINED, Event::LEFT, Event::EXPIRED])
when ISSUE when ISSUE
events.where(action: [Event::CREATED, Event::UPDATED, Event::CLOSED, Event::REOPENED], target_type: 'Issue') events.where(action: [Event::CREATED, Event::UPDATED, Event::CLOSED, Event::REOPENED], target_type: 'Issue')
when WIKI
wiki_events(events)
else else
events events
end end
...@@ -41,8 +46,20 @@ class EventFilter ...@@ -41,8 +46,20 @@ class EventFilter
private private
def apply_feature_flags(events)
return events.not_wiki_page unless Feature.enabled?(:wiki_events)
events
end
def wiki_events(events)
return events unless Feature.enabled?(:wiki_events)
events.for_wiki_page
end
def filters def filters
[ALL, PUSH, MERGED, ISSUE, COMMENTS, TEAM] [ALL, PUSH, MERGED, ISSUE, COMMENTS, TEAM, WIKI]
end end
end end
......
...@@ -36,9 +36,9 @@ sast: ...@@ -36,9 +36,9 @@ sast:
export DOCKER_HOST='tcp://localhost:2375' export DOCKER_HOST='tcp://localhost:2375'
fi fi
fi fi
- ENVS=`printenv | grep -vE '^(DOCKER_|CI|GITLAB_|FF_|HOME|PWD|OLDPWD|PATH|SHLVL|HOSTNAME)' | sed -n '/^[^\t]/s/=.*//p' | sed '/^$/d' | sed 's/^/-e /g' | tr '\n' ' '`
- | - |
docker run $ENVS \ docker run \
$(awk 'BEGIN{for(v in ENVIRON) print v}' | grep -v -E '^(DOCKER_|CI|GITLAB_|FF_|HOME|PWD|OLDPWD|PATH|SHLVL|HOSTNAME)' | awk '{printf " -e %s", $0}') \
--volume "$PWD:/code" \ --volume "$PWD:/code" \
--volume /var/run/docker.sock:/var/run/docker.sock \ --volume /var/run/docker.sock:/var/run/docker.sock \
"registry.gitlab.com/gitlab-org/security-products/sast:$SAST_VERSION" /app/bin/run /code "registry.gitlab.com/gitlab-org/security-products/sast:$SAST_VERSION" /app/bin/run /code
......
...@@ -28,6 +28,12 @@ module Gitlab ...@@ -28,6 +28,12 @@ module Gitlab
environment: ::Gitlab.dev_env_or_com?, environment: ::Gitlab.dev_env_or_com?,
enabled_ratio: 0.1, enabled_ratio: 0.1,
tracking_category: 'Growth::Expansion::Experiment::SuggestPipeline' tracking_category: 'Growth::Expansion::Experiment::SuggestPipeline'
},
ci_notification_dot: {
feature_toggle: :ci_notification_dot,
environment: ::Gitlab.dev_env_or_com?,
enabled_ratio: 0.1,
tracking_category: 'Growth::Expansion::Experiment::CiNotificationDot'
} }
}.freeze }.freeze
......
...@@ -22,6 +22,9 @@ msgstr "" ...@@ -22,6 +22,9 @@ msgstr ""
msgid " (from %{timeoutSource})" msgid " (from %{timeoutSource})"
msgstr "" msgstr ""
msgid " Collected %{time}"
msgstr ""
msgid " Please sign in." msgid " Please sign in."
msgstr "" msgstr ""
...@@ -475,7 +478,7 @@ msgstr "" ...@@ -475,7 +478,7 @@ msgstr ""
msgid "%{tags} tags per image name" msgid "%{tags} tags per image name"
msgstr "" msgstr ""
msgid "%{tag}-evidence.json" msgid "%{tag}-%{evidence}-%{filename}"
msgstr "" msgstr ""
msgid "%{template_project_id} is unknown or invalid" msgid "%{template_project_id} is unknown or invalid"
...@@ -21006,6 +21009,9 @@ msgstr "" ...@@ -21006,6 +21009,9 @@ msgstr ""
msgid "Toggle Sidebar" msgid "Toggle Sidebar"
msgstr "" msgstr ""
msgid "Toggle all threads"
msgstr ""
msgid "Toggle backtrace" msgid "Toggle backtrace"
msgstr "" msgstr ""
......
...@@ -72,15 +72,17 @@ end ...@@ -72,15 +72,17 @@ end
# Define suffix in review app URL based on project # Define suffix in review app URL based on project
# #
def slug def slug
case ENV["CI_PROJECT_NAME"] case ENV["CI_PROJECT_PATH"]
when 'gitlab-foss' when 'gitlab-org/gitlab-foss'
'ce' 'ce'
when 'gitlab' when 'gitlab-org/gitlab'
'ee' 'ee'
when 'gitlab-runner' when 'gitlab-org/gitlab-runner'
'runner' 'runner'
when 'omnibus-gitlab' when 'gitlab-org/omnibus-gitlab'
'omnibus' 'omnibus'
when 'gitlab-org/charts/gitlab'
'charts'
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Releases::EvidencesController do
let!(:project) { create(:project, :repository, :public) }
let_it_be(:private_project) { create(:project, :repository, :private) }
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
let(:user) { developer }
before do
project.add_developer(developer)
project.add_reporter(reporter)
end
shared_examples_for 'successful request' do
it 'renders a 200' do
subject
expect(response).to have_gitlab_http_status(:success)
end
end
shared_examples_for 'not found' do
it 'renders 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe 'GET #show' do
let_it_be(:tag_name) { "v1.1.0-evidence" }
let!(:release) { create(:release, :with_evidence, project: project, tag: tag_name) }
let(:evidence) { release.evidences.first }
let(:tag) { CGI.escape(release.tag) }
let(:format) { :json }
subject do
get :show, params: {
namespace_id: project.namespace.to_param,
project_id: project,
tag: tag,
id: evidence.id,
format: format
}
end
before do
sign_in(user)
end
context 'when the user is a developer' do
it 'returns the correct evidence summary as a json' do
subject
expect(json_response).to eq(evidence.summary)
end
context 'when the release was created before evidence existed' do
before do
evidence.destroy
end
it_behaves_like 'not found'
end
end
context 'when the user is a guest for the project' do
before do
project.add_guest(user)
end
context 'when the project is private' do
let(:project) { private_project }
it_behaves_like 'not found'
end
context 'when the project is public' do
it_behaves_like 'successful request'
end
end
context 'when release is associated to a milestone which includes an issue' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:milestone) { create(:milestone, project: project, issues: [issue]) }
let_it_be(:release) { create(:release, project: project, tag: tag_name, milestones: [milestone]) }
before do
create(:evidence, release: release)
end
shared_examples_for 'does not show the issue in evidence' do
it do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['release']['milestones']
.all? { |milestone| milestone['issues'].nil? }).to eq(true)
end
end
shared_examples_for 'evidence not found' do
it do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
shared_examples_for 'safely expose evidence' do
it_behaves_like 'does not show the issue in evidence'
context 'when the issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when the user is the author of the confidential issue' do
let(:issue) { create(:issue, :confidential, project: project, author: user) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when project is private' do
let!(:project) { create(:project, :repository, :private) }
it_behaves_like 'evidence not found'
end
context 'when project restricts the visibility of issues to project members only' do
let!(:project) { create(:project, :repository, :issues_private) }
it_behaves_like 'evidence not found'
end
end
context 'when user is non-project member' do
let(:user) { create(:user) }
it_behaves_like 'safely expose evidence'
end
context 'when user is auditor', if: Gitlab.ee? do
let(:user) { create(:user, :auditor) }
it_behaves_like 'safely expose evidence'
end
context 'when external authorization control is enabled' do
let(:user) { create(:user) }
before do
stub_application_setting(external_authorization_service_enabled: true)
end
it_behaves_like 'evidence not found'
end
end
end
end
...@@ -3,11 +3,11 @@ ...@@ -3,11 +3,11 @@
require 'spec_helper' require 'spec_helper'
describe Projects::ReleasesController do describe Projects::ReleasesController do
let!(:project) { create(:project, :repository, :public) } let!(:project) { create(:project, :repository, :public) }
let!(:private_project) { create(:project, :repository, :private) } let_it_be(:private_project) { create(:project, :repository, :private) }
let(:user) { developer } let_it_be(:developer) { create(:user) }
let(:developer) { create(:user) } let_it_be(:reporter) { create(:user) }
let(:reporter) { create(:user) } let_it_be(:user) { developer }
let!(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) } let!(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) }
let!(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) } let!(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) }
...@@ -295,141 +295,6 @@ describe Projects::ReleasesController do ...@@ -295,141 +295,6 @@ describe Projects::ReleasesController do
end end
end end
describe 'GET #evidence' do
let_it_be(:tag_name) { "v1.1.0-evidence" }
let!(:release) { create(:release, :with_evidence, project: project, tag: tag_name) }
let(:tag) { CGI.escape(release.tag) }
let(:format) { :json }
subject do
get :evidence, params: {
namespace_id: project.namespace,
project_id: project,
tag: tag,
format: format
}
end
before do
sign_in(user)
end
context 'when the user is a developer' do
it 'returns the correct evidence summary as a json' do
subject
expect(json_response).to eq(release.evidence.summary)
end
context 'when the release was created before evidence existed' do
before do
release.evidence.destroy
end
it 'returns an empty json' do
subject
expect(json_response).to eq({})
end
end
end
context 'when the user is a guest for the project' do
before do
project.add_guest(user)
end
context 'when the project is private' do
let(:project) { private_project }
it_behaves_like 'not found'
end
context 'when the project is public' do
it_behaves_like 'successful request'
end
end
context 'when release is associated to a milestone which includes an issue' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:milestone) { create(:milestone, project: project, issues: [issue]) }
let_it_be(:release) { create(:release, project: project, tag: tag_name, milestones: [milestone]) }
before do
create(:evidence, release: release)
end
shared_examples_for 'does not show the issue in evidence' do
it do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['release']['milestones']
.all? { |milestone| milestone['issues'].nil? }).to eq(true)
end
end
shared_examples_for 'evidence not found' do
it do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
shared_examples_for 'safely expose evidence' do
it_behaves_like 'does not show the issue in evidence'
context 'when the issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when the user is the author of the confidential issue' do
let(:issue) { create(:issue, :confidential, project: project, author: user) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when project is private' do
let!(:project) { create(:project, :repository, :private) }
it_behaves_like 'evidence not found'
end
context 'when project restricts the visibility of issues to project members only' do
let!(:project) { create(:project, :repository, :issues_private) }
it_behaves_like 'evidence not found'
end
end
context 'when user is non-project member' do
let(:user) { create(:user) }
it_behaves_like 'safely expose evidence'
end
context 'when user is auditor', if: Gitlab.ee? do
let(:user) { create(:user, :auditor) }
it_behaves_like 'safely expose evidence'
end
context 'when external authorization control is enabled' do
let(:user) { create(:user) }
before do
stub_application_setting(external_authorization_service_enabled: true)
end
it_behaves_like 'evidence not found'
end
end
end
private private
def get_index def get_index
......
...@@ -25,12 +25,12 @@ FactoryBot.define do ...@@ -25,12 +25,12 @@ FactoryBot.define do
factory :wiki_page_event do factory :wiki_page_event do
action { Event::CREATED } action { Event::CREATED }
project { @overrides[:wiki_page]&.project || create(:project, :wiki_repo) }
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do transient do
wiki_page { create(:wiki_page, project: project) } wiki_page { create(:wiki_page, project: project) }
end end
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :evidence do factory :evidence, class: 'Releases::Evidence' do
release release
end end
end end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
describe EventsFinder do describe EventsFinder do
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:other_user) { create(:user) } let(:other_user) { create(:user) }
let(:project1) { create(:project, :private, creator_id: user.id, namespace: user.namespace) } let(:project1) { create(:project, :private, creator_id: user.id, namespace: user.namespace) }
...@@ -20,7 +20,7 @@ describe EventsFinder do ...@@ -20,7 +20,7 @@ describe EventsFinder do
let(:opened_merge_request3) { create(:merge_request, source_project: project1, author: other_user) } let(:opened_merge_request3) { create(:merge_request, source_project: project1, author: other_user) }
let!(:other_developer_event) { create(:event, project: project1, author: other_user, target: opened_merge_request3, action: Event::CREATED) } let!(:other_developer_event) { create(:event, project: project1, author: other_user, target: opened_merge_request3, action: Event::CREATED) }
let(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) } let_it_be(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) }
let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) } let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) }
let!(:confidential_event) { create(:event, project: public_project, author: user, target: confidential_issue, action: Event::CLOSED) } let!(:confidential_event) { create(:event, project: public_project, author: user, target: confidential_issue, action: Event::CLOSED) }
...@@ -59,6 +59,32 @@ describe EventsFinder do ...@@ -59,6 +59,32 @@ describe EventsFinder do
end end
end end
describe 'wiki events feature flag' do
let_it_be(:events) { create_list(:wiki_page_event, 3, project: public_project) }
subject(:finder) { described_class.new(source: public_project, target_type: 'wiki', current_user: user) }
context 'the wiki_events feature flag is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'omits the wiki page events' do
expect(finder.execute).to be_empty
end
end
context 'the wiki_events feature flag is enabled' do
before do
stub_feature_flags(wiki_events: true)
end
it 'can find the wiki events' do
expect(finder.execute).to match_array(events)
end
end
end
context 'dashboard events' do context 'dashboard events' do
before do before do
project1.add_developer(other_user) project1.add_developer(other_user)
......
...@@ -22,6 +22,10 @@ ...@@ -22,6 +22,10 @@
"commit_path": { "type": "string" }, "commit_path": { "type": "string" },
"tag_path": { "type": "string" }, "tag_path": { "type": "string" },
"name": { "type": "string" }, "name": { "type": "string" },
"evidences": {
"type": "array",
"items": { "$ref": "release/evidence.json" }
},
"assets": { "assets": {
"required": ["count", "links", "sources"], "required": ["count", "links", "sources"],
"properties": { "properties": {
......
{
"type": "object",
"required" : [
"sha",
"filepath",
"collected_at"
],
"properties" : {
"sha": { "type": "string" },
"filepath": { "type": "string" },
"collected_at": { "type": "date" }
},
"additionalProperties": false
}
import { createDateTimeFormat, languageCode } from '~/locale'; import { createDateTimeFormat, languageCode } from '~/locale';
import { setLanguage } from '../helpers/locale_helper'; import { setLanguage } from 'helpers/locale_helper';
describe('locale', () => { describe('locale', () => {
afterEach(() => { afterEach(() => setLanguage(null));
setLanguage(null);
});
describe('languageCode', () => { describe('languageCode', () => {
it('parses the lang attribute', () => { it('parses the lang attribute', () => {
...@@ -22,14 +20,12 @@ describe('locale', () => { ...@@ -22,14 +20,12 @@ describe('locale', () => {
}); });
describe('createDateTimeFormat', () => { describe('createDateTimeFormat', () => {
beforeEach(() => { beforeEach(() => setLanguage('en'));
setLanguage('de');
});
it('creates an instance of Intl.DateTimeFormat', () => { it('creates an instance of Intl.DateTimeFormat', () => {
const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
expect(dateFormat.format(new Date(2015, 6, 3))).toBe('3. Juli 2015'); expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015');
}); });
}); });
}); });
...@@ -75,17 +75,66 @@ describe('DiscussionCounter component', () => { ...@@ -75,17 +75,66 @@ describe('DiscussionCounter component', () => {
}); });
it.each` it.each`
title | resolved | hasNextBtn | isActive | icon | groupLength title | resolved | isActive | icon | groupLength
${'hasNextButton'} | ${false} | ${true} | ${false} | ${'check-circle'} | ${2} ${'not allResolved'} | ${false} | ${false} | ${'check-circle'} | ${3}
${'allResolved'} | ${true} | ${false} | ${true} | ${'check-circle-filled'} | ${0} ${'allResolved'} | ${true} | ${true} | ${'check-circle-filled'} | ${1}
`('renders correctly if $title', ({ resolved, hasNextBtn, isActive, icon, groupLength }) => { `('renders correctly if $title', ({ resolved, isActive, icon, groupLength }) => {
updateStore({ resolvable: true, resolved }); updateStore({ resolvable: true, resolved });
wrapper = shallowMount(DiscussionCounter, { store, localVue }); wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.has-next-btn`).exists()).toBe(hasNextBtn);
expect(wrapper.find(`.is-active`).exists()).toBe(isActive); expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
expect(wrapper.find({ name: icon }).exists()).toBe(true); expect(wrapper.find({ name: icon }).exists()).toBe(true);
expect(wrapper.findAll('[role="group"').length).toBe(groupLength); expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
}); });
}); });
describe('toggle all threads button', () => {
let toggleAllButton;
const updateStoreWithExpanded = expanded => {
const discussion = { ...discussionMock, expanded };
store.commit(types.SET_INITIAL_DISCUSSIONS, [discussion]);
store.dispatch('updateResolvableDiscussionsCounts');
wrapper = shallowMount(DiscussionCounter, { store, localVue });
toggleAllButton = wrapper.find('.toggle-all-discussions-btn');
};
afterEach(() => wrapper.destroy());
it('calls button handler when clicked', () => {
updateStoreWithExpanded(true);
wrapper.setMethods({ handleExpandDiscussions: jest.fn() });
toggleAllButton.trigger('click');
expect(wrapper.vm.handleExpandDiscussions).toHaveBeenCalledTimes(1);
});
it('collapses all discussions if expanded', () => {
updateStoreWithExpanded(true);
expect(wrapper.vm.allExpanded).toBe(true);
expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true);
toggleAllButton.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(false);
expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true);
});
});
it('expands all discussions if collapsed', () => {
updateStoreWithExpanded(false);
expect(wrapper.vm.allExpanded).toBe(false);
expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true);
toggleAllButton.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(true);
expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true);
});
});
});
}); });
...@@ -329,6 +329,52 @@ describe('Notes Store mutations', () => { ...@@ -329,6 +329,52 @@ describe('Notes Store mutations', () => {
}); });
}); });
describe('SET_EXPAND_DISCUSSIONS', () => {
it('should succeed when discussions are null', () => {
const state = {};
mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds: null, expanded: true });
expect(state).toEqual({});
});
it('should succeed when discussions are empty', () => {
const state = {};
mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds: [], expanded: true });
expect(state).toEqual({});
});
it('should open all closed discussions', () => {
const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds, expanded: true });
state.discussions.forEach(discussion => {
expect(discussion.expanded).toEqual(true);
});
});
it('should close all opened discussions', () => {
const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds, expanded: false });
state.discussions.forEach(discussion => {
expect(discussion.expanded).toEqual(false);
});
});
});
describe('UPDATE_NOTE', () => { describe('UPDATE_NOTE', () => {
it('should update a note', () => { it('should update a note', () => {
const state = { const state = {
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlLink, GlIcon } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import Icon from '~/vue_shared/components/icon.vue';
import { release as originalRelease } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import EvidenceBlock from '~/releases/components/evidence_block.vue'; import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
...@@ -32,11 +31,11 @@ describe('Evidence Block', () => { ...@@ -32,11 +31,11 @@ describe('Evidence Block', () => {
}); });
it('renders the evidence icon', () => { it('renders the evidence icon', () => {
expect(wrapper.find(Icon).props('name')).toBe('review-list'); expect(wrapper.find(GlIcon).props('name')).toBe('review-list');
}); });
it('renders the title for the dowload link', () => { it('renders the title for the dowload link', () => {
expect(wrapper.find(GlLink).text()).toBe(`${release.tagName}-evidence.json`); expect(wrapper.find(GlLink).text()).toBe('v1.1.2-evidences-1.json');
}); });
it('renders the correct hover text for the download', () => { it('renders the correct hover text for the download', () => {
...@@ -44,19 +43,19 @@ describe('Evidence Block', () => { ...@@ -44,19 +43,19 @@ describe('Evidence Block', () => {
}); });
it('renders the correct file link for download', () => { it('renders the correct file link for download', () => {
expect(wrapper.find(GlLink).attributes().download).toBe(`${release.tagName}-evidence.json`); expect(wrapper.find(GlLink).attributes().download).toBe('v1.1.2-evidences-1.json');
}); });
describe('sha text', () => { describe('sha text', () => {
it('renders the short sha initially', () => { it('renders the short sha initially', () => {
expect(wrapper.find('.js-short').text()).toBe(truncateSha(release.evidenceSha)); expect(wrapper.find('.js-short').text()).toBe(truncateSha(release.evidences[0].sha));
}); });
it('renders the long sha after expansion', () => { it('renders the long sha after expansion', () => {
wrapper.find('.js-text-expander-prepend').trigger('click'); wrapper.find('.js-text-expander-prepend').trigger('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find('.js-expanded').text()).toBe(release.evidenceSha); expect(wrapper.find('.js-expanded').text()).toBe(release.evidences[0].sha);
}); });
}); });
}); });
...@@ -72,7 +71,7 @@ describe('Evidence Block', () => { ...@@ -72,7 +71,7 @@ describe('Evidence Block', () => {
it('copies the sha', () => { it('copies the sha', () => {
expect(wrapper.find(ClipboardButton).attributes('data-clipboard-text')).toBe( expect(wrapper.find(ClipboardButton).attributes('data-clipboard-text')).toBe(
release.evidenceSha, release.evidences[0].sha,
); );
}); });
}); });
......
...@@ -43,7 +43,6 @@ export const release = { ...@@ -43,7 +43,6 @@ export const release = {
description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>', description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>',
created_at: '2019-08-26T17:54:04.952Z', created_at: '2019-08-26T17:54:04.952Z',
released_at: '2019-08-26T17:54:04.807Z', released_at: '2019-08-26T17:54:04.807Z',
evidence_sha: 'fb3a125fd69a0e5048ebfb0ba43eb32ce4911520dd8d',
author: { author: {
id: 1, id: 1,
name: 'Administrator', name: 'Administrator',
...@@ -69,10 +68,28 @@ export const release = { ...@@ -69,10 +68,28 @@ export const release = {
commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1', commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1',
upcoming_release: false, upcoming_release: false,
milestones, milestones,
evidences: [
{
filepath:
'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/1.json',
sha: 'fb3a125fd69a0e5048ebfb0ba43eb32ce4911520dd8d',
collected_at: '2018-10-19 15:43:20 +0200',
},
{
filepath:
'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/2.json',
sha: '6ebd17a66e6a861175735416e49cf677678029805712dd71bb805c609e2d9108',
collected_at: '2018-10-19 15:43:20 +0200',
},
{
filepath:
'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/3.json',
sha: '2f65beaf275c3cb4b4e24fb01d481cc475d69c957830833f15338384816b5cba',
collected_at: '2018-10-19 15:43:20 +0200',
},
],
assets: { assets: {
count: 5, count: 5,
evidence_file_path:
'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidence.json',
sources: [ sources: [
{ {
format: 'zip', format: 'zip',
......
...@@ -7,15 +7,20 @@ describe Resolvers::IssuesResolver do ...@@ -7,15 +7,20 @@ describe Resolvers::IssuesResolver do
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
context "with a project" do let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:milestone) { create(:milestone, project: project) } let_it_be(:other_project) { create(:project, group: group) }
let_it_be(:assignee) { create(:user) }
let_it_be(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: milestone) } let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) } let_it_be(:assignee) { create(:user) }
let_it_be(:label1) { create(:label, project: project) } let_it_be(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: milestone) }
let_it_be(:label2) { create(:label, project: project) } let_it_be(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) }
let_it_be(:issue3) { create(:issue, project: other_project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) }
let_it_be(:issue4) { create(:issue) }
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
context "with a project" do
before do before do
project.add_developer(current_user) project.add_developer(current_user)
create(:label_link, label: label1, target: issue1) create(:label_link, label: label1, target: issue1)
...@@ -184,6 +189,20 @@ describe Resolvers::IssuesResolver do ...@@ -184,6 +189,20 @@ describe Resolvers::IssuesResolver do
end end
end end
context "with a group" do
before do
group.add_developer(current_user)
end
describe '#resolve' do
it 'finds all group issues' do
result = resolve(described_class, obj: group, ctx: { current_user: current_user })
expect(result).to contain_exactly(issue1, issue2, issue3)
end
end
end
context "when passing a non existent, batch loaded project" do context "when passing a non existent, batch loaded project" do
let(:project) do let(:project) do
BatchLoader::GraphQL.for("non-existent-path").batch do |_fake_paths, loader, _| BatchLoader::GraphQL.for("non-existent-path").batch do |_fake_paths, loader, _|
......
...@@ -117,4 +117,24 @@ describe NavHelper, :do_not_mock_admin_mode do ...@@ -117,4 +117,24 @@ describe NavHelper, :do_not_mock_admin_mode do
it { is_expected.to all(be_a(String)) } it { is_expected.to all(be_a(String)) }
end end
describe '.show_user_notification_dot?' do
subject { helper.show_user_notification_dot? }
context 'when experiment is disabled' do
before do
allow(helper).to receive(:experiment_enabled?).with(:ci_notification_dot).and_return(false)
end
it { is_expected.to be_falsey }
end
context 'when experiment is enabled' do
before do
allow(helper).to receive(:experiment_enabled?).with(:ci_notification_dot).and_return(true)
end
it { is_expected.to be_truthy }
end
end
end end
...@@ -4,26 +4,29 @@ require 'spec_helper' ...@@ -4,26 +4,29 @@ require 'spec_helper'
describe API::Entities::Release do describe API::Entities::Release do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) } let_it_be(:release) { create(:release, :with_evidence, project: project) }
let(:entity) { described_class.new(release, current_user: user) } let(:evidence) { release.evidences.first }
let(:user) { create(:user) }
describe 'evidence' do let(:entity) { described_class.new(release, current_user: user).as_json }
let(:release) { create(:release, :with_evidence, project: project) }
subject { entity.as_json }
describe 'evidences' do
context 'when the current user can download code' do context 'when the current user can download code' do
let(:entity_evidence) { entity[:evidences].first }
it 'exposes the evidence sha and the json path' do it 'exposes the evidence sha and the json path' do
allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?) allow(Ability).to receive(:allowed?)
.with(user, :download_code, project).and_return(true) .with(user, :download_code, project).and_return(true)
expect(subject[:evidence_sha]).to eq(release.evidence_sha) expect(entity_evidence[:sha]).to eq(evidence.summary_sha)
expect(subject[:assets][:evidence_file_path]).to eq( expect(entity_evidence[:collected_at]).to eq(evidence.collected_at)
Gitlab::Routing.url_helpers.evidence_project_release_url(project, expect(entity_evidence[:filepath]).to eq(
release.tag, Gitlab::Routing.url_helpers.namespace_project_evidence_url(
format: :json) namespace_id: project.namespace,
) project_id: project,
tag: release,
id: evidence.id,
format: :json))
end end
end end
...@@ -33,8 +36,7 @@ describe API::Entities::Release do ...@@ -33,8 +36,7 @@ describe API::Entities::Release do
allow(Ability).to receive(:allowed?) allow(Ability).to receive(:allowed?)
.with(user, :download_code, project).and_return(false) .with(user, :download_code, project).and_return(false)
expect(subject.keys).not_to include(:evidence_sha) expect(entity.keys).not_to include(:evidences)
expect(subject[:assets].keys).not_to include(:evidence_file_path)
end end
end end
end end
...@@ -45,7 +47,7 @@ describe API::Entities::Release do ...@@ -45,7 +47,7 @@ describe API::Entities::Release do
let(:issue_title) { 'title="%s"' % issue.title } let(:issue_title) { 'title="%s"' % issue.title }
let(:release) { create(:release, project: project, description: "Now shipping #{issue.to_reference}") } let(:release) { create(:release, project: project, description: "Now shipping #{issue.to_reference}") }
subject(:description_html) { entity.as_json[:description_html] } subject(:description_html) { entity.as_json['description_html'] }
it 'renders special references if current user has access' do it 'renders special references if current user has access' do
project.add_reporter(user) project.add_reporter(user)
......
...@@ -28,6 +28,8 @@ describe EventFilter do ...@@ -28,6 +28,8 @@ describe EventFilter do
let_it_be(:comments_event) { create(:event, :commented, project: public_project, target: public_project) } let_it_be(:comments_event) { create(:event, :commented, project: public_project, target: public_project) }
let_it_be(:joined_event) { create(:event, :joined, project: public_project, target: public_project) } let_it_be(:joined_event) { create(:event, :joined, project: public_project, target: public_project) }
let_it_be(:left_event) { create(:event, :left, project: public_project, target: public_project) } let_it_be(:left_event) { create(:event, :left, project: public_project, target: public_project) }
let_it_be(:wiki_page_event) { create(:wiki_page_event) }
let_it_be(:wiki_page_update_event) { create(:wiki_page_event, :updated) }
let(:filtered_events) { described_class.new(filter).apply_filter(Event.all) } let(:filtered_events) { described_class.new(filter).apply_filter(Event.all) }
...@@ -77,6 +79,34 @@ describe EventFilter do ...@@ -77,6 +79,34 @@ describe EventFilter do
it 'returns all events' do it 'returns all events' do
expect(filtered_events).to eq(Event.all) expect(filtered_events).to eq(Event.all)
end end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end
context 'with the "wiki" filter' do
let(:filter) { described_class::WIKI }
it 'returns only wiki page events' do
expect(filtered_events).to contain_exactly(wiki_page_event, wiki_page_update_event)
end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).not_to include(wiki_page_event, wiki_page_update_event)
end
end
end end
context 'with an unknown filter' do context 'with an unknown filter' do
...@@ -85,6 +115,16 @@ describe EventFilter do ...@@ -85,6 +115,16 @@ describe EventFilter do
it 'returns all events' do it 'returns all events' do
expect(filtered_events).to eq(Event.all) expect(filtered_events).to eq(Event.all)
end end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end end
context 'with a nil filter' do context 'with a nil filter' do
...@@ -93,6 +133,16 @@ describe EventFilter do ...@@ -93,6 +133,16 @@ describe EventFilter do
it 'returns all events' do it 'returns all events' do
expect(filtered_events).to eq(Event.all) expect(filtered_events).to eq(Event.all)
end end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end end
end end
......
...@@ -94,7 +94,7 @@ releases: ...@@ -94,7 +94,7 @@ releases:
- links - links
- milestone_releases - milestone_releases
- milestones - milestones
- evidence - evidences
links: links:
- release - release
project_members: project_members:
......
...@@ -134,7 +134,7 @@ Release: ...@@ -134,7 +134,7 @@ Release:
- created_at - created_at
- updated_at - updated_at
- released_at - released_at
Evidence: Releases::Evidence:
- id - id
- summary - summary
- created_at - created_at
......
...@@ -8,22 +8,68 @@ describe EventCollection do ...@@ -8,22 +8,68 @@ describe EventCollection do
let_it_be(:project) { create(:project_empty_repo, group: group) } let_it_be(:project) { create(:project_empty_repo, group: group) }
let_it_be(:projects) { Project.where(id: project.id) } let_it_be(:projects) { Project.where(id: project.id) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:merge_request) { create(:merge_request) }
context 'with project events' do context 'with project events' do
before do let_it_be(:push_event_payloads) do
20.times do Array.new(9) do
event = create(:push_event, project: project, author: user) create(:push_event_payload,
event: create(:push_event, project: project, author: user))
create(:push_event_payload, event: event)
end end
create(:closed_issue_event, project: project, author: user)
end end
it 'returns an Array of events' do let_it_be(:merge_request_events) { create_list(:event, 10, :commented, project: project, target: merge_request) }
let_it_be(:closed_issue_event) { create(:closed_issue_event, project: project, author: user) }
let_it_be(:wiki_page_event) { create(:wiki_page_event, project: project) }
let(:push_events) { push_event_payloads.map(&:event) }
it 'returns an Array of events', :aggregate_failures do
most_recent_20_events = [
wiki_page_event,
closed_issue_event,
*push_events,
*merge_request_events
].sort_by(&:id).reverse.take(20)
events = described_class.new(projects).to_a events = described_class.new(projects).to_a
expect(events).to be_an_instance_of(Array) expect(events).to be_an_instance_of(Array)
expect(events).to match_array(most_recent_20_events)
end
context 'the wiki_events feature flag is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'omits the wiki page events when using to_a' do
events = described_class.new(projects).to_a
expect(events).not_to include(wiki_page_event)
end
it 'omits the wiki page events when using all_project_events' do
events = described_class.new(projects).all_project_events
expect(events).not_to include(wiki_page_event)
end
end
context 'the wiki_events feature flag is enabled' do
before do
stub_feature_flags(wiki_events: true)
end
it 'includes the wiki page events when using to_a' do
events = described_class.new(projects).to_a
expect(events).to include(wiki_page_event)
end
it 'includes the wiki page events when using all_project_events' do
events = described_class.new(projects).all_project_events
expect(events).to include(wiki_page_event)
end
end end
it 'applies a limit to the number of events' do it 'applies a limit to the number of events' do
...@@ -44,12 +90,25 @@ describe EventCollection do ...@@ -44,12 +90,25 @@ describe EventCollection do
expect(events).to be_empty expect(events).to be_empty
end end
it 'allows filtering of events using an EventFilter' do it 'allows filtering of events using an EventFilter, returning single item' do
filter = EventFilter.new(EventFilter::ISSUE) filter = EventFilter.new(EventFilter::ISSUE)
events = described_class.new(projects, filter: filter).to_a events = described_class.new(projects, filter: filter).to_a
expect(events.length).to eq(1) expect(events).to contain_exactly(closed_issue_event)
expect(events[0].action).to eq(Event::CLOSED) end
it 'allows filtering of events using an EventFilter, returning several items' do
filter = EventFilter.new(EventFilter::COMMENTS)
events = described_class.new(projects, filter: filter).to_a
expect(events).to match_array(merge_request_events)
end
it 'allows filtering of events using an EventFilter, returning pushes' do
filter = EventFilter.new(EventFilter::PUSH)
events = described_class.new(projects, filter: filter).to_a
expect(events).to match_array(push_events)
end end
end end
......
...@@ -454,9 +454,10 @@ describe Event do ...@@ -454,9 +454,10 @@ describe Event do
end end
end end
describe '.for_wiki_page' do describe 'wiki_page predicate scopes' do
let_it_be(:events) do let_it_be(:events) do
[ [
create(:push_event),
create(:closed_issue_event), create(:closed_issue_event),
create(:wiki_page_event), create(:wiki_page_event),
create(:closed_issue_event), create(:closed_issue_event),
...@@ -465,10 +466,22 @@ describe Event do ...@@ -465,10 +466,22 @@ describe Event do
] ]
end end
it 'only contains the wiki page events' do describe '.for_wiki_page' do
wiki_events = events.select(&:wiki_page?) it 'only contains the wiki page events' do
wiki_events = events.select(&:wiki_page?)
expect(described_class.for_wiki_page).to match_array(wiki_events) expect(events).not_to match_array(wiki_events)
expect(described_class.for_wiki_page).to match_array(wiki_events)
end
end
describe '.not_wiki_page' do
it 'does not contain the wiki page events' do
non_wiki_events = events.reject(&:wiki_page?)
expect(events).not_to match_array(non_wiki_events)
expect(described_class.not_wiki_page).to match_array(non_wiki_events)
end
end end
end end
......
...@@ -15,7 +15,7 @@ RSpec.describe Release do ...@@ -15,7 +15,7 @@ RSpec.describe Release do
it { is_expected.to have_many(:links).class_name('Releases::Link') } it { is_expected.to have_many(:links).class_name('Releases::Link') }
it { is_expected.to have_many(:milestones) } it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:milestone_releases) } it { is_expected.to have_many(:milestone_releases) }
it { is_expected.to have_one(:evidence) } it { is_expected.to have_many(:evidences).class_name('Releases::Evidence') }
end end
describe 'validation' do describe 'validation' do
...@@ -97,7 +97,7 @@ RSpec.describe Release do ...@@ -97,7 +97,7 @@ RSpec.describe Release do
describe '#create_evidence!' do describe '#create_evidence!' do
context 'when a release is created' do context 'when a release is created' do
it 'creates one Evidence object too' do it 'creates one Evidence object too' do
expect { release_with_evidence }.to change(Evidence, :count).by(1) expect { release_with_evidence }.to change(Releases::Evidence, :count).by(1)
end end
end end
end end
...@@ -106,7 +106,7 @@ RSpec.describe Release do ...@@ -106,7 +106,7 @@ RSpec.describe Release do
it 'also deletes the associated evidence' do it 'also deletes the associated evidence' do
release_with_evidence release_with_evidence
expect { release_with_evidence.destroy }.to change(Evidence, :count).by(-1) expect { release_with_evidence.destroy }.to change(Releases::Evidence, :count).by(-1)
end end
end end
end end
...@@ -155,7 +155,7 @@ RSpec.describe Release do ...@@ -155,7 +155,7 @@ RSpec.describe Release do
context 'when a release was created with evidence collection' do context 'when a release was created with evidence collection' do
let!(:release) { create(:release, :with_evidence) } let!(:release) { create(:release, :with_evidence) }
it { is_expected.to eq(release.evidence.summary_sha) } it { is_expected.to eq(release.evidences.first.summary_sha) }
end end
end end
...@@ -171,7 +171,7 @@ RSpec.describe Release do ...@@ -171,7 +171,7 @@ RSpec.describe Release do
context 'when a release was created with evidence collection' do context 'when a release was created with evidence collection' do
let!(:release) { create(:release, :with_evidence) } let!(:release) { create(:release, :with_evidence) }
it { is_expected.to eq(release.evidence.summary) } it { is_expected.to eq(release.evidences.first.summary) }
end end
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe Evidence do describe Releases::Evidence do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let(:release) { create(:release, project: project) } let(:release) { create(:release, project: project) }
let(:schema_file) { 'evidences/evidence' } let(:schema_file) { 'evidences/evidence' }
......
...@@ -112,28 +112,4 @@ describe ReleasePresenter do ...@@ -112,28 +112,4 @@ describe ReleasePresenter do
it { is_expected.to be_nil } it { is_expected.to be_nil }
end end
end end
describe '#evidence_file_path' do
subject { presenter.evidence_file_path }
context 'without evidence' do
it { is_expected.to be_falsy }
end
context 'with evidence' do
let(:release) { create :release, :with_evidence, project: project }
specify do
is_expected.to match /#{evidence_project_release_url(project, release.tag, format: :json)}/
end
end
context 'when a tag contains a slash' do
let(:release) { create :release, :with_evidence, project: project, tag: 'debian/2.4.0-1' }
specify do
is_expected.to match /#{evidence_project_release_url(project, CGI.escape(release.tag), format: :json)}/
end
end
end
end end
...@@ -114,6 +114,26 @@ describe API::Events do ...@@ -114,6 +114,26 @@ describe API::Events do
expect(json_response.size).to eq(1) expect(json_response.size).to eq(1)
end end
context 'when the list of events includes wiki page events' do
it 'returns information about the wiki event', :aggregate_failures do
page = create(:wiki_page, project: private_project)
[Event::CREATED, Event::UPDATED, Event::DESTROYED].each do |action|
create(:wiki_page_event, wiki_page: page, action: action, author: user)
end
get api("/users/#{user.id}/events", user)
wiki_events = json_response.select { |e| e['target_type'] == 'WikiPage::Meta' }
action_names = wiki_events.map { |e| e['action_name'] }
titles = wiki_events.map { |e| e['target_title'] }
slugs = wiki_events.map { |e| e.dig('wiki_page', 'slug') }
expect(action_names).to contain_exactly('created', 'updated', 'destroyed')
expect(titles).to all(eq(page.title))
expect(slugs).to all(eq(page.slug))
end
end
context 'when the list of events includes push events' do context 'when the list of events includes push events' do
let(:event) do let(:event) do
create(:push_event, author: user, project: private_project) create(:push_event, author: user, project: private_project)
......
...@@ -51,6 +51,7 @@ describe 'getting group information', :do_not_mock_admin_mode do ...@@ -51,6 +51,7 @@ describe 'getting group information', :do_not_mock_admin_mode do
it "returns one of user1's groups" do it "returns one of user1's groups" do
project = create(:project, namespace: group2, path: 'Foo') project = create(:project, namespace: group2, path: 'Foo')
issue = create(:issue, project: create(:project, group: group1))
create(:project_group_link, project: project, group: group1) create(:project_group_link, project: project, group: group1)
post_graphql(group_query(group1), current_user: user1) post_graphql(group_query(group1), current_user: user1)
...@@ -67,6 +68,8 @@ describe 'getting group information', :do_not_mock_admin_mode do ...@@ -67,6 +68,8 @@ describe 'getting group information', :do_not_mock_admin_mode do
expect(graphql_data['group']['fullName']).to eq(group1.full_name) expect(graphql_data['group']['fullName']).to eq(group1.full_name)
expect(graphql_data['group']['fullPath']).to eq(group1.full_path) expect(graphql_data['group']['fullPath']).to eq(group1.full_path)
expect(graphql_data['group']['parentId']).to eq(group1.parent_id) expect(graphql_data['group']['parentId']).to eq(group1.parent_id)
expect(graphql_data['group']['issues']['nodes'].count).to eq(1)
expect(graphql_data['group']['issues']['nodes'][0]['iid']).to eq(issue.iid.to_s)
end end
it "does not return a non existing group" do it "does not return a non existing group" do
......
...@@ -71,6 +71,7 @@ describe API::Groups do ...@@ -71,6 +71,7 @@ describe API::Groups do
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(json_response.length).to eq(1) expect(json_response.length).to eq(1)
expect(json_response.first['created_at']).to be_present
expect(json_response) expect(json_response)
.to satisfy_one { |group| group['name'] == group1.name } .to satisfy_one { |group| group['name'] == group1.name }
end end
...@@ -121,6 +122,15 @@ describe API::Groups do ...@@ -121,6 +122,15 @@ describe API::Groups do
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(json_response.first).not_to include 'statistics' expect(json_response.first).not_to include 'statistics'
end end
it "includes a created_at timestamp" do
get api("/groups", user1)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['created_at']).to be_present
end
end end
context "when authenticated as admin" do context "when authenticated as admin" do
...@@ -152,6 +162,15 @@ describe API::Groups do ...@@ -152,6 +162,15 @@ describe API::Groups do
expect(json_response.first).not_to include('statistics') expect(json_response.first).not_to include('statistics')
end end
it "includes a created_at timestamp" do
get api("/groups", admin)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['created_at']).to be_present
end
it "includes statistics if requested" do it "includes statistics if requested" do
attributes = { attributes = {
storage_size: 1158, storage_size: 1158,
...@@ -357,6 +376,7 @@ describe API::Groups do ...@@ -357,6 +376,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include('runners_token') expect(json_response).not_to include('runners_token')
expect(json_response).to include('created_at')
end end
it 'returns only public projects in the group' do it 'returns only public projects in the group' do
...@@ -407,6 +427,7 @@ describe API::Groups do ...@@ -407,6 +427,7 @@ describe API::Groups do
expect(json_response['full_name']).to eq(group1.full_name) expect(json_response['full_name']).to eq(group1.full_name)
expect(json_response['full_path']).to eq(group1.full_path) expect(json_response['full_path']).to eq(group1.full_path)
expect(json_response['parent_id']).to eq(group1.parent_id) expect(json_response['parent_id']).to eq(group1.parent_id)
expect(json_response['created_at']).to be_present
expect(json_response['projects']).to be_an Array expect(json_response['projects']).to be_an Array
expect(json_response['projects'].length).to eq(2) expect(json_response['projects'].length).to eq(2)
expect(json_response['shared_projects']).to be_an Array expect(json_response['shared_projects']).to be_an Array
...@@ -613,6 +634,7 @@ describe API::Groups do ...@@ -613,6 +634,7 @@ describe API::Groups do
expect(json_response['subgroup_creation_level']).to eq("maintainer") expect(json_response['subgroup_creation_level']).to eq("maintainer")
expect(json_response['request_access_enabled']).to eq(true) expect(json_response['request_access_enabled']).to eq(true)
expect(json_response['parent_id']).to eq(nil) expect(json_response['parent_id']).to eq(nil)
expect(json_response['created_at']).to be_present
expect(json_response['projects']).to be_an Array expect(json_response['projects']).to be_an Array
expect(json_response['projects'].length).to eq(2) expect(json_response['projects'].length).to eq(2)
expect(json_response['shared_projects']).to be_an Array expect(json_response['shared_projects']).to be_an Array
......
...@@ -104,6 +104,21 @@ describe API::Releases do ...@@ -104,6 +104,21 @@ describe API::Releases do
expect(json_response.first['upcoming_release']).to eq(false) expect(json_response.first['upcoming_release']).to eq(false)
end end
it 'avoids N+1 queries' do
create(:release, :with_evidence, project: project, tag: 'v0.1', author: maintainer)
control_count = ActiveRecord::QueryRecorder.new do
get api("/projects/#{project.id}/releases", maintainer)
end.count
create(:release, :with_evidence, project: project, tag: 'v0.1', author: maintainer)
create(:release, :with_evidence, project: project, tag: 'v0.1', author: maintainer)
expect do
get api("/projects/#{project.id}/releases", maintainer)
end.not_to exceed_query_limit(control_count)
end
context 'when tag does not exist in git repository' do context 'when tag does not exist in git repository' do
let!(:release) { create(:release, project: project, tag: 'v1.1.5') } let!(:release) { create(:release, project: project, tag: 'v1.1.5') }
...@@ -725,7 +740,7 @@ describe API::Releases do ...@@ -725,7 +740,7 @@ describe API::Releases do
end end
it 'does not create an Evidence object', :sidekiq_inline do it 'does not create an Evidence object', :sidekiq_inline do
expect { subject }.not_to change(Evidence, :count) expect { subject }.not_to change(Releases::Evidence, :count)
end end
it 'is a historical release' do it 'is a historical release' do
...@@ -755,7 +770,7 @@ describe API::Releases do ...@@ -755,7 +770,7 @@ describe API::Releases do
end end
it 'creates Evidence', :sidekiq_inline do it 'creates Evidence', :sidekiq_inline do
expect { subject }.to change(Evidence, :count).by(1) expect { subject }.to change(Releases::Evidence, :count).by(1)
end end
it 'is not a historical release' do it 'is not a historical release' do
...@@ -785,7 +800,7 @@ describe API::Releases do ...@@ -785,7 +800,7 @@ describe API::Releases do
end end
it 'creates Evidence', :sidekiq_inline do it 'creates Evidence', :sidekiq_inline do
expect { subject }.to change(Evidence, :count).by(1) expect { subject }.to change(Releases::Evidence, :count).by(1)
end end
it 'is not a historical release' do it 'is not a historical release' do
......
...@@ -153,6 +153,46 @@ describe EventCreateService do ...@@ -153,6 +153,46 @@ describe EventCreateService do
end end
end end
describe '#wiki_event' do
let_it_be(:user) { create(:user) }
let_it_be(:wiki_page) { create(:wiki_page) }
let_it_be(:meta) { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
Event::WIKI_ACTIONS.each do |action|
context "The action is #{action}" do
let(:event) { service.wiki_event(meta, user, action) }
it 'creates the event' do
expect(event).to have_attributes(
wiki_page?: true,
valid?: true,
persisted?: true,
action: action,
wiki_page: wiki_page
)
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not create the event' do
expect { event }.not_to change(Event, :count)
end
end
end
end
(Event::ACTIONS.values - Event::WIKI_ACTIONS).each do |bad_action|
context "The action is #{bad_action}" do
it 'raises an error' do
expect { service.wiki_event(meta, user, bad_action) }.to raise_error(described_class::IllegalActionError)
end
end
end
end
describe '#push', :clean_gitlab_redis_shared_state do describe '#push', :clean_gitlab_redis_shared_state do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
......
...@@ -6,22 +6,24 @@ describe WikiPages::BaseService do ...@@ -6,22 +6,24 @@ describe WikiPages::BaseService do
let(:project) { double('project') } let(:project) { double('project') }
let(:user) { double('user') } let(:user) { double('user') }
subject(:service) { described_class.new(project, user, {}) }
describe '#increment_usage' do describe '#increment_usage' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
error = counter::UnknownEvent error = counter::UnknownEvent
it 'raises an error on unknown events' do let(:subject) { bad_service_class.new(project, user, {}) }
expect { subject.send(:increment_usage, :bad_event) }.to raise_error error
end
context 'the event is valid' do context 'the class implements usage_counter_action incorrectly' do
counter::KNOWN_EVENTS.each do |e| let(:bad_service_class) do
it "updates the #{e} counter" do Class.new(described_class) do
expect { subject.send(:increment_usage, e) }.to change { counter.read(e) } def usage_counter_action
:bad_event
end
end end
end end
it 'raises an error on unknown events' do
expect { subject.send(:increment_usage) }.to raise_error(error)
end
end end
end end
end end
...@@ -5,19 +5,16 @@ require 'spec_helper' ...@@ -5,19 +5,16 @@ require 'spec_helper'
describe WikiPages::CreateService do describe WikiPages::CreateService do
let(:project) { create(:project, :wiki_repo) } let(:project) { create(:project, :wiki_repo) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:page_title) { 'Title' }
let(:opts) do let(:opts) do
{ {
title: 'Title', title: page_title,
content: 'Content for wiki page', content: 'Content for wiki page',
format: 'markdown' format: 'markdown'
} }
end end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) } subject(:service) { described_class.new(project, user, opts) }
before do before do
...@@ -35,8 +32,7 @@ describe WikiPages::CreateService do ...@@ -35,8 +32,7 @@ describe WikiPages::CreateService do
end end
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(WikiPage)
.with(instance_of(WikiPage), 'create')
service.execute service.execute
end end
...@@ -47,8 +43,41 @@ describe WikiPages::CreateService do ...@@ -47,8 +43,41 @@ describe WikiPages::CreateService do
expect { service.execute }.to change { counter.read(:create) }.by 1 expect { service.execute }.to change { counter.read(:create) }.by 1
end end
shared_examples 'correct event created' do
it 'creates appropriate events' do
expect { service.execute }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::CREATED,
target: have_attributes(canonical_slug: page_title)
)
end
end
context 'the new page is at the top level' do
let(:page_title) { 'root-level-page' }
include_examples 'correct event created'
end
context 'the new page is in a subsection' do
let(:page_title) { 'subsection/page' }
include_examples 'correct event created'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
end
context 'when the options are bad' do context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) } let(:page_title) { '' }
it 'does not count a creation event' do it 'does not count a creation event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
...@@ -56,6 +85,10 @@ describe WikiPages::CreateService do ...@@ -56,6 +85,10 @@ describe WikiPages::CreateService do
expect { service.execute }.not_to change { counter.read(:create) } expect { service.execute }.not_to change { counter.read(:create) }
end end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
it 'reports the error' do it 'reports the error' do
expect(service.execute).to be_invalid expect(service.execute).to be_invalid
.and have_attributes(errors: be_present) .and have_attributes(errors: be_present)
......
...@@ -15,8 +15,7 @@ describe WikiPages::DestroyService do ...@@ -15,8 +15,7 @@ describe WikiPages::DestroyService do
describe '#execute' do describe '#execute' do
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(page)
.with(instance_of(WikiPage), 'delete')
service.execute(page) service.execute(page)
end end
...@@ -27,10 +26,29 @@ describe WikiPages::DestroyService do ...@@ -27,10 +26,29 @@ describe WikiPages::DestroyService do
expect { service.execute(page) }.to change { counter.read(:delete) }.by 1 expect { service.execute(page) }.to change { counter.read(:delete) }.by 1
end end
it 'creates a new wiki page deletion event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::DESTROYED,
target: have_attributes(canonical_slug: page.slug)
)
end
it 'does not increment the delete count if the deletion failed' do it 'does not increment the delete count if the deletion failed' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
expect { service.execute(nil) }.not_to change { counter.read(:delete) } expect { service.execute(nil) }.not_to change { counter.read(:delete) }
end end
end end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
end end
...@@ -6,20 +6,17 @@ describe WikiPages::UpdateService do ...@@ -6,20 +6,17 @@ describe WikiPages::UpdateService do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:page) { create(:wiki_page) } let(:page) { create(:wiki_page) }
let(:page_title) { 'New Title' }
let(:opts) do let(:opts) do
{ {
content: 'New content for wiki page', content: 'New content for wiki page',
format: 'markdown', format: 'markdown',
message: 'New wiki message', message: 'New wiki message',
title: 'New Title' title: page_title
} }
end end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) } subject(:service) { described_class.new(project, user, opts) }
before do before do
...@@ -34,12 +31,11 @@ describe WikiPages::UpdateService do ...@@ -34,12 +31,11 @@ describe WikiPages::UpdateService do
expect(updated_page.message).to eq(opts[:message]) expect(updated_page.message).to eq(opts[:message])
expect(updated_page.content).to eq(opts[:content]) expect(updated_page.content).to eq(opts[:content])
expect(updated_page.format).to eq(opts[:format].to_sym) expect(updated_page.format).to eq(opts[:format].to_sym)
expect(updated_page.title).to eq(opts[:title]) expect(updated_page.title).to eq(page_title)
end end
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(WikiPage)
.with(instance_of(WikiPage), 'update')
service.execute(page) service.execute(page)
end end
...@@ -50,8 +46,42 @@ describe WikiPages::UpdateService do ...@@ -50,8 +46,42 @@ describe WikiPages::UpdateService do
expect { service.execute page }.to change { counter.read(:update) }.by 1 expect { service.execute page }.to change { counter.read(:update) }.by 1
end end
shared_examples 'adds activity event' do
it 'adds a new wiki page activity event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::UPDATED,
wiki_page: page,
target_title: page.title
)
end
end
context 'the page is at the top level' do
let(:page_title) { 'Top level page' }
include_examples 'adds activity event'
end
context 'the page is in a subsection' do
let(:page_title) { 'Subsection / secondary page' }
include_examples 'adds activity event'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
context 'when the options are bad' do context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) } let(:page_title) { '' }
it 'does not count an edit event' do it 'does not count an edit event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
...@@ -59,6 +89,10 @@ describe WikiPages::UpdateService do ...@@ -59,6 +89,10 @@ describe WikiPages::UpdateService do
expect { service.execute page }.not_to change { counter.read(:update) } expect { service.execute page }.not_to change { counter.read(:update) }
end end
it 'does not record the activity' do
expect { service.execute page }.not_to change(Event, :count)
end
it 'reports the error' do it 'reports the error' do
expect(service.execute page).to be_invalid expect(service.execute page).to be_invalid
.and have_attributes(errors: be_present) .and have_attributes(errors: be_present)
......
...@@ -8,6 +8,8 @@ module StubExperiments ...@@ -8,6 +8,8 @@ module StubExperiments
# Examples # Examples
# - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally. # - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally.
def stub_experiment(experiments) def stub_experiment(experiments)
allow(Gitlab::Experimentation).to receive(:enabled?).and_call_original
experiments.each do |experiment_key, enabled| experiments.each do |experiment_key, enabled|
allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key) { enabled } allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key) { enabled }
end end
...@@ -20,6 +22,8 @@ module StubExperiments ...@@ -20,6 +22,8 @@ module StubExperiments
# Examples # Examples
# - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user. # - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user.
def stub_experiment_for_user(experiments) def stub_experiment_for_user(experiments)
allow(Gitlab::Experimentation).to receive(:enabled_for_user?).and_call_original
experiments.each do |experiment_key, enabled| experiments.each do |experiment_key, enabled|
allow(Gitlab::Experimentation).to receive(:enabled_for_user?).with(experiment_key, anything) { enabled } allow(Gitlab::Experimentation).to receive(:enabled_for_user?).with(experiment_key, anything) { enabled }
end end
......
...@@ -5,7 +5,7 @@ require 'spec_helper' ...@@ -5,7 +5,7 @@ require 'spec_helper'
describe CreateEvidenceWorker do describe CreateEvidenceWorker do
let!(:release) { create(:release) } let!(:release) { create(:release) }
it 'creates a new Evidence' do it 'creates a new Evidence record' do
expect { described_class.new.perform(release.id) }.to change(Evidence, :count).by(1) expect { described_class.new.perform(release.id) }.to change(Releases::Evidence, :count).by(1)
end 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