Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-29 12:09:08 +00:00
parent 46b10c0fc8
commit 7cc6872401
64 changed files with 2671 additions and 955 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import Tracking from '~/tracking';
import {
NAME_REGEX_LENGTH,
UPDATE_SETTINGS_ERROR_MESSAGE,
@ -27,10 +28,18 @@ export default {
GlCard,
GlLoadingIcon,
},
mixins: [Tracking.mixin()],
labelsConfig: {
cols: 3,
align: 'right',
},
data() {
return {
tracking: {
label: 'docker_container_retention_and_expiration_policies',
},
};
},
computed: {
...mapState(['formOptions', 'isLoading']),
...mapComputed(
@ -86,7 +95,12 @@ export default {
},
methods: {
...mapActions(['resetSettings', 'saveSettings']),
reset() {
this.track('reset_form');
this.resetSettings();
},
submit() {
this.track('submit_form');
this.saveSettings()
.then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
.catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }));
@ -96,7 +110,7 @@ export default {
</script>
<template>
<form ref="form-element" @submit.prevent="submit" @reset.prevent="resetSettings">
<form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
<gl-card>
<template #header>
{{ s__('ContainerRegistry|Tag expiration policy') }}

View File

@ -3,7 +3,13 @@
class Admin::ApplicationSettingsController < Admin::ApplicationController
include InternalRedirect
# NOTE: Use @application_setting in this controller when you need to access
# application_settings after it has been modified. This is because the
# ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the
# cache might be stale immediately after an update.
# https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233
before_action :set_application_setting
before_action :whitelist_query_limiting, only: [:usage_data]
before_action :validate_self_monitoring_feature_flag_enabled, only: [
:create_self_monitoring_project,
@ -79,6 +85,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
# Specs are in spec/requests/self_monitoring_project_spec.rb
def create_self_monitoring_project
job_id = SelfMonitoringProjectCreateWorker.perform_async
@ -88,6 +95,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
# Specs are in spec/requests/self_monitoring_project_spec.rb
def status_create_self_monitoring_project
job_id = params[:job_id].to_s
@ -98,10 +106,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
if Gitlab::CurrentSettings.self_monitoring_project_id.present?
return render status: :ok, json: self_monitoring_data
elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id)
if SelfMonitoringProjectCreateWorker.in_progress?(job_id)
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
return render status: :accepted, json: {
@ -109,12 +114,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
if @application_setting.self_monitoring_project_id.present?
return render status: :ok, json: self_monitoring_data
end
render status: :bad_request, json: {
message: _('Self-monitoring project does not exist. Please check logs ' \
'for any error messages')
}
end
# Specs are in spec/requests/self_monitoring_project_spec.rb
def delete_self_monitoring_project
job_id = SelfMonitoringProjectDeleteWorker.perform_async
@ -124,6 +134,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
# Specs are in spec/requests/self_monitoring_project_spec.rb
def status_delete_self_monitoring_project
job_id = params[:job_id].to_s
@ -134,12 +145,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
if Gitlab::CurrentSettings.self_monitoring_project_id.nil?
return render status: :ok, json: {
message: _('Self-monitoring project has been successfully deleted')
}
elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id)
if SelfMonitoringProjectDeleteWorker.in_progress?(job_id)
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
return render status: :accepted, json: {
@ -147,6 +153,12 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
if @application_setting.self_monitoring_project_id.nil?
return render status: :ok, json: {
message: _('Self-monitoring project has been successfully deleted')
}
end
render status: :bad_request, json: {
message: _('Self-monitoring project was not deleted. Please check logs ' \
'for any error messages')
@ -161,8 +173,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def self_monitoring_data
{
project_id: Gitlab::CurrentSettings.self_monitoring_project_id,
project_full_path: Gitlab::CurrentSettings.self_monitoring_project&.full_path
project_id: @application_setting.self_monitoring_project_id,
project_full_path: @application_setting.self_monitoring_project&.full_path
}
end

View File

@ -24,7 +24,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
def mark_as_ham
spam_log = SpamLog.find(params[:id])
if HamService.new(spam_log).mark_as_ham!
if Spam::HamService.new(spam_log).mark_as_ham!
redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.')
else
redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.')

View File

@ -8,7 +8,6 @@ module Resolvers
description: 'ID of the Sentry issue'
def resolve(**args)
project = object
current_user = context[:current_user]
issue_id = GlobalID.parse(args[:id]).model_id
@ -23,6 +22,14 @@ module Resolvers
issue
end
private
def project
return object.gitlab_project if object.respond_to?(:gitlab_project)
object
end
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Resolvers
module ErrorTracking
class SentryErrorCollectionResolver < BaseResolver
def resolve(**args)
project = object
service = ::ErrorTracking::ListIssuesService.new(
project,
context[:current_user]
)
Gitlab::ErrorTracking::ErrorCollection.new(
external_url: service.external_url,
project: project
)
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Resolvers
module ErrorTracking
class SentryErrorsResolver < BaseResolver
def resolve(**args)
args[:cursor] = args.delete(:after)
project = object.project
result = ::ErrorTracking::ListIssuesService.new(
project,
context[:current_user],
args
).execute
next_cursor = result[:pagination]&.dig('next', 'cursor')
previous_cursor = result[:pagination]&.dig('previous', 'cursor')
issues = result[:issues]
# ReactiveCache is still fetching data
return if issues.nil?
Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues)
end
end
end
end

View File

@ -4,8 +4,9 @@ module Types
module ErrorTracking
class SentryDetailedErrorType < ::Types::BaseObject
graphql_name 'SentryDetailedError'
description 'A Sentry error.'
present_using SentryDetailedErrorPresenter
present_using SentryErrorPresenter
authorize :read_sentry_issue
@ -92,18 +93,6 @@ module Types
field :tags, Types::ErrorTracking::SentryErrorTagsType,
null: false,
description: 'Tags associated with the Sentry Error'
def first_seen
DateTime.parse(object.first_seen)
end
def last_seen
DateTime.parse(object.last_seen)
end
def project_id
Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s
end
end
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
module Types
module ErrorTracking
class SentryErrorCollectionType < ::Types::BaseObject
graphql_name 'SentryErrorCollection'
description 'An object containing a collection of Sentry errors, and a detailed error.'
authorize :read_sentry_issue
field :errors,
Types::ErrorTracking::SentryErrorType.connection_type,
connection: false,
null: true,
description: "Collection of Sentry Errors",
extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension],
resolver: Resolvers::ErrorTracking::SentryErrorsResolver do
argument :search_term,
String,
description: 'Search term for the Sentry error.',
required: false
argument :sort,
String,
description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.',
required: false
end
field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType,
null: true,
description: 'Detailed version of a Sentry error on the project',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
field :external_url,
GraphQL::STRING_TYPE,
null: true,
description: "External URL for Sentry"
end
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Types
module ErrorTracking
# rubocop: disable Graphql/AuthorizeTypes
class SentryErrorType < ::Types::BaseObject
graphql_name 'SentryError'
description 'A Sentry error. A simplified version of SentryDetailedError.'
present_using SentryErrorPresenter
field :id, GraphQL::ID_TYPE,
null: false,
description: 'ID (global ID) of the error'
field :sentry_id, GraphQL::STRING_TYPE,
method: :id,
null: false,
description: 'ID (Sentry ID) of the error'
field :first_seen, Types::TimeType,
null: false,
description: 'Timestamp when the error was first seen'
field :last_seen, Types::TimeType,
null: false,
description: 'Timestamp when the error was last seen'
field :title, GraphQL::STRING_TYPE,
null: false,
description: 'Title of the error'
field :type, GraphQL::STRING_TYPE,
null: false,
description: 'Type of the error'
field :user_count, GraphQL::INT_TYPE,
null: false,
description: 'Count of users affected by the error'
field :count, GraphQL::INT_TYPE,
null: false,
description: 'Count of occurrences'
field :message, GraphQL::STRING_TYPE,
null: true,
description: 'Sentry metadata message of the error'
field :culprit, GraphQL::STRING_TYPE,
null: false,
description: 'Culprit of the error'
field :external_url, GraphQL::STRING_TYPE,
null: false,
description: 'External URL of the error'
field :short_id, GraphQL::STRING_TYPE,
null: false,
description: 'Short ID (Sentry ID) of the error'
field :status, Types::ErrorTracking::SentryErrorStatusEnum,
null: false,
description: 'Status of the error'
field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
null: false,
description: 'Last 24hr stats of the error'
field :sentry_project_id, GraphQL::ID_TYPE,
method: :project_id,
null: false,
description: 'ID of the project (Sentry project)'
field :sentry_project_name, GraphQL::STRING_TYPE,
method: :project_name,
null: false,
description: 'Name of the project affected by the error'
field :sentry_project_slug, GraphQL::STRING_TYPE,
method: :project_slug,
null: false,
description: 'Slug of the project affected by the error'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end

View File

@ -173,6 +173,12 @@ module Types
null: true,
description: 'Snippets of the project',
resolver: Resolvers::Projects::SnippetsResolver
field :sentry_errors,
Types::ErrorTracking::SentryErrorCollectionType,
null: true,
description: 'Paginated collection of Sentry errors on the project',
resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver
end
end

View File

@ -484,10 +484,10 @@ class Commit
end
def commit_reference(from, referable_commit_id, full: false)
reference = project.to_reference(from, full: full)
base = project.to_reference_base(from, full: full)
if reference.present?
"#{reference}#{self.class.reference_prefix}#{referable_commit_id}"
if base.present?
"#{base}#{self.class.reference_prefix}#{referable_commit_id}"
else
referable_commit_id
end

View File

@ -92,7 +92,7 @@ class CommitRange
alias_method :id, :to_s
def to_reference(from = nil, full: false)
project_reference = project.to_reference(from, full: full)
project_reference = project.to_reference_base(from, full: full)
if project_reference.present?
project_reference + self.class.reference_prefix + self.id
@ -102,7 +102,7 @@ class CommitRange
end
def reference_link_text(from = nil)
project_reference = project.to_reference(from)
project_reference = project.to_reference_base(from)
reference = ref_from + notation + ref_to
if project_reference.present?

View File

@ -23,6 +23,14 @@ module Referable
''
end
# If this referable object can serve as the base for the
# reference of child objects (e.g. projects are the base of
# issues), but it is formatted differently, then you may wish
# to override this method.
def to_reference_base(from = nil, full:)
to_reference(from, full: full)
end
def reference_link_text(from = nil)
to_reference(from)
end

View File

@ -173,7 +173,7 @@ class Issue < ApplicationRecord
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
"#{project.to_reference(from, full: full)}#{reference}"
"#{project.to_reference_base(from, full: full)}#{reference}"
end
def suggested_branch_name

View File

@ -225,7 +225,7 @@ class Label < ApplicationRecord
reference = "#{self.class.reference_prefix}#{format_reference}"
if from
"#{from.to_reference(target_project, full: full)}#{reference}"
"#{from.to_reference_base(target_project, full: full)}#{reference}"
else
reference
end

View File

@ -396,7 +396,7 @@ class MergeRequest < ApplicationRecord
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
"#{project.to_reference(from, full: full)}#{reference}"
"#{project.to_reference_base(from, full: full)}#{reference}"
end
def commits(limit: nil)

View File

@ -228,7 +228,7 @@ class Milestone < ApplicationRecord
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
"#{project.to_reference(from, full: full)}#{reference}"
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end

View File

@ -1068,12 +1068,19 @@ class Project < ApplicationRecord
end
end
def to_reference_with_postfix
"#{to_reference(full: true)}#{self.class.reference_postfix}"
# Produce a valid reference (see Referable#to_reference)
#
# NB: For projects, all references are 'full' - i.e. they all include the
# full_path, rather than just the project name. For this reason, we ignore
# the value of `full:` passed to this method, which is part of the Referable
# interface.
def to_reference(from = nil, full: false)
base = to_reference_base(from, full: true)
"#{base}#{self.class.reference_postfix}"
end
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
def to_reference_base(from = nil, full: false)
if full || cross_namespace_reference?(from)
full_path
elsif cross_project_reference?(from)

View File

@ -180,7 +180,7 @@ class Snippet < ApplicationRecord
reference = "#{self.class.reference_prefix}#{id}"
if project.present?
"#{project.to_reference(from, full: full)}#{reference}"
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module ErrorTracking
class DetailedErrorPolicy < BasePolicy
class BasePolicy < ::BasePolicy
delegate { @subject.gitlab_project }
end
end

View File

@ -1,10 +1,22 @@
# frozen_string_literal: true
class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated
class SentryErrorPresenter < Gitlab::View::Presenter::Delegated
presents :error
FrequencyStruct = Struct.new(:time, :count, keyword_init: true)
def first_seen
DateTime.parse(error.first_seen)
end
def last_seen
DateTime.parse(error.last_seen)
end
def project_id
Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s
end
def frequency
utc_offset = Time.zone_offset('UTC')

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
class HamService
attr_accessor :spam_log
def initialize(spam_log)
@spam_log = spam_log
end
def mark_as_ham!
if akismet.submit_ham
spam_log.update_attribute(:submitted_as_ham, true)
else
false
end
end
private
def akismet
user = spam_log.user
@akismet ||= AkismetService.new(
user.name,
user.email,
spam_log.text,
ip_address: spam_log.source_ip,
user_agent: spam_log.user_agent
)
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Spam
class HamService
attr_accessor :spam_log
def initialize(spam_log)
@spam_log = spam_log
end
def mark_as_ham!
if akismet.submit_ham
spam_log.update_attribute(:submitted_as_ham, true)
else
false
end
end
private
def akismet
user = spam_log.user
@akismet ||= AkismetService.new(
user.name,
user.email,
spam_log.text,
ip_address: spam_log.source_ip,
user_agent: spam_log.user_agent
)
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Add querying of Sentry errors to Graphql
merge_request: 21802
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: refactoring gl_dropdown.js to use ES6 classes instead of constructor functions
merge_request: 20488
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Add license FAQ link to license expired message
merge_request:
author:
type: added

View File

@ -342,16 +342,28 @@ pages:
1. [Reconfigure GitLab][reconfigure] for the changes to take effect.
### Using a custom Certificate Authority (CA) with Access Control
### Using a custom Certificate Authority (CA)
When using certificates issued by a custom CA, Access Control on GitLab Pages may fail to work if the custom CA is not recognized.
When using certificates issued by a custom CA, [Access Control](../../user/project/pages/pages_access_control.md#gitlab-pages-access-control) and
the [online view of HTML job artifacts](../../user/project/pipelines/job_artifacts.md#browsing-artifacts)
will fail to work if the custom CA is not recognized.
This usually results in this error:
`Post /oauth/token: x509: certificate signed by unknown authority`.
For GitLab Pages Access Control with TLS/SSL certs issued by an internal or custom CA:
For installation from source this can be fixed by installing the custom Certificate
Authority (CA) in the system certificate store.
1. Copy the certificate bundle to `/opt/gitlab/embedded/ssl/certs/` in `.pem` format.
For Omnibus, normally this would be fixed by [installing a custom CA in GitLab Omnibus](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates)
but a [bug](https://gitlab.com/gitlab-org/gitlab/issues/25411) is currently preventing
that method from working. Use the following workaround:
1. Append your GitLab server TLS/SSL certficate to `/opt/gitlab/embedded/ssl/certs/cacert.pem` where `gitlab-domain-example.com` is your GitLab application URL
```bash
printf "\ngitlab-domain-example.com\n===========================\n" | sudo tee --append /opt/gitlab/embedded/ssl/certs/cacert.pem
echo -n | openssl s_client -connect gitlab-domain-example.com:443 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | sudo tee --append /opt/gitlab/embedded/ssl/certs/cacert.pem
```
1. [Restart](../restart_gitlab.md) the GitLab Pages Daemon. For GitLab Omnibus instances:
@ -359,6 +371,9 @@ For GitLab Pages Access Control with TLS/SSL certs issued by an internal or cust
sudo gitlab-ctl restart gitlab-pages
```
CAUTION: **Caution:**
Some GitLab Omnibus upgrades will revert this workaround and you'll need to apply it again.
## Activate verbose logging for daemon
Verbose logging was [introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2533) in

View File

@ -5453,6 +5453,11 @@ type Project {
id: ID!
): SentryDetailedError
"""
Paginated collection of Sentry errors on the project
"""
sentryErrors: SentryErrorCollection
"""
E-mail address of the service desk.
"""
@ -6054,6 +6059,9 @@ type RootStorageStatistics {
wikiSize: Int!
}
"""
A Sentry error.
"""
type SentryDetailedError {
"""
Count of occurrences
@ -6186,6 +6194,186 @@ type SentryDetailedError {
userCount: Int!
}
"""
A Sentry error. A simplified version of SentryDetailedError.
"""
type SentryError {
"""
Count of occurrences
"""
count: Int!
"""
Culprit of the error
"""
culprit: String!
"""
External URL of the error
"""
externalUrl: String!
"""
Timestamp when the error was first seen
"""
firstSeen: Time!
"""
Last 24hr stats of the error
"""
frequency: [SentryErrorFrequency!]!
"""
ID (global ID) of the error
"""
id: ID!
"""
Timestamp when the error was last seen
"""
lastSeen: Time!
"""
Sentry metadata message of the error
"""
message: String
"""
ID (Sentry ID) of the error
"""
sentryId: String!
"""
ID of the project (Sentry project)
"""
sentryProjectId: ID!
"""
Name of the project affected by the error
"""
sentryProjectName: String!
"""
Slug of the project affected by the error
"""
sentryProjectSlug: String!
"""
Short ID (Sentry ID) of the error
"""
shortId: String!
"""
Status of the error
"""
status: SentryErrorStatus!
"""
Title of the error
"""
title: String!
"""
Type of the error
"""
type: String!
"""
Count of users affected by the error
"""
userCount: Int!
}
"""
An object containing a collection of Sentry errors, and a detailed error.
"""
type SentryErrorCollection {
"""
Detailed version of a Sentry error on the project
"""
detailedError(
"""
ID of the Sentry issue
"""
id: ID!
): SentryDetailedError
"""
Collection of Sentry Errors
"""
errors(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Search term for the Sentry error.
"""
searchTerm: String
"""
Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.
"""
sort: String
): SentryErrorConnection
"""
External URL for Sentry
"""
externalUrl: String
}
"""
The connection type for SentryError.
"""
type SentryErrorConnection {
"""
A list of edges.
"""
edges: [SentryErrorEdge]
"""
A list of nodes.
"""
nodes: [SentryError]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SentryErrorEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SentryError
}
type SentryErrorFrequency {
"""
Count of errors received since the previously recorded time

View File

@ -1433,6 +1433,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryErrors",
"description": "Paginated collection of Sentry errors on the project",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SentryErrorCollection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "serviceDeskAddress",
"description": "E-mail address of the service desk.",
@ -16708,7 +16722,7 @@
{
"kind": "OBJECT",
"name": "SentryDetailedError",
"description": null,
"description": "A Sentry error.",
"fields": [
{
"name": "count",
@ -17408,6 +17422,568 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryErrorCollection",
"description": "An object containing a collection of Sentry errors, and a detailed error.",
"fields": [
{
"name": "detailedError",
"description": "Detailed version of a Sentry error on the project",
"args": [
{
"name": "id",
"description": "ID of the Sentry issue",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SentryDetailedError",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Collection of Sentry Errors",
"args": [
{
"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
},
{
"name": "searchTerm",
"description": "Search term for the Sentry error.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "sort",
"description": "Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SentryErrorConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalUrl",
"description": "External URL for Sentry",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryErrorConnection",
"description": "The connection type for SentryError.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SentryErrorEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SentryError",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryErrorEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SentryError",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryError",
"description": "A Sentry error. A simplified version of SentryDetailedError.",
"fields": [
{
"name": "count",
"description": "Count of occurrences",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "culprit",
"description": "Culprit of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalUrl",
"description": "External URL of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "firstSeen",
"description": "Timestamp when the error was first seen",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "frequency",
"description": "Last 24hr stats of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SentryErrorFrequency",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID (global ID) of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeen",
"description": "Timestamp when the error was last seen",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "message",
"description": "Sentry metadata message of the error",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryId",
"description": "ID (Sentry ID) of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryProjectId",
"description": "ID of the project (Sentry project)",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryProjectName",
"description": "Name of the project affected by the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryProjectSlug",
"description": "Slug of the project affected by the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "shortId",
"description": "Short ID (Sentry ID) of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "Status of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SentryErrorStatus",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": "Title of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": "Type of the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userCount",
"description": "Count of users affected by the error",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Metadata",

View File

@ -815,6 +815,7 @@ Information about pagination in a connection.
| `repository` | Repository | Git repository of the project |
| `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project |
| `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project |
| `sentryErrors` | SentryErrorCollection | Paginated collection of Sentry errors on the project |
| `serviceDeskAddress` | String | E-mail address of the service desk. |
| `serviceDeskEnabled` | Boolean | Indicates if the project has service desk enabled. |
| `sharedRunnersEnabled` | Boolean | Indicates if shared runners are enabled on the project |
@ -919,6 +920,8 @@ Autogenerated return type of RemoveAwardEmoji
## SentryDetailedError
A Sentry error.
| Name | Type | Description |
| --- | ---- | ---------- |
| `count` | Int! | Count of occurrences |
@ -948,6 +951,40 @@ Autogenerated return type of RemoveAwardEmoji
| `type` | String! | Type of the error |
| `userCount` | Int! | Count of users affected by the error |
## SentryError
A Sentry error. A simplified version of SentryDetailedError.
| Name | Type | Description |
| --- | ---- | ---------- |
| `count` | Int! | Count of occurrences |
| `culprit` | String! | Culprit of the error |
| `externalUrl` | String! | External URL of the error |
| `firstSeen` | Time! | Timestamp when the error was first seen |
| `frequency` | SentryErrorFrequency! => Array | Last 24hr stats of the error |
| `id` | ID! | ID (global ID) of the error |
| `lastSeen` | Time! | Timestamp when the error was last seen |
| `message` | String | Sentry metadata message of the error |
| `sentryId` | String! | ID (Sentry ID) of the error |
| `sentryProjectId` | ID! | ID of the project (Sentry project) |
| `sentryProjectName` | String! | Name of the project affected by the error |
| `sentryProjectSlug` | String! | Slug of the project affected by the error |
| `shortId` | String! | Short ID (Sentry ID) of the error |
| `status` | SentryErrorStatus! | Status of the error |
| `title` | String! | Title of the error |
| `type` | String! | Type of the error |
| `userCount` | Int! | Count of users affected by the error |
## SentryErrorCollection
An object containing a collection of Sentry errors, and a detailed error.
| Name | Type | Description |
| --- | ---- | ---------- |
| `detailedError` | SentryDetailedError | Detailed version of a Sentry error on the project |
| `errors` | SentryErrorConnection | Collection of Sentry Errors |
| `externalUrl` | String | External URL for Sentry |
## SentryErrorFrequency
| Name | Type | Description |

View File

@ -385,6 +385,21 @@ NOTE: **Note:**
The usage of `perform_enqueued_jobs` is currently useless since our
workers aren't inheriting from `ApplicationJob` / `ActiveJob::Base`.
#### DNS
DNS requests are stubbed universally in the test suite
(as of [!22368](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22368)), as DNS can
cause issues depending on the developer's local network. There are RSpec labels
available in `spec/support/dns.rb` which you can apply to tests if you need to
bypass the DNS stubbing, e.g.:
```
it "really connects to Prometheus", :permit_dns do
```
And if you need more specific control, the DNS blocking is implemented in
`spec/support/helpers/dns_helpers.rb` and these methods can be called elsewhere.
#### Filesystem
Filesystem data can be roughly split into "repositories", and "everything else".

View File

@ -121,7 +121,7 @@ module Banzai
def object_link_text(object, matches)
milestone_link = escape_once(super)
reference = object.project&.to_reference(project)
reference = object.project&.to_reference_base(project)
if reference.present?
"#{milestone_link} <i>in #{reference}</i>".html_safe

View File

@ -104,7 +104,7 @@ module Banzai
def link_to_project(project, link_content: nil)
url = urls.project_url(project, only_path: context[:only_path])
data = data_attribute(project: project.id)
content = link_content || project.to_reference_with_postfix
content = link_content || project.to_reference
link_tag(url, data, content, project.name)
end

View File

@ -35,7 +35,7 @@ module Gitlab
:user_count
def self.declarative_policy_class
'ErrorTracking::DetailedErrorPolicy'
'ErrorTracking::BasePolicy'
end
end
end

View File

@ -4,11 +4,16 @@ module Gitlab
module ErrorTracking
class Error
include ActiveModel::Model
include GlobalID::Identification
attr_accessor :id, :title, :type, :user_count, :count,
:first_seen, :last_seen, :message, :culprit,
:external_url, :project_id, :project_name, :project_slug,
:short_id, :status, :frequency
def self.declarative_policy_class
'ErrorTracking::BasePolicy'
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Gitlab
module ErrorTracking
class ErrorCollection
include GlobalID::Identification
attr_accessor :issues, :external_url, :project
alias_attribute :gitlab_project, :project
def initialize(project:, external_url: nil, issues: [])
@project = project
@external_url = external_url
@issues = issues
end
def self.declarative_policy_class
'ErrorTracking::BasePolicy'
end
end
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module Extensions
class ExternallyPaginatedArrayExtension < GraphQL::Schema::Field::ConnectionExtension
def resolve(object:, arguments:, context:)
yield(object, arguments)
end
end
end
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
class ProjectTreeLoader
def load(path, dedup_entries: false)
tree_hash = ActiveSupport::JSON.decode(IO.read(path))
if dedup_entries
dedup_tree(tree_hash)
else
tree_hash
end
end
private
# This function removes duplicate entries from the given tree recursively
# by caching nodes it encounters repeatedly. We only consider nodes for
# which there can actually be multiple equivalent instances (e.g. strings,
# hashes and arrays, but not `nil`s, numbers or booleans.)
#
# The algorithm uses a recursive depth-first descent with 3 cases, starting
# with a root node (the tree/hash itself):
# - a node has already been cached; in this case we return it from the cache
# - a node has not been cached yet but should be; descend into its children
# - a node is neither cached nor qualifies for caching; this is a no-op
def dedup_tree(node, nodes_seen = {})
if nodes_seen.key?(node) && distinguishable?(node)
yield nodes_seen[node]
elsif should_dedup?(node)
nodes_seen[node] = node
case node
when Array
node.each_index do |idx|
dedup_tree(node[idx], nodes_seen) do |cached_node|
node[idx] = cached_node
end
end
when Hash
node.each do |k, v|
dedup_tree(v, nodes_seen) do |cached_node|
node[k] = cached_node
end
end
end
else
node
end
end
# We do not need to consider nodes for which there cannot be multiple instances
def should_dedup?(node)
node && !(node.is_a?(Numeric) || node.is_a?(TrueClass) || node.is_a?(FalseClass))
end
# We can only safely de-dup values that are distinguishable. True value objects
# are always distinguishable by nature. Hashes however can represent entities,
# which are identified by ID, not value. We therefore disallow de-duping hashes
# that do not have an `id` field, since we might risk dropping entities that
# have equal attributes yet different identities.
def distinguishable?(node)
if node.is_a?(Hash)
node.key?('id')
else
true
end
end
end
end
end

View File

@ -3,15 +3,17 @@
module Gitlab
module ImportExport
class ProjectTreeRestorer
LARGE_PROJECT_FILE_SIZE_BYTES = 500.megabyte
attr_reader :user
attr_reader :shared
attr_reader :project
def initialize(user:, shared:, project:)
@path = File.join(shared.export_path, 'project.json')
@user = user
@shared = shared
@project = project
@tree_loader = ProjectTreeLoader.new
end
def restore
@ -36,9 +38,16 @@ module Gitlab
private
def large_project?(path)
File.size(path) >= LARGE_PROJECT_FILE_SIZE_BYTES
end
def read_tree_hash
json = IO.read(@path)
ActiveSupport::JSON.decode(json)
path = File.join(@shared.export_path, 'project.json')
dedup_entries = large_project?(path) &&
Feature.enabled?(:dedup_project_import_metadata, project.group)
@tree_loader.load(path, dedup_entries: dedup_entries)
rescue => e
Rails.logger.error("Import/Export error: #{e.message}") # rubocop:disable Gitlab/RailsLogger
raise Gitlab::ImportExport::Error.new('Incorrect JSON format')

View File

@ -159,7 +159,7 @@ module Gitlab
def build_relation(relation_key, relation_definition, data_hash)
# TODO: This is hack to not create relation for the author
# Rather make `RelationFactory#set_note_author` to take care of that
return data_hash if relation_key == 'author'
return data_hash if relation_key == 'author' || already_restored?(data_hash)
# create relation objects recursively for all sub-objects
relation_definition.each do |sub_relation_key, sub_relation_definition|
@ -169,6 +169,13 @@ module Gitlab
@relation_factory.create(relation_factory_params(relation_key, data_hash))
end
# Since we update the data hash in place as we restore relation items,
# and since we also de-duplicate items, we might encounter items that
# have already been restored in a previous iteration.
def already_restored?(relation_item)
!relation_item.is_a?(Hash)
end
def transform_sub_relations!(data_hash, sub_relation_key, sub_relation_definition)
sub_data_hash = data_hash[sub_relation_key]
return unless sub_data_hash

View File

@ -8450,6 +8450,9 @@ msgstr ""
msgid "For public projects, anyone can view pipelines and access job details (output logs and artifacts)"
msgstr ""
msgid "For renewal instructions %{link_start}view our Licensing FAQ.%{link_end}"
msgstr ""
msgid "Forgot your password?"
msgstr ""

View File

@ -1,41 +1,20 @@
# frozen_string_literal: true
FactoryBot.define do
factory :detailed_error_tracking_error, class: 'Gitlab::ErrorTracking::DetailedError' do
id { '1' }
title { 'title' }
type { 'error' }
user_count { 1 }
count { 2 }
first_seen { Time.now.iso8601 }
last_seen { Time.now.iso8601 }
message { 'message' }
culprit { 'culprit' }
external_url { 'http://example.com/id' }
factory :detailed_error_tracking_error, parent: :error_tracking_error, class: 'Gitlab::ErrorTracking::DetailedError' do
gitlab_issue { 'http://gitlab.example.com/issues/1' }
external_base_url { 'http://example.com' }
project_id { 'project1' }
project_name { 'project name' }
project_slug { 'project_name' }
short_id { 'ID' }
status { 'unresolved' }
first_release_last_commit { '68c914da9' }
last_release_last_commit { '9ad419c86' }
first_release_short_version { 'abc123' }
last_release_short_version { 'abc123' }
first_release_version { '12345678' }
tags do
{
level: 'error',
logger: 'rails'
}
end
frequency do
[
[Time.now.to_i, 10]
]
end
gitlab_issue { 'http://gitlab.example.com/issues/1' }
first_release_last_commit { '68c914da9' }
last_release_last_commit { '9ad419c86' }
first_release_short_version { 'abc123' }
last_release_short_version { 'abc123' }
first_release_version { '12345678' }
skip_create
end
end

View File

@ -2,13 +2,13 @@
FactoryBot.define do
factory :error_tracking_error, class: 'Gitlab::ErrorTracking::Error' do
id { 'id' }
id { '1' }
title { 'title' }
type { 'error' }
user_count { 1 }
count { 2 }
first_seen { Time.now }
last_seen { Time.now }
first_seen { Time.now.iso8601 }
last_seen { Time.now.iso8601 }
message { 'message' }
culprit { 'culprit' }
external_url { 'http://example.com/id' }
@ -17,7 +17,11 @@ FactoryBot.define do
project_slug { 'project_name' }
short_id { 'ID' }
status { 'unresolved' }
frequency { [] }
frequency do
[
[Time.now.to_i, 10]
]
end
skip_create
end

View File

@ -32,7 +32,7 @@ describe 'issue move to another project' do
let(:new_project) { create(:project) }
let(:new_project_search) { create(:project) }
let(:text) { "Text with #{mr.to_reference}" }
let(:cross_reference) { old_project.to_reference(new_project) }
let(:cross_reference) { old_project.to_reference_base(new_project) }
before do
old_project.add_reporter(user)

View File

@ -0,0 +1,43 @@
{
"simple": 42,
"duped_hash_with_id": {
"id": 0,
"v1": 1
},
"duped_hash_no_id": {
"v1": 1
},
"duped_array": [
"v2"
],
"array": [
{
"duped_hash_with_id": {
"id": 0,
"v1": 1
}
},
{
"duped_array": [
"v2"
]
},
{
"duped_hash_no_id": {
"v1": 1
}
}
],
"nested": {
"duped_hash_with_id": {
"id": 0,
"v1": 1
},
"duped_array": [
"v2"
],
"array": [
"don't touch"
]
}
}

View File

@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/settings/components/settings_form.vue';
import { createStore } from '~/registry/settings/store/';
@ -15,6 +16,9 @@ describe('Settings Form', () => {
let dispatchSpy;
const FORM_ELEMENTS_ID_PREFIX = '#expiration-policy';
const trackingPayload = {
label: 'docker_container_retention_and_expiration_policies',
};
const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
@ -48,6 +52,7 @@ describe('Settings Form', () => {
store.dispatch('setInitialState', stringifiedFormOptions);
dispatchSpy = jest.spyOn(store, 'dispatch');
mountComponent();
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
@ -118,15 +123,23 @@ describe('Settings Form', () => {
beforeEach(() => {
form = findForm();
});
it('cancel has type reset', () => {
expect(findCancelButton().attributes('type')).toBe('reset');
});
it('form reset event call the appropriate function', () => {
dispatchSpy.mockReturnValue();
form.trigger('reset');
// expect.any(Object) is necessary because the event payload is passed to the function
expect(dispatchSpy).toHaveBeenCalledWith('resetSettings', expect.any(Object));
describe('form cancel event', () => {
it('has type reset', () => {
expect(findCancelButton().attributes('type')).toBe('reset');
});
it('calls the appropriate function', () => {
dispatchSpy.mockReturnValue();
form.trigger('reset');
expect(dispatchSpy).toHaveBeenCalledWith('resetSettings');
});
it('tracks the reset event', () => {
dispatchSpy.mockReturnValue();
form.trigger('reset');
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'reset_form', trackingPayload);
});
});
it('save has type submit', () => {
@ -177,6 +190,12 @@ describe('Settings Form', () => {
expect(dispatchSpy).toHaveBeenCalledWith('saveSettings');
});
it('tracks the submit event', () => {
dispatchSpy.mockResolvedValue();
form.trigger('submit');
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
});
it('show a success toast when submit succeed', () => {
dispatchSpy.mockResolvedValue();
form.trigger('submit');

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::ErrorTracking::SentryErrorCollectionResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') }
before do
project.add_developer(current_user)
allow(ErrorTracking::ListIssuesService)
.to receive(:new)
.and_return list_issues_service
end
describe '#resolve' do
it 'returns an error collection object' do
expect(resolve_error_collection).to be_a Gitlab::ErrorTracking::ErrorCollection
end
it 'provides the service url' do
fake_url = 'http://test.com'
expect(list_issues_service)
.to receive(:external_url)
.and_return(fake_url)
result = resolve_error_collection
expect(result.external_url).to eq fake_url
end
it 'provides the project' do
expect(resolve_error_collection.project).to eq project
end
end
private
def resolve_error_collection(context = { current_user: current_user })
resolve(described_class, obj: project, args: {}, ctx: context)
end
end

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::ErrorTracking::SentryErrorsResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:error_collection) { Gitlab::ErrorTracking::ErrorCollection.new(project: project) }
let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') }
let(:issues) { nil }
let(:pagination) { nil }
describe '#resolve' do
context 'insufficient user permission' do
let(:user) { create(:user) }
it 'returns nil' do
context = { current_user: user }
expect(resolve_errors({}, context)).to eq nil
end
end
context 'user with permission' do
before do
project.add_developer(current_user)
allow(ErrorTracking::ListIssuesService)
.to receive(:new)
.and_return list_issues_service
end
context 'when after arg given' do
let(:after) { "1576029072000:0:0" }
it 'gives the cursor arg' do
expect(ErrorTracking::ListIssuesService)
.to receive(:new)
.with(project, current_user, { cursor: after })
.and_return list_issues_service
resolve_errors({ after: after })
end
end
context 'when no issues fetched' do
before do
allow(list_issues_service)
.to receive(:execute)
.and_return(
issues: nil
)
end
it 'returns nil' do
expect(resolve_errors).to eq nil
end
end
context 'when issues returned' do
let(:issues) { [:issue_1, :issue_2] }
let(:pagination) do
{
'next' => { 'cursor' => 'next' },
'previous' => { 'cursor' => 'prev' }
}
end
before do
allow(list_issues_service)
.to receive(:execute)
.and_return(
issues: issues,
pagination: pagination
)
end
it 'sets the issues' do
expect(resolve_errors).to contain_exactly(*issues)
end
it 'sets the pagination variables' do
result = resolve_errors
expect(result.next_cursor).to eq 'next'
expect(result.previous_cursor).to eq 'prev'
end
it 'returns an externally paginated array' do
expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray
end
end
end
end
private
def resolve_errors(args = {}, context = { current_user: current_user })
resolve(described_class, obj: error_collection, args: args, ctx: context)
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['SentryErrorCollection'] do
it { expect(described_class.graphql_name).to eq('SentryErrorCollection') }
it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
it 'exposes the expected fields' do
expected_fields = %i[
errors
detailed_error
external_url
]
is_expected.to have_graphql_fields(*expected_fields)
end
describe 'errors field' do
subject { described_class.fields['errors'] }
it 'returns errors' do
aggregate_failures 'testing the correct types are returned' do
is_expected.to have_graphql_type(Types::ErrorTracking::SentryErrorType.connection_type)
is_expected.to have_graphql_extension(Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension)
is_expected.to have_graphql_resolver(Resolvers::ErrorTracking::SentryErrorsResolver)
end
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['SentryError'] do
it { expect(described_class.graphql_name).to eq('SentryError') }
it 'exposes the expected fields' do
expected_fields = %i[
id
sentryId
title
type
userCount
count
firstSeen
lastSeen
message
culprit
externalUrl
sentryProjectId
sentryProjectName
sentryProjectSlug
shortId
status
frequency
]
is_expected.to have_graphql_fields(*expected_fields)
end
end

View File

@ -229,10 +229,10 @@ describe Banzai::Filter::CommitRangeReferenceFilter do
end
it 'ignores invalid commit IDs on the referenced project' do
exp = act = "Fixed #{project2.to_reference}@#{commit1.id.reverse}...#{commit2.id}"
exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id.reverse}...#{commit2.id}"
expect(reference_filter(act).to_html).to eq exp
exp = act = "Fixed #{project2.to_reference}@#{commit1.id}...#{commit2.id.reverse}"
exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id}...#{commit2.id.reverse}"
expect(reference_filter(act).to_html).to eq exp
end
end

View File

@ -369,7 +369,7 @@ describe Banzai::Filter::LabelReferenceFilter do
end
context 'with project reference' do
let(:reference) { "#{project.to_reference}#{group_label.to_reference(format: :name)}" }
let(:reference) { "#{project.to_reference_base}#{group_label.to_reference(format: :name)}" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}", project: project)
@ -385,7 +385,7 @@ describe Banzai::Filter::LabelReferenceFilter do
end
it 'ignores invalid label names' do
exp = act = %(Label #{project.to_reference}#{Label.reference_prefix}"#{group_label.name.reverse}")
exp = act = %(Label #{project.to_reference_base}#{Label.reference_prefix}"#{group_label.name.reverse}")
expect(reference_filter(act).to_html).to eq exp
end

View File

@ -367,15 +367,17 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(doc.css('a').first.text).to eq(urls.milestone_url(milestone))
end
it 'does not support cross-project references' do
it 'does not support cross-project references', :aggregate_failures do
another_group = create(:group)
another_project = create(:project, :public, group: group)
project_reference = another_project.to_reference(project)
project_reference = another_project.to_reference_base(project)
input_text = "See #{project_reference}#{reference}"
milestone.update!(group: another_group)
doc = reference_filter("See #{project_reference}#{reference}")
doc = reference_filter(input_text)
expect(input_text).to match(Milestone.reference_pattern)
expect(doc.css('a')).to be_empty
end

View File

@ -10,7 +10,7 @@ describe Banzai::Filter::ProjectReferenceFilter do
end
def get_reference(project)
project.to_reference_with_postfix
project.to_reference
end
let(:project) { create(:project, :public) }

View File

@ -8,7 +8,7 @@ describe Gitlab::Gfm::ReferenceRewriter do
let(:new_project) { create(:project, name: 'new-project', group: group) }
let(:user) { create(:user) }
let(:old_project_ref) { old_project.to_reference(new_project) }
let(:old_project_ref) { old_project.to_reference_base(new_project) }
let(:text) { 'some text' }
before do

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::ProjectTreeLoader do
let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/with_duplicates.json' }
let(:project_tree) { JSON.parse(File.read(fixture)) }
context 'without de-duplicating entries' do
let(:parsed_tree) do
subject.load(fixture)
end
it 'parses the JSON into the expected tree' do
expect(parsed_tree).to eq(project_tree)
end
it 'does not de-duplicate entries' do
expect(parsed_tree['duped_hash_with_id']).not_to be(parsed_tree['array'][0]['duped_hash_with_id'])
end
end
context 'with de-duplicating entries' do
let(:parsed_tree) do
subject.load(fixture, dedup_entries: true)
end
it 'parses the JSON into the expected tree' do
expect(parsed_tree).to eq(project_tree)
end
it 'de-duplicates equal values' do
expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['array'][0]['duped_hash_with_id'])
expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['nested']['duped_hash_with_id'])
expect(parsed_tree['duped_array']).to be(parsed_tree['array'][1]['duped_array'])
expect(parsed_tree['duped_array']).to be(parsed_tree['nested']['duped_array'])
end
it 'does not de-duplicate hashes without IDs' do
expect(parsed_tree['duped_hash_no_id']).to eq(parsed_tree['array'][2]['duped_hash_no_id'])
expect(parsed_tree['duped_hash_no_id']).not_to be(parsed_tree['array'][2]['duped_hash_no_id'])
end
it 'keeps single entries intact' do
expect(parsed_tree['simple']).to eq(42)
expect(parsed_tree['nested']['array']).to eq(["don't touch"])
end
end
end

View File

@ -450,7 +450,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
context 'project.json file access check' do
let(:user) { create(:user) }
let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) }
let(:project_tree_restorer) do
described_class.new(user: user, shared: shared, project: project)
end
let(:restored_project_json) { project_tree_restorer.restore }
it 'does not read a symlink' do
@ -725,7 +727,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:tree_hash) { { 'visibility_level' => visibility } }
let(:restorer) { described_class.new(user: user, shared: shared, project: project) }
let(:restorer) do
described_class.new(user: user, shared: shared, project: project)
end
before do
expect(restorer).to receive(:read_tree_hash) { tree_hash }

View File

@ -131,23 +131,19 @@ describe Project do
end
context 'when creating a new project' do
it 'automatically creates a CI/CD settings row' do
project = create(:project)
let_it_be(:project) { create(:project) }
it 'automatically creates a CI/CD settings row' do
expect(project.ci_cd_settings).to be_an_instance_of(ProjectCiCdSetting)
expect(project.ci_cd_settings).to be_persisted
end
it 'automatically creates a container expiration policy row' do
project = create(:project)
expect(project.container_expiration_policy).to be_an_instance_of(ContainerExpirationPolicy)
expect(project.container_expiration_policy).to be_persisted
end
it 'automatically creates a Pages metadata row' do
project = create(:project)
expect(project.pages_metadatum).to be_an_instance_of(ProjectPagesMetadatum)
expect(project.pages_metadatum).to be_persisted
end
@ -532,111 +528,114 @@ describe Project do
it { is_expected.to delegate_method(:last_pipeline).to(:commit).with_arguments(allow_nil: true) }
end
describe '#to_reference_with_postfix' do
it 'returns the full path with reference_postfix' do
namespace = create(:namespace, path: 'sample-namespace')
project = create(:project, path: 'sample-project', namespace: namespace)
describe 'reference methods' do
let_it_be(:owner) { create(:user, name: 'Gitlab') }
let_it_be(:namespace) { create(:namespace, name: 'Sample namespace', path: 'sample-namespace', owner: owner) }
let_it_be(:project) { create(:project, name: 'Sample project', path: 'sample-project', namespace: namespace) }
let_it_be(:group) { create(:group, name: 'Group', path: 'sample-group') }
let_it_be(:another_project) { create(:project, namespace: namespace) }
let_it_be(:another_namespace_project) { create(:project, name: 'another-project') }
expect(project.to_reference_with_postfix).to eq 'sample-namespace/sample-project>'
end
end
describe '#to_reference' do
it 'returns the path with reference_postfix' do
expect(project.to_reference).to eq("#{project.full_path}>")
end
describe '#to_reference' do
let(:owner) { create(:user, name: 'Gitlab') }
let(:namespace) { create(:namespace, path: 'sample-namespace', owner: owner) }
let(:project) { create(:project, path: 'sample-project', namespace: namespace) }
let(:group) { create(:group, name: 'Group', path: 'sample-group') }
it 'returns the path with reference_postfix when arg is self' do
expect(project.to_reference(project)).to eq("#{project.full_path}>")
end
context 'when nil argument' do
it 'returns nil' do
expect(project.to_reference).to be_nil
it 'returns the full_path with reference_postfix when full' do
expect(project.to_reference(full: true)).to eq("#{project.full_path}>")
end
it 'returns the full_path with reference_postfix when cross-project' do
expect(project.to_reference(build_stubbed(:project))).to eq("#{project.full_path}>")
end
end
context 'when full is true' do
it 'returns complete path to the project' do
expect(project.to_reference(full: true)).to eq 'sample-namespace/sample-project'
expect(project.to_reference(project, full: true)).to eq 'sample-namespace/sample-project'
expect(project.to_reference(group, full: true)).to eq 'sample-namespace/sample-project'
describe '#to_reference_base' do
context 'when nil argument' do
it 'returns nil' do
expect(project.to_reference_base).to be_nil
end
end
end
context 'when same project argument' do
it 'returns nil' do
expect(project.to_reference(project)).to be_nil
context 'when full is true' do
it 'returns complete path to the project', :aggregate_failures do
be_full_path = eq('sample-namespace/sample-project')
expect(project.to_reference_base(full: true)).to be_full_path
expect(project.to_reference_base(project, full: true)).to be_full_path
expect(project.to_reference_base(group, full: true)).to be_full_path
end
end
end
context 'when cross namespace project argument' do
let(:another_namespace_project) { create(:project, name: 'another-project') }
it 'returns complete path to the project' do
expect(project.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project'
context 'when same project argument' do
it 'returns nil' do
expect(project.to_reference_base(project)).to be_nil
end
end
end
context 'when same namespace / cross-project argument' do
let(:another_project) { create(:project, namespace: namespace) }
it 'returns path to the project' do
expect(project.to_reference(another_project)).to eq 'sample-project'
context 'when cross namespace project argument' do
it 'returns complete path to the project' do
expect(project.to_reference_base(another_namespace_project)).to eq 'sample-namespace/sample-project'
end
end
end
context 'when different namespace / cross-project argument' do
let(:another_namespace) { create(:namespace, path: 'another-namespace', owner: owner) }
let(:another_project) { create(:project, path: 'another-project', namespace: another_namespace) }
it 'returns full path to the project' do
expect(project.to_reference(another_project)).to eq 'sample-namespace/sample-project'
end
end
context 'when argument is a namespace' do
context 'with same project path' do
context 'when same namespace / cross-project argument' do
it 'returns path to the project' do
expect(project.to_reference(namespace)).to eq 'sample-project'
expect(project.to_reference_base(another_project)).to eq 'sample-project'
end
end
context 'with different project path' do
context 'when different namespace / cross-project argument with same owner' do
let(:another_namespace_same_owner) { create(:namespace, path: 'another-namespace', owner: owner) }
let(:another_project_same_owner) { create(:project, path: 'another-project', namespace: another_namespace_same_owner) }
it 'returns full path to the project' do
expect(project.to_reference(group)).to eq 'sample-namespace/sample-project'
expect(project.to_reference_base(another_project_same_owner)).to eq 'sample-namespace/sample-project'
end
end
context 'when argument is a namespace' do
context 'with same project path' do
it 'returns path to the project' do
expect(project.to_reference_base(namespace)).to eq 'sample-project'
end
end
context 'with different project path' do
it 'returns full path to the project' do
expect(project.to_reference_base(group)).to eq 'sample-namespace/sample-project'
end
end
end
end
end
describe '#to_human_reference' do
let(:owner) { create(:user, name: 'Gitlab') }
let(:namespace) { create(:namespace, name: 'Sample namespace', owner: owner) }
let(:project) { create(:project, name: 'Sample project', namespace: namespace) }
context 'when nil argument' do
it 'returns nil' do
expect(project.to_human_reference).to be_nil
describe '#to_human_reference' do
context 'when nil argument' do
it 'returns nil' do
expect(project.to_human_reference).to be_nil
end
end
end
context 'when same project argument' do
it 'returns nil' do
expect(project.to_human_reference(project)).to be_nil
context 'when same project argument' do
it 'returns nil' do
expect(project.to_human_reference(project)).to be_nil
end
end
end
context 'when cross namespace project argument' do
let(:another_namespace_project) { create(:project, name: 'another-project') }
it 'returns complete name with namespace of the project' do
expect(project.to_human_reference(another_namespace_project)).to eq 'Gitlab / Sample project'
context 'when cross namespace project argument' do
it 'returns complete name with namespace of the project' do
expect(project.to_human_reference(another_namespace_project)).to eq 'Gitlab / Sample project'
end
end
end
context 'when same namespace / cross-project argument' do
let(:another_project) { create(:project, namespace: namespace) }
it 'returns name of the project' do
expect(project.to_human_reference(another_project)).to eq 'Sample project'
context 'when same namespace / cross-project argument' do
it 'returns name of the project' do
expect(project.to_human_reference(another_project)).to eq 'Sample project'
end
end
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe SentryDetailedErrorPresenter do
describe SentryErrorPresenter do
let(:error) { build(:detailed_error_tracking_error) }
let(:presenter) { described_class.new(error) }
@ -10,7 +10,7 @@ describe SentryDetailedErrorPresenter do
subject { presenter.frequency }
it 'returns an array of frequency structs' do
expect(subject).to include(a_kind_of(SentryDetailedErrorPresenter::FrequencyStruct))
expect(subject).to include(a_kind_of(SentryErrorPresenter::FrequencyStruct))
end
it 'converts the times into UTC time objects' do

View File

@ -0,0 +1,191 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'sentry errors requests' do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:project_setting) { create(:project_error_tracking_setting, project: project) }
let_it_be(:current_user) { project.owner }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('sentryErrors', {}, fields)
)
end
describe 'getting a detailed sentry error' do
let_it_be(:sentry_detailed_error) { build(:detailed_error_tracking_error) }
let(:sentry_gid) { sentry_detailed_error.to_global_id.to_s }
let(:detailed_fields) do
all_graphql_fields_for('SentryDetailedError'.classify)
end
let(:fields) do
query_graphql_field('detailedError', { id: sentry_gid }, detailed_fields)
end
let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'detailedError') }
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
context 'when data is loading via reactive cache' do
before do
post_graphql(query, current_user: current_user)
end
it "is expected to return an empty error" do
expect(error_data).to eq nil
end
end
context 'reactive cache returns data' do
before do
allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
.to receive(:issue_details)
.and_return({ issue: sentry_detailed_error })
post_graphql(query, current_user: current_user)
end
let(:sentry_error) { sentry_detailed_error }
let(:error) { error_data }
it_behaves_like 'setting sentry error data'
it 'is expected to return the frequency correctly' do
aggregate_failures 'it returns the frequency correctly' do
expect(error_data['frequency'].count).to eql sentry_detailed_error.frequency.count
first_frequency = error_data['frequency'].first
expect(Time.parse(first_frequency['time'])).to eql Time.at(sentry_detailed_error.frequency[0][0], in: 0)
expect(first_frequency['count']).to eql sentry_detailed_error.frequency[0][1]
end
end
context 'user does not have permission' do
let(:current_user) { create(:user) }
it "is expected to return an empty error" do
expect(error_data).to eq nil
end
end
end
context 'sentry api returns an error' do
before do
expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
.to receive(:issue_details)
.and_return({ error: 'error message' })
post_graphql(query, current_user: current_user)
end
it 'is expected to handle the error and return nil' do
expect(error_data).to eq nil
end
end
end
describe 'getting an errors list' do
let_it_be(:sentry_error) { build(:error_tracking_error) }
let_it_be(:pagination) do
{
'next' => { 'cursor' => '2222' },
'previous' => { 'cursor' => '1111' }
}
end
let(:fields) do
<<~QUERY
errors {
nodes {
#{all_graphql_fields_for('SentryError'.classify)}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
QUERY
end
let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'errors', 'nodes') }
let(:pagination_data) { graphql_data.dig('project', 'sentryErrors', 'errors', 'pageInfo') }
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
context 'when data is loading via reactive cache' do
before do
post_graphql(query, current_user: current_user)
end
it "is expected to return nil" do
expect(error_data).to eq nil
end
end
context 'reactive cache returns data' do
before do
expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
.to receive(:list_sentry_issues)
.and_return({ issues: [sentry_error], pagination: pagination })
post_graphql(query, current_user: current_user)
end
let(:error) { error_data.first }
it 'is expected to return an array of data' do
expect(error_data).to be_a Array
expect(error_data.count).to eq 1
end
it_behaves_like 'setting sentry error data'
it 'sets the pagination correctly' do
expect(pagination_data['startCursor']).to eq(pagination['previous']['cursor'])
expect(pagination_data['endCursor']).to eq(pagination['next']['cursor'])
end
it 'is expected to return the frequency correctly' do
aggregate_failures 'it returns the frequency correctly' do
error = error_data.first
expect(error['frequency'].count).to eql sentry_error.frequency.count
first_frequency = error['frequency'].first
expect(Time.parse(first_frequency['time'])).to eql Time.at(sentry_error.frequency[0][0], in: 0)
expect(first_frequency['count']).to eql sentry_error.frequency[0][1]
end
end
end
context "sentry api itself errors out" do
before do
expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
.to receive(:list_sentry_issues)
.and_return({ error: 'error message' })
post_graphql(query, current_user: current_user)
end
it 'is expected to handle the error and return nil' do
expect(error_data).to eq nil
end
end
end
end

View File

@ -68,6 +68,8 @@ describe 'Self-Monitoring project requests' do
let(:job_id) { nil }
it 'returns bad_request' do
create(:application_setting)
subject
aggregate_failures do
@ -81,11 +83,10 @@ describe 'Self-Monitoring project requests' do
end
context 'when self-monitoring project exists' do
let(:project) { build(:project) }
let(:project) { create(:project) }
before do
stub_application_setting(self_monitoring_project_id: 1)
stub_application_setting(self_monitoring_project: project)
create(:application_setting, self_monitoring_project_id: project.id)
end
it 'does not need job_id' do
@ -94,7 +95,7 @@ describe 'Self-Monitoring project requests' do
aggregate_failures do
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq(
'project_id' => 1,
'project_id' => project.id,
'project_full_path' => project.full_path
)
end
@ -106,7 +107,7 @@ describe 'Self-Monitoring project requests' do
aggregate_failures do
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq(
'project_id' => 1,
'project_id' => project.id,
'project_full_path' => project.full_path
)
end
@ -179,7 +180,7 @@ describe 'Self-Monitoring project requests' do
context 'when self-monitoring project exists and job does not exist' do
before do
stub_application_setting(self_monitoring_project_id: 1)
create(:application_setting, self_monitoring_project_id: create(:project).id)
end
it 'returns bad_request' do
@ -196,6 +197,10 @@ describe 'Self-Monitoring project requests' do
end
context 'when self-monitoring project does not exist' do
before do
create(:application_setting)
end
it 'does not need job_id' do
get status_delete_self_monitoring_project_admin_application_settings_path

View File

@ -108,6 +108,12 @@ RSpec::Matchers.define :have_graphql_resolver do |expected|
end
end
RSpec::Matchers.define :have_graphql_extension do |expected|
match do |field|
expect(field.metadata[:type_class].extensions).to include(expected)
end
end
RSpec::Matchers.define :expose_permissions_using do |expected|
match do |type|
permission_field = type.fields['userPermissions']

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
RSpec.shared_examples 'setting sentry error data' do
it 'sets the sentry error data correctly' do
aggregate_failures 'testing the sentry error is correct' do
expect(error['id']).to eql sentry_error.to_global_id.to_s
expect(error['sentryId']).to eql sentry_error.id.to_s
expect(error['status']).to eql sentry_error.status.upcase
expect(error['firstSeen']).to eql sentry_error.first_seen
expect(error['lastSeen']).to eql sentry_error.last_seen
end
end
end