Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
b90cf01a88
commit
5db6a7a014
|
|
@ -389,10 +389,7 @@ export default {
|
|||
() => {
|
||||
this.setDiscussions();
|
||||
|
||||
if (
|
||||
this.$store.state.notes.doneFetchingBatchDiscussions &&
|
||||
window.gon?.features?.paginatedMrDiscussions
|
||||
) {
|
||||
if (this.$store.state.notes.doneFetchingBatchDiscussions) {
|
||||
this.unwatchDiscussions();
|
||||
}
|
||||
},
|
||||
|
|
@ -402,10 +399,6 @@ export default {
|
|||
() => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`,
|
||||
() => {
|
||||
if (!this.retrievingBatches && this.$store.state.notes.discussions.length) {
|
||||
if (!window.gon?.features?.paginatedMrDiscussions) {
|
||||
this.unwatchDiscussions();
|
||||
}
|
||||
|
||||
this.unwatchRetrievingBatches();
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -56,11 +56,7 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
discussionTabCounter(val) {
|
||||
if (this.glFeatures.paginatedMrDiscussions) {
|
||||
if (this.doneFetchingBatchDiscussions) {
|
||||
this.discussionCounter = val;
|
||||
}
|
||||
} else {
|
||||
if (this.doneFetchingBatchDiscussions) {
|
||||
this.discussionCounter = val;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,25 @@ export default {
|
|||
artifactsLabel: __('Artifacts'),
|
||||
parametersLabel: __('Parameters'),
|
||||
metricsLabel: __('Metrics'),
|
||||
metadataLabel: __('Metadata'),
|
||||
},
|
||||
computed: {
|
||||
sections() {
|
||||
return [
|
||||
{
|
||||
sectionName: this.$options.i18n.parametersLabel,
|
||||
sectionValues: this.candidate.params,
|
||||
},
|
||||
{
|
||||
sectionName: this.$options.i18n.metricsLabel,
|
||||
sectionValues: this.candidate.metrics,
|
||||
},
|
||||
{
|
||||
sectionName: this.$options.i18n.metadataLabel,
|
||||
sectionValues: this.candidate.metadata,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -67,27 +86,18 @@ export default {
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="divider"></tr>
|
||||
<template v-for="{ sectionName, sectionValues } in sections">
|
||||
<tr :key="sectionName" class="divider"></tr>
|
||||
|
||||
<tr v-for="(param, index) in candidate.params" :key="param.name">
|
||||
<td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold">
|
||||
{{ $options.i18n.parametersLabel }}
|
||||
</td>
|
||||
<td v-else></td>
|
||||
<td class="gl-font-weight-bold">{{ param.name }}</td>
|
||||
<td>{{ param.value }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="divider"></tr>
|
||||
|
||||
<tr v-for="(metric, index) in candidate.metrics" :key="metric.name">
|
||||
<td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold">
|
||||
{{ $options.i18n.metricsLabel }}
|
||||
</td>
|
||||
<td v-else></td>
|
||||
<td class="gl-font-weight-bold">{{ metric.name }}</td>
|
||||
<td>{{ metric.value }}</td>
|
||||
</tr>
|
||||
<tr v-for="(item, index) in sectionValues" :key="item.name">
|
||||
<td v-if="index === 0" class="gl-text-secondary gl-font-weight-bold">
|
||||
{{ sectionName }}
|
||||
</td>
|
||||
<td v-else></td>
|
||||
<td class="gl-font-weight-bold">{{ item.name }}</td>
|
||||
<td>{{ item.value }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const fetchDiscussions = (
|
|||
|
||||
if (
|
||||
getters.noteableType === constants.ISSUE_NOTEABLE_TYPE ||
|
||||
window.gon?.features?.paginatedMrDiscussions
|
||||
getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
|
||||
) {
|
||||
return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ export const PROJECT_DATA = {
|
|||
fullName: 'name_with_namespace',
|
||||
};
|
||||
|
||||
export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/user/search/advanced_search.md';
|
||||
export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md';
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class Import::GithubController < Import::BaseController
|
|||
end
|
||||
|
||||
def client_repos_response
|
||||
@client_repos_response ||= client_proxy.repos(sanitized_filter_param, pagination_options)
|
||||
@client_repos_response ||= client_proxy.repos(sanitized_filter_param, fetch_repos_options)
|
||||
end
|
||||
|
||||
def client_repos
|
||||
|
|
@ -160,7 +160,11 @@ class Import::GithubController < Import::BaseController
|
|||
def sanitized_filter_param
|
||||
super
|
||||
|
||||
@filter = @filter&.tr(' ', '')&.tr(':', '')
|
||||
@filter = sanitize_query_param(@filter)
|
||||
end
|
||||
|
||||
def sanitize_query_param(value)
|
||||
value.to_s.first(255).gsub(/[ :]/, '')
|
||||
end
|
||||
|
||||
def verify_import_enabled
|
||||
|
|
@ -222,6 +226,10 @@ class Import::GithubController < Import::BaseController
|
|||
head :too_many_requests
|
||||
end
|
||||
|
||||
def fetch_repos_options
|
||||
pagination_options.merge(relation_options)
|
||||
end
|
||||
|
||||
def pagination_options
|
||||
{
|
||||
before: params[:before].presence,
|
||||
|
|
@ -233,6 +241,13 @@ class Import::GithubController < Import::BaseController
|
|||
per_page: PAGE_LENGTH
|
||||
}
|
||||
end
|
||||
|
||||
def relation_options
|
||||
{
|
||||
relation_type: params[:relation_type],
|
||||
organization_login: sanitize_query_param(params[:organization_login])
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Import::GithubController.prepend_mod_with('Import::GithubController')
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
|
|||
push_frontend_feature_flag(:refactor_security_extension, @project)
|
||||
push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
|
||||
push_frontend_feature_flag(:moved_mr_sidebar, project)
|
||||
push_frontend_feature_flag(:paginated_mr_discussions, project)
|
||||
push_frontend_feature_flag(:mr_review_submit_comment, project)
|
||||
push_frontend_feature_flag(:mr_experience_survey, project)
|
||||
push_frontend_feature_flag(:realtime_reviewers, project)
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ module EnvironmentHelper
|
|||
s_('Deployment|blocked')
|
||||
end
|
||||
|
||||
klass = "ci-status ci-#{status.dasherize}"
|
||||
text = "#{ci_icon_for_status(status)} #{status_text}".html_safe
|
||||
ci_icon_utilities = "gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base"
|
||||
klass = "ci-status ci-#{status.dasherize} #{ci_icon_utilities}"
|
||||
text = "#{ci_icon_for_status(status)} <span class=\"gl-ml-2\">#{status_text}</span>".html_safe
|
||||
|
||||
if deployment.deployable
|
||||
link_to(text, deployment_path(deployment), class: klass)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ module Projects
|
|||
experiment_name: candidate.experiment.name,
|
||||
path_to_experiment: link_to_experiment(candidate),
|
||||
status: candidate.status
|
||||
}
|
||||
},
|
||||
metadata: candidate.metadata
|
||||
}
|
||||
|
||||
Gitlab::Json.generate(data)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ module BulkImports
|
|||
end
|
||||
|
||||
def execute
|
||||
validate!
|
||||
|
||||
bulk_import = create_bulk_import
|
||||
|
||||
Gitlab::Tracking.event(self.class.name, 'create', label: 'bulk_import_group')
|
||||
|
|
@ -43,7 +45,8 @@ module BulkImports
|
|||
BulkImportWorker.perform_async(bulk_import.id)
|
||||
|
||||
ServiceResponse.success(payload: bulk_import)
|
||||
rescue ActiveRecord::RecordInvalid, BulkImports::NetworkError => e
|
||||
|
||||
rescue ActiveRecord::RecordInvalid, BulkImports::Error, BulkImports::NetworkError => e
|
||||
ServiceResponse.error(
|
||||
message: e.message,
|
||||
http_status: :unprocessable_entity
|
||||
|
|
@ -52,6 +55,11 @@ module BulkImports
|
|||
|
||||
private
|
||||
|
||||
def validate!
|
||||
client.validate_instance_version!
|
||||
client.validate_import_scopes!
|
||||
end
|
||||
|
||||
def create_bulk_import
|
||||
BulkImport.transaction do
|
||||
bulk_import = BulkImport.create!(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ module Issuable
|
|||
|
||||
def paginator
|
||||
return if params[:per_page].blank?
|
||||
return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, issuable.project)
|
||||
|
||||
strong_memoize(:paginator) do
|
||||
issuable
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
= render "projects/merge_requests/awards_block"
|
||||
= render "projects/merge_requests/widget"
|
||||
- if mr_action === "show"
|
||||
- add_page_startup_api_call Feature.enabled?(:paginated_mr_discussions, @project) ? discussions_path(@merge_request, per_page: 20) : discussions_path(@merge_request)
|
||||
- add_page_startup_api_call discussions_path(@merge_request, per_page: 20)
|
||||
- add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json)
|
||||
- add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json)
|
||||
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json,
|
||||
|
|
|
|||
|
|
@ -71,14 +71,14 @@ environment variable.
|
|||
An example configuration file for Redis is in this directory under the name
|
||||
`resque.yml.example`.
|
||||
|
||||
| Name | Fallback instance | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `cache` | | Volatile non-persistent data |
|
||||
| `queues` | | Background job processing queues |
|
||||
| `shared_state` | | Persistent application state |
|
||||
| `trace_chunks` | `shared_state` | [CI trace chunks](https://docs.gitlab.com/ee/administration/job_logs.html#incremental-logging-architecture) |
|
||||
| `rate_limiting` | `cache` | [Rate limiting](https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html) state |
|
||||
| `sessions` | `shared_state` | [Sessions](https://docs.gitlab.com/ee/development/session.html#redis)|
|
||||
| Name | Fallback instance | Purpose |
|
||||
|--------------------|-------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| `cache` | | Volatile non-persistent data |
|
||||
| `queues` | | Background job processing queues |
|
||||
| `shared_state` | | Persistent application state |
|
||||
| `trace_chunks` | `shared_state` | [CI trace chunks](https://docs.gitlab.com/ee/administration/job_logs.html#incremental-logging-architecture) |
|
||||
| `rate_limiting` | `cache` | [Rate limiting](https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html) state |
|
||||
| `sessions` | `shared_state` | [Sessions](https://docs.gitlab.com/ee/development/session.html#redis) |
|
||||
|
||||
If no configuration is found, or no URL is found in the configuration
|
||||
file, the default URL used is:
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ module Gitlab
|
|||
require_dependency Rails.root.join('lib/gitlab/redis/trace_chunks')
|
||||
require_dependency Rails.root.join('lib/gitlab/redis/rate_limiting')
|
||||
require_dependency Rails.root.join('lib/gitlab/redis/sessions')
|
||||
require_dependency Rails.root.join('lib/gitlab/redis/repository_cache')
|
||||
require_dependency Rails.root.join('lib/gitlab/current_settings')
|
||||
require_dependency Rails.root.join('lib/gitlab/middleware/read_only')
|
||||
require_dependency Rails.root.join('lib/gitlab/middleware/compressed_json')
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: paginated_mr_discussions
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88905
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364497
|
||||
milestone: '15.1'
|
||||
type: development
|
||||
group: group::code review
|
||||
default_enabled: true
|
||||
|
|
@ -3488,7 +3488,7 @@ any subkeys. All additional details and related topics are the same.
|
|||
|
||||
**Possible inputs**:
|
||||
|
||||
- An array of file paths. In GitLab 13.6 and later, [file paths can include variables](../jobs/job_control.md#variables-in-ruleschanges).
|
||||
- An array of file paths. [File paths can include variables](../jobs/job_control.md#variables-in-ruleschanges).
|
||||
|
||||
**Example of `rules:changes:paths`**:
|
||||
|
||||
|
|
|
|||
|
|
@ -456,7 +456,13 @@ Log in to your **primary** node, executing the following:
|
|||
1. To get the database migrations and latest code in place, run:
|
||||
|
||||
```shell
|
||||
sudo SKIP_POST_DEPLOYMENT_MIGRATIONS=true gitlab-ctl reconfigure
|
||||
sudo gitlab-ctl reconfigure
|
||||
```
|
||||
|
||||
1. After the node is updated and reconfigure finished successfully, complete the migrations:
|
||||
|
||||
```shell
|
||||
sudo SKIP_POST_DEPLOYMENT_MIGRATIONS=true gitlab-rake db:migrate
|
||||
```
|
||||
|
||||
### Update the Geo secondary site
|
||||
|
|
@ -486,7 +492,13 @@ On each **secondary** node, executing the following:
|
|||
1. To get the database migrations and latest code in place, run:
|
||||
|
||||
```shell
|
||||
sudo SKIP_POST_DEPLOYMENT_MIGRATIONS=true gitlab-ctl reconfigure
|
||||
sudo gitlab-ctl reconfigure
|
||||
```
|
||||
|
||||
1. After the node is updated and reconfigure finished successfully, complete the migrations:
|
||||
|
||||
```shell
|
||||
sudo SKIP_POST_DEPLOYMENT_MIGRATIONS=true gitlab-rake db:migrate
|
||||
```
|
||||
|
||||
1. Run post-deployment database migrations, specific to the Geo database:
|
||||
|
|
|
|||
|
|
@ -159,13 +159,6 @@ the default option of one corpus per job.
|
|||
The corpus registry uses the package registry to store the project's corpuses. Corpuses stored in
|
||||
the registry are hidden to ensure data integrity.
|
||||
|
||||
In the GitLab UI, with corpus management you can:
|
||||
|
||||
- View details of the corpus registry.
|
||||
- Download a corpus.
|
||||
- Delete a corpus.
|
||||
- Create a new corpus.
|
||||
|
||||
When you download a corpus, the file is named `artifacts.zip`, regardless of the filename used when
|
||||
the corpus was initially uploaded. This file contains only the corpus, which is different to the
|
||||
artifacts files you can download from the CI/CD pipeline. Also, a project member with a Reporter or above privilege can download the corpus using the direct download link.
|
||||
|
|
|
|||
|
|
@ -363,12 +363,7 @@ Threads on lines that don't change and top-level resolvable threads are not reso
|
|||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340172) in GitLab 15.1 [with a flag](../../administration/feature_flags.md) named `paginated_mr_discussions`. Disabled by default.
|
||||
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/364497) in GitLab 15.2.
|
||||
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/364497) in GitLab 15.3.
|
||||
|
||||
FLAG:
|
||||
On self-managed GitLab, by default this feature is available. To hide the feature
|
||||
per project or for your entire instance, ask an administrator to
|
||||
[disable the feature flag](../../administration/feature_flags.md) named `paginated_mr_discussions`.
|
||||
On GitLab.com, this feature is available.
|
||||
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/370075) in GitLab 15.8. Feature flag `paginated_mr_discussions` removed.
|
||||
|
||||
A merge request can have many discussions. Loading them all in a single request
|
||||
can be slow. To improve the performance of loading discussions, they are split into multiple
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ module BulkImports
|
|||
API_VERSION = 'v4'
|
||||
DEFAULT_PAGE = 1
|
||||
DEFAULT_PER_PAGE = 30
|
||||
PAT_ENDPOINT_MIN_VERSION = '15.5.0'
|
||||
|
||||
def initialize(url:, token:, page: DEFAULT_PAGE, per_page: DEFAULT_PER_PAGE, api_version: API_VERSION)
|
||||
@url = url
|
||||
|
|
@ -66,38 +67,57 @@ module BulkImports
|
|||
instance_version >= BulkImport.min_gl_version_for_project_migration
|
||||
end
|
||||
|
||||
private
|
||||
def options
|
||||
{ headers: { 'Content-Type' => 'application/json' }, query: { private_token: @token } }
|
||||
end
|
||||
|
||||
def validate_import_scopes!
|
||||
return true unless instance_version >= ::Gitlab::VersionInfo.parse(PAT_ENDPOINT_MIN_VERSION)
|
||||
|
||||
response = with_error_handling do
|
||||
Gitlab::HTTP.get(resource_url("personal_access_tokens/self"), options)
|
||||
end
|
||||
|
||||
return true if response['scopes']&.include?('api')
|
||||
|
||||
raise ::BulkImports::Error.scope_validation_failure
|
||||
end
|
||||
|
||||
def validate_instance_version!
|
||||
return if @compatible_instance_version
|
||||
return true unless instance_version.major < BulkImport::MIN_MAJOR_VERSION
|
||||
|
||||
if instance_version.major < BulkImport::MIN_MAJOR_VERSION
|
||||
raise ::BulkImports::Error.unsupported_gitlab_version
|
||||
else
|
||||
@compatible_instance_version = true
|
||||
end
|
||||
raise ::BulkImports::Error.unsupported_gitlab_version
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metadata
|
||||
response = begin
|
||||
with_error_handling do
|
||||
Gitlab::HTTP.get(resource_url(:version), default_options)
|
||||
Gitlab::HTTP.get(resource_url(:version), options)
|
||||
end
|
||||
rescue BulkImports::NetworkError
|
||||
# `version` endpoint is not available, try `metadata` endpoint instead
|
||||
with_error_handling do
|
||||
Gitlab::HTTP.get(resource_url(:metadata), default_options)
|
||||
Gitlab::HTTP.get(resource_url(:metadata), options)
|
||||
end
|
||||
end
|
||||
|
||||
response.parsed_response
|
||||
rescue BulkImports::NetworkError => e
|
||||
case e&.response&.code
|
||||
when 401, 403
|
||||
raise ::BulkImports::Error.scope_validation_failure
|
||||
when 404
|
||||
raise ::BulkImports::Error.invalid_url
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
strong_memoize_attr :metadata
|
||||
|
||||
# rubocop:disable GitlabSecurity/PublicSend
|
||||
def request(method, resource, options = {}, &block)
|
||||
validate_instance_version!
|
||||
|
||||
with_error_handling do
|
||||
Gitlab::HTTP.public_send(
|
||||
method,
|
||||
|
|
@ -134,9 +154,10 @@ module BulkImports
|
|||
def with_error_handling
|
||||
response = yield
|
||||
|
||||
raise ::BulkImports::NetworkError.new("Unsuccessful response #{response.code} from #{response.request.path.path}. Body: #{response.parsed_response}", response: response) unless response.success?
|
||||
return response if response.success?
|
||||
|
||||
raise ::BulkImports::NetworkError.new("Unsuccessful response #{response.code} from #{response.request.path.path}. Body: #{response.parsed_response}", response: response)
|
||||
|
||||
response
|
||||
rescue *Gitlab::HTTP::HTTP_ERRORS => e
|
||||
raise ::BulkImports::NetworkError, e
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,5 +5,13 @@ module BulkImports
|
|||
def self.unsupported_gitlab_version
|
||||
self.new("Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MIN_MAJOR_VERSION}.")
|
||||
end
|
||||
|
||||
def self.scope_validation_failure
|
||||
self.new("Import aborted as the provided personal access token does not have the required 'api' scope.")
|
||||
end
|
||||
|
||||
def self.invalid_url
|
||||
self.new("Import aborted as it was not possible to connect to the provided GitLab instance URL.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ module Gitlab
|
|||
secret_detection: VERSIONS_TO_REMOVE_IN_16_0
|
||||
}.freeze
|
||||
|
||||
CURRENT_VERSIONS = SUPPORTED_VERSIONS.to_h { |k, v| [k, v - DEPRECATED_VERSIONS[k]] }
|
||||
|
||||
class Schema
|
||||
def root_path
|
||||
File.join(__dir__, 'schemas')
|
||||
|
|
@ -187,11 +189,15 @@ module Gitlab
|
|||
def add_deprecated_report_version_message
|
||||
log_warnings(problem_type: 'using_deprecated_schema_version')
|
||||
|
||||
template = _("Version %{report_version} for report type %{report_type} has been deprecated,"\
|
||||
" supported versions for this report type are: %{supported_schema_versions}."\
|
||||
" GitLab will attempt to parse and ingest this report if valid.")
|
||||
template = _("version %{report_version} for report type %{report_type} is deprecated. "\
|
||||
"However, GitLab will still attempt to parse and ingest this report. "\
|
||||
"Upgrade the security report to one of the following versions: %{current_schema_versions}.")
|
||||
|
||||
message = format(template, report_version: report_version, report_type: report_type, supported_schema_versions: supported_schema_versions)
|
||||
message = format(
|
||||
template,
|
||||
report_version: report_version,
|
||||
report_type: report_type,
|
||||
current_schema_versions: current_schema_versions)
|
||||
|
||||
add_message_as(level: :deprecation_warning, message: message)
|
||||
end
|
||||
|
|
@ -212,6 +218,10 @@ module Gitlab
|
|||
)
|
||||
end
|
||||
|
||||
def current_schema_versions
|
||||
CURRENT_VERSIONS[report_type].join(", ")
|
||||
end
|
||||
|
||||
def supported_schema_versions
|
||||
SUPPORTED_VERSIONS[report_type].join(", ")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -264,18 +264,6 @@ module Gitlab
|
|||
|
||||
private
|
||||
|
||||
def collaborations_subquery
|
||||
each_object(:repos, nil, { affiliation: 'collaborator' })
|
||||
.map { |repo| "repo:#{repo[:full_name]}" }
|
||||
.join(' ')
|
||||
end
|
||||
|
||||
def organizations_subquery
|
||||
each_object(:organizations)
|
||||
.map { |org| "org:#{org[:login]}" }
|
||||
.join(' ')
|
||||
end
|
||||
|
||||
def with_retry
|
||||
Retriable.retriable(on: CLIENT_CONNECTION_ERROR, on_retry: on_retry) do
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -10,24 +10,24 @@ module Gitlab
|
|||
@client = pick_client(access_token, client_options)
|
||||
end
|
||||
|
||||
def repos(search_text, pagination_options)
|
||||
def repos(search_text, options)
|
||||
return { repos: filtered(client.repos, search_text) } if use_legacy?
|
||||
|
||||
if use_graphql?
|
||||
fetch_repos_via_graphql(search_text, pagination_options)
|
||||
fetch_repos_via_graphql(search_text, options)
|
||||
else
|
||||
fetch_repos_via_rest(search_text, pagination_options)
|
||||
fetch_repos_via_rest(search_text, options)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_repos_via_rest(search_text, pagination_options)
|
||||
{ repos: client.search_repos_by_name(search_text, pagination_options)[:items] }
|
||||
def fetch_repos_via_rest(search_text, options)
|
||||
{ repos: client.search_repos_by_name(search_text, options)[:items] }
|
||||
end
|
||||
|
||||
def fetch_repos_via_graphql(search_text, pagination_options)
|
||||
response = client.search_repos_by_name_graphql(search_text, pagination_options)
|
||||
def fetch_repos_via_graphql(search_text, options)
|
||||
response = client.search_repos_by_name_graphql(search_text, options)
|
||||
{
|
||||
repos: response.dig(:data, :search, :nodes),
|
||||
page_info: response.dig(:data, :search, :pageInfo)
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@ module Gitlab
|
|||
end
|
||||
|
||||
def search_repos_by_name(name, options = {})
|
||||
search_query = search_repos_query(name, options)
|
||||
|
||||
with_retry do
|
||||
octokit.search_repositories(
|
||||
search_repos_query(str: name, type: :name),
|
||||
options
|
||||
).to_h
|
||||
octokit.search_repositories(search_query, options).to_h
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def graphql_search_repos_body(name, options)
|
||||
query = search_repos_query(str: name, type: :name)
|
||||
query = search_repos_query(name, options)
|
||||
query = "query: \"#{query}\""
|
||||
first = options[:first].present? ? ", first: #{options[:first]}" : ''
|
||||
after = options[:after].present? ? ", after: \"#{options[:after]}\"" : ''
|
||||
|
|
@ -52,13 +51,49 @@ module Gitlab
|
|||
TEXT
|
||||
end
|
||||
|
||||
def search_repos_query(str:, type:, include_collaborations: true, include_orgs: true)
|
||||
query = "#{str} in:#{type} is:public,private user:#{octokit.user.to_h[:login]}"
|
||||
def search_repos_query(string, options = {})
|
||||
base = "#{string} in:name is:public,private"
|
||||
|
||||
query = [query, collaborations_subquery].join(' ') if include_collaborations
|
||||
query = [query, organizations_subquery].join(' ') if include_orgs
|
||||
case options[:relation_type]
|
||||
when 'organization' then organization_repos_query(base, options)
|
||||
when 'collaborated' then collaborated_repos_query(base)
|
||||
when 'owned' then owned_repos_query(base)
|
||||
# TODO: remove after https://gitlab.com/gitlab-org/gitlab/-/issues/385113 get done
|
||||
else legacy_all_repos_query(base)
|
||||
end
|
||||
end
|
||||
|
||||
query
|
||||
def organization_repos_query(search_string, options)
|
||||
"#{search_string} org:#{options[:organization_login]}"
|
||||
end
|
||||
|
||||
def collaborated_repos_query(search_string)
|
||||
"#{search_string} #{collaborations_subquery}"
|
||||
end
|
||||
|
||||
def owned_repos_query(search_string)
|
||||
"#{search_string} user:#{octokit.user.to_h[:login]}"
|
||||
end
|
||||
|
||||
def legacy_all_repos_query(search_string)
|
||||
[
|
||||
search_string,
|
||||
"user:#{octokit.user.to_h[:login]}",
|
||||
collaborations_subquery,
|
||||
organizations_subquery
|
||||
].join(' ')
|
||||
end
|
||||
|
||||
def collaborations_subquery
|
||||
each_object(:repos, nil, { affiliation: 'collaborator' })
|
||||
.map { |repo| "repo:#{repo[:full_name]}" }
|
||||
.join(' ')
|
||||
end
|
||||
|
||||
def organizations_subquery
|
||||
each_object(:organizations)
|
||||
.map { |org| "org:#{org[:login]}" }
|
||||
.join(' ')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ module Gitlab
|
|||
Gitlab::Redis::Cache,
|
||||
Gitlab::Redis::Queues,
|
||||
Gitlab::Redis::RateLimiting,
|
||||
Gitlab::Redis::RepositoryCache,
|
||||
Gitlab::Redis::Sessions,
|
||||
Gitlab::Redis::SharedState,
|
||||
Gitlab::Redis::TraceChunks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Redis
|
||||
class RepositoryCache < ::Gitlab::Redis::Wrapper
|
||||
# The data we store on RepositoryCache used to be stored on Cache.
|
||||
def self.config_fallback
|
||||
Cache
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -26325,6 +26325,9 @@ msgstr ""
|
|||
msgid "Messages"
|
||||
msgstr ""
|
||||
|
||||
msgid "Metadata"
|
||||
msgstr ""
|
||||
|
||||
msgid "Method"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -45892,9 +45895,6 @@ msgstr ""
|
|||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Version %{report_version} for report type %{report_type} has been deprecated, supported versions for this report type are: %{supported_schema_versions}. GitLab will attempt to parse and ingest this report if valid."
|
||||
msgstr ""
|
||||
|
||||
msgid "Version %{report_version} for report type %{report_type} is unsupported, supported versions for this report type are: %{supported_schema_versions}. GitLab will attempt to validate this report against the earliest supported versions of this report type, to show all the errors but will not ingest the report"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -50785,6 +50785,9 @@ msgstr ""
|
|||
msgid "verify ownership"
|
||||
msgstr ""
|
||||
|
||||
msgid "version %{report_version} for report type %{report_type} is deprecated. However, GitLab will still attempt to parse and ingest this report. Upgrade the security report to one of the following versions: %{current_schema_versions}."
|
||||
msgstr ""
|
||||
|
||||
msgid "version %{versionIndex}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,18 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
|
|||
)
|
||||
end
|
||||
|
||||
let(:source_version) do
|
||||
Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
|
||||
::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
|
||||
end
|
||||
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
|
||||
allow(instance).to receive(:instance_version).and_return(source_version)
|
||||
allow(instance).to receive(:instance_enterprise).and_return(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns serialized group data' do
|
||||
get_status
|
||||
|
||||
|
|
@ -203,8 +215,15 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
|
|||
end
|
||||
|
||||
context 'when connection error occurs' do
|
||||
let(:source_version) do
|
||||
Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
|
||||
::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
|
||||
end
|
||||
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
|
||||
allow(instance).to receive(:instance_version).and_return(source_version)
|
||||
allow(instance).to receive(:instance_enterprise).and_return(false)
|
||||
allow(instance).to receive(:get).and_raise(BulkImports::Error)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Import::GithubController do
|
||||
RSpec.describe Import::GithubController, feature_category: :import do
|
||||
include ImportSpecHelper
|
||||
|
||||
let(:provider) { :github }
|
||||
|
|
@ -138,7 +138,7 @@ RSpec.describe Import::GithubController do
|
|||
it 'calls repos list from provider with expected args' do
|
||||
expect_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |client|
|
||||
expect(client).to receive(:repos)
|
||||
.with(expected_filter, expected_pagination_options)
|
||||
.with(expected_filter, expected_options)
|
||||
.and_return({ repos: [], page_info: {} })
|
||||
end
|
||||
|
||||
|
|
@ -155,11 +155,16 @@ RSpec.describe Import::GithubController do
|
|||
let(:provider_token) { 'asdasd12345' }
|
||||
let(:client_auth_success) { true }
|
||||
let(:client_stub) { instance_double(Gitlab::GithubImport::Client, user: { login: 'user' }) }
|
||||
let(:expected_pagination_options) { pagination_params.merge(first: 25, page: 1, per_page: 25) }
|
||||
let(:expected_filter) { nil }
|
||||
let(:params) { nil }
|
||||
let(:pagination_params) { { before: nil, after: nil } }
|
||||
let(:relation_params) { { relation_type: nil, organization_login: '' } }
|
||||
let(:provider_repos) { [] }
|
||||
let(:expected_filter) { '' }
|
||||
let(:expected_options) do
|
||||
pagination_params.merge(relation_params).merge(
|
||||
first: 25, page: 1, per_page: 25
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |proxy|
|
||||
|
|
@ -277,8 +282,34 @@ RSpec.describe Import::GithubController do
|
|||
|
||||
context 'when page is specified' do
|
||||
let(:pagination_params) { { before: nil, after: nil, page: 2 } }
|
||||
let(:expected_pagination_options) { pagination_params.merge(first: 25, page: 2, per_page: 25) }
|
||||
let(:params) { pagination_params }
|
||||
let(:expected_options) do
|
||||
pagination_params.merge(relation_params).merge(first: 25, page: 2, per_page: 25)
|
||||
end
|
||||
|
||||
it_behaves_like 'calls repos through Clients::Proxy with expected args'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when relation type params present' do
|
||||
let(:organization_login) { 'test-login' }
|
||||
let(:params) { pagination_params.merge(relation_type: 'organization', organization_login: organization_login) }
|
||||
let(:pagination_defaults) { { first: 25, page: 1, per_page: 25 } }
|
||||
let(:expected_options) do
|
||||
pagination_defaults.merge(pagination_params).merge(
|
||||
relation_type: 'organization', organization_login: organization_login
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'calls repos through Clients::Proxy with expected args'
|
||||
|
||||
context 'when organization_login is too long and with ":"' do
|
||||
let(:organization_login) { ":#{Array.new(270) { ('a'..'z').to_a.sample }.join}" }
|
||||
let(:expected_options) do
|
||||
pagination_defaults.merge(pagination_params).merge(
|
||||
relation_type: 'organization', organization_login: organization_login.slice(1, 254)
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'calls repos through Clients::Proxy with expected args'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,6 +72,19 @@ RSpec.describe BranchesFinder, feature_category: :source_code_management do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by string' do
|
||||
let(:params) { { search: 'add' } }
|
||||
|
||||
it 'returns all branches contain name' do
|
||||
result = subject
|
||||
|
||||
result.each do |branch|
|
||||
expect(branch.name).to include('add')
|
||||
end
|
||||
expect(result.count).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
context 'by provided names' do
|
||||
let(:params) { { names: %w[fix csv lfs does-not-exist] } }
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ describe('diffs/components/app', () => {
|
|||
beforeEach(() => {
|
||||
const fetchResolver = () => {
|
||||
store.state.diffs.retrievingBatches = false;
|
||||
store.state.notes.doneFetchingBatchDiscussions = true;
|
||||
store.state.notes.discussions = 'test';
|
||||
return Promise.resolve({ real_size: 100 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -163,8 +163,8 @@ exports[`MlCandidate renders correctly 1`] = `
|
|||
class="gl-text-secondary gl-font-weight-bold"
|
||||
>
|
||||
|
||||
Parameters
|
||||
|
||||
Parameters
|
||||
|
||||
</td>
|
||||
|
||||
<td
|
||||
|
|
@ -190,7 +190,6 @@ exports[`MlCandidate renders correctly 1`] = `
|
|||
3
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
class="divider"
|
||||
/>
|
||||
|
|
@ -200,8 +199,8 @@ exports[`MlCandidate renders correctly 1`] = `
|
|||
class="gl-text-secondary gl-font-weight-bold"
|
||||
>
|
||||
|
||||
Metrics
|
||||
|
||||
Metrics
|
||||
|
||||
</td>
|
||||
|
||||
<td
|
||||
|
|
@ -227,6 +226,42 @@ exports[`MlCandidate renders correctly 1`] = `
|
|||
.99
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
class="divider"
|
||||
/>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
class="gl-text-secondary gl-font-weight-bold"
|
||||
>
|
||||
|
||||
Metadata
|
||||
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="gl-font-weight-bold"
|
||||
>
|
||||
FileName
|
||||
</td>
|
||||
|
||||
<td>
|
||||
test.py
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td />
|
||||
|
||||
<td
|
||||
class="gl-font-weight-bold"
|
||||
>
|
||||
ExecutionTime
|
||||
</td>
|
||||
|
||||
<td>
|
||||
.0856
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ describe('MlCandidate', () => {
|
|||
{ name: 'AUC', value: '.55' },
|
||||
{ name: 'Accuracy', value: '.99' },
|
||||
],
|
||||
metadata: [
|
||||
{ name: 'FileName', value: 'test.py' },
|
||||
{ name: 'ExecutionTime', value: '.0856' },
|
||||
],
|
||||
info: {
|
||||
iid: 'candidate_iid',
|
||||
artifact_link: 'path_to_artifact',
|
||||
|
|
|
|||
|
|
@ -1442,7 +1442,7 @@ describe('Actions Notes Store', () => {
|
|||
return testAction(
|
||||
actions.fetchDiscussions,
|
||||
{},
|
||||
{ noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE },
|
||||
{ noteableType: notesConstants.EPIC_NOTEABLE_TYPE },
|
||||
[
|
||||
{ type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } },
|
||||
{ type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false },
|
||||
|
|
@ -1472,9 +1472,7 @@ describe('Actions Notes Store', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('dispatches `fetchDiscussionsBatch` action if `paginatedMrDiscussions` feature flag is enabled', () => {
|
||||
window.gon = { features: { paginatedMrDiscussions: true } };
|
||||
|
||||
it('dispatches `fetchDiscussionsBatch` action if noteable is a MergeRequest', () => {
|
||||
return testAction(
|
||||
actions.fetchDiscussions,
|
||||
{ path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe BulkImports::Clients::HTTP do
|
||||
RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
|
||||
include ImportSpecHelper
|
||||
|
||||
let(:url) { 'http://gitlab.example' }
|
||||
|
|
@ -22,12 +22,6 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Gitlab::HTTP).to receive(:get)
|
||||
.with('http://gitlab.example/api/v4/version', anything)
|
||||
.and_return(metadata_response)
|
||||
end
|
||||
|
||||
subject { described_class.new(url: url, token: token) }
|
||||
|
||||
shared_examples 'performs network request' do
|
||||
|
|
@ -39,7 +33,7 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
|
||||
context 'error handling' do
|
||||
context 'when error occurred' do
|
||||
it 'raises BulkImports::Error' do
|
||||
it 'raises BulkImports::NetworkError' do
|
||||
allow(Gitlab::HTTP).to receive(method).and_raise(Errno::ECONNREFUSED)
|
||||
|
||||
expect { subject.public_send(method, resource) }.to raise_exception(BulkImports::NetworkError)
|
||||
|
|
@ -47,7 +41,7 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
end
|
||||
|
||||
context 'when response is not success' do
|
||||
it 'raises BulkImports::Error' do
|
||||
it 'raises BulkImports::NetworkError' do
|
||||
response_double = double(code: 503, success?: false, parsed_response: 'Error', request: double(path: double(path: '/test')))
|
||||
|
||||
allow(Gitlab::HTTP).to receive(method).and_return(response_double)
|
||||
|
|
@ -210,33 +204,149 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
|
||||
describe '#instance_version' do
|
||||
it 'returns version as an instance of Gitlab::VersionInfo' do
|
||||
response = { version: version }
|
||||
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token')
|
||||
.to_return(status: 200, body: response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(subject.instance_version).to eq(Gitlab::VersionInfo.parse(version))
|
||||
end
|
||||
|
||||
context 'when /version endpoint is not available' do
|
||||
it 'requests /metadata endpoint' do
|
||||
response_double = double(code: 404, success?: false, parsed_response: 'Not Found', request: double(path: double(path: '/version')))
|
||||
response = { version: version }
|
||||
|
||||
allow(Gitlab::HTTP).to receive(:get)
|
||||
.with('http://gitlab.example/api/v4/version', anything)
|
||||
.and_return(response_double)
|
||||
|
||||
expect(Gitlab::HTTP).to receive(:get)
|
||||
.with('http://gitlab.example/api/v4/metadata', anything)
|
||||
.and_return(metadata_response)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 200, body: response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(subject.instance_version).to eq(Gitlab::VersionInfo.parse(version))
|
||||
end
|
||||
|
||||
context 'when /metadata endpoint returns a 401' do
|
||||
it 'raises a BulkImports:Error' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 401, body: "", headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect { subject.instance_version }.to raise_exception(BulkImports::Error, "Import aborted as the provided personal access token does not have the required 'api' scope.")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when /metadata endpoint returns a 403' do
|
||||
it 'raises a BulkImports:Error' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 403, body: "", headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect { subject.instance_version }.to raise_exception(BulkImports::Error, "Import aborted as the provided personal access token does not have the required 'api' scope.")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when /metadata endpoint returns a 404' do
|
||||
it 'raises a BulkImports:Error' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 404, body: "", headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect { subject.instance_version }.to raise_exception(BulkImports::Error, 'Import aborted as it was not possible to connect to the provided GitLab instance URL.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when /metadata endpoint returns any other BulkImports::NetworkError' do
|
||||
it 'raises a BulkImports:NetworkError' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 418, body: "", headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect { subject.instance_version }.to raise_exception(BulkImports::NetworkError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate_instance_version!' do
|
||||
before do
|
||||
allow(subject).to receive(:instance_version).and_return(source_version)
|
||||
end
|
||||
|
||||
context 'when instance version is greater than or equal to the minimum major version' do
|
||||
let(:source_version) { Gitlab::VersionInfo.new(14) }
|
||||
|
||||
it { expect(subject.validate_instance_version!).to eq(true) }
|
||||
end
|
||||
|
||||
context 'when instance version is less than the minimum major version' do
|
||||
let(:source_version) { Gitlab::VersionInfo.new(13, 10, 0) }
|
||||
|
||||
it { expect { subject.validate_instance_version! }.to raise_exception(BulkImports::Error) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate_import_scopes!' do
|
||||
context 'when the source_version is < 15.5' do
|
||||
let(:source_version) { Gitlab::VersionInfo.new(15, 0) }
|
||||
|
||||
it 'skips validation' do
|
||||
allow(subject).to receive(:instance_version).and_return(source_version)
|
||||
|
||||
expect(subject.validate_import_scopes!).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when source version is 15.5 or higher' do
|
||||
let(:source_version) { Gitlab::VersionInfo.new(15, 6) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:instance_version).and_return(source_version)
|
||||
end
|
||||
|
||||
context 'when an HTTP error is raised' do
|
||||
let(:response) { { enterprise: false } }
|
||||
|
||||
it 'raises BulkImports::NetworkError' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
|
||||
.to_return(status: 404)
|
||||
|
||||
expect { subject.validate_import_scopes! }.to raise_exception(BulkImports::NetworkError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when scopes are valid' do
|
||||
it 'returns true' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
|
||||
.to_return(status: 200, body: { 'scopes' => ['api'] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(subject.validate_import_scopes!).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when scopes are invalid' do
|
||||
it 'raises a BulkImports error' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
|
||||
.to_return(status: 200, body: { 'scopes' => ['read_user'] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(subject.instance_version).to eq(Gitlab::VersionInfo.parse(source_version))
|
||||
expect { subject.validate_import_scopes! }.to raise_exception(BulkImports::Error)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instance_enterprise' do
|
||||
let(:response) { { enterprise: false } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token')
|
||||
.to_return(status: 200, body: response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'returns source instance enterprise information' do
|
||||
expect(subject.instance_enterprise).to eq(false)
|
||||
end
|
||||
|
||||
context 'when enterprise information is missing' do
|
||||
let(:enterprise) { nil }
|
||||
let(:response) { {} }
|
||||
|
||||
it 'defaults to true' do
|
||||
expect(subject.instance_enterprise).to eq(true)
|
||||
|
|
@ -245,14 +355,20 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
end
|
||||
|
||||
describe '#compatible_for_project_migration?' do
|
||||
before do
|
||||
allow(subject).to receive(:instance_version).and_return(Gitlab::VersionInfo.parse(version))
|
||||
end
|
||||
|
||||
context 'when instance version is lower the the expected minimum' do
|
||||
let(:version) { '14.3.0' }
|
||||
|
||||
it 'returns false' do
|
||||
expect(subject.compatible_for_project_migration?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instance version is at least the expected minimum' do
|
||||
let(:version) { "14.4.4" }
|
||||
let(:version) { '14.4.4' }
|
||||
|
||||
it 'returns true' do
|
||||
expect(subject.compatible_for_project_migration?).to be true
|
||||
|
|
@ -260,18 +376,6 @@ RSpec.describe BulkImports::Clients::HTTP do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when source instance is incompatible' do
|
||||
let(:version) { '13.0.0' }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject.get(resource) }
|
||||
.to raise_error(
|
||||
::BulkImports::Error,
|
||||
"Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MIN_MAJOR_VERSION}."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when url is relative' do
|
||||
let(:url) { 'http://website.example/gitlab' }
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ require 'spec_helper'
|
|||
RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, feature_category: :vulnerability_management do
|
||||
let_it_be(:project) { create(:project) }
|
||||
|
||||
let(:current_dast_versions) { described_class::CURRENT_VERSIONS[:dast].join(', ') }
|
||||
let(:supported_dast_versions) { described_class::SUPPORTED_VERSIONS[:dast].join(', ') }
|
||||
let(:deprecated_schema_version_message) {}
|
||||
let(:missing_schema_version_message) do
|
||||
|
|
@ -466,8 +467,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
|
|||
|
||||
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
|
||||
let(:expected_deprecation_message) do
|
||||
"Version #{report_version} for report type #{report_type} has been deprecated, supported versions for this "\
|
||||
"report type are: #{supported_dast_versions}. GitLab will attempt to parse and ingest this report if valid."
|
||||
"version #{report_version} for report type #{report_type} is deprecated. "\
|
||||
"However, GitLab will still attempt to parse and ingest this report. "\
|
||||
"Upgrade the security report to one of the following versions: #{current_dast_versions}."
|
||||
end
|
||||
|
||||
let(:expected_deprecation_warnings) do
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::GithubImport::Client do
|
||||
RSpec.describe Gitlab::GithubImport::Client, feature_category: :importer do
|
||||
subject(:client) { described_class.new('foo', parallel: parallel) }
|
||||
|
||||
let(:parallel) { true }
|
||||
|
|
@ -614,6 +614,46 @@ RSpec.describe Gitlab::GithubImport::Client do
|
|||
client.search_repos_by_name_graphql('test')
|
||||
end
|
||||
|
||||
context 'when relation type option present' do
|
||||
context 'when relation type is owned' do
|
||||
let(:expected_query) { 'test in:name is:public,private user:user' }
|
||||
|
||||
it 'searches for repositories within the organization based on name' do
|
||||
expect(client.octokit).to receive(:post).with(
|
||||
'/graphql', { query: expected_graphql }.to_json
|
||||
)
|
||||
|
||||
client.search_repos_by_name_graphql('test', relation_type: 'owned')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when relation type is organization' do
|
||||
let(:expected_query) { 'test in:name is:public,private org:test-login' }
|
||||
|
||||
it 'searches for repositories within the organization based on name' do
|
||||
expect(client.octokit).to receive(:post).with(
|
||||
'/graphql', { query: expected_graphql }.to_json
|
||||
)
|
||||
|
||||
client.search_repos_by_name_graphql(
|
||||
'test', relation_type: 'organization', organization_login: 'test-login'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when relation type is collaborated' do
|
||||
let(:expected_query) { 'test in:name is:public,private repo:repo1 repo:repo2' }
|
||||
|
||||
it 'searches for collaborated repositories based on name' do
|
||||
expect(client.octokit).to receive(:post).with(
|
||||
'/graphql', { query: expected_graphql }.to_json
|
||||
)
|
||||
|
||||
client.search_repos_by_name_graphql('test', relation_type: 'collaborated')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pagination options present' do
|
||||
context 'with "first" option' do
|
||||
let(:expected_graphql_params) do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Redis::RepositoryCache, feature_category: :scalability do
|
||||
include_examples "redis_new_instance_shared_examples", 'repository_cache', Gitlab::Redis::Cache
|
||||
end
|
||||
|
|
@ -91,22 +91,6 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :source_code
|
|||
expect(discussions.count).to eq(1)
|
||||
expect(notes).to match([a_hash_including('id' => discussion_2.id.to_s)])
|
||||
end
|
||||
|
||||
context 'when paginated_mr_discussions is disabled' do
|
||||
before do
|
||||
stub_feature_flags(paginated_mr_discussions: false)
|
||||
end
|
||||
|
||||
it 'returns all discussions and ignores per_page param' do
|
||||
get_discussions(per_page: 2)
|
||||
|
||||
discussions = Gitlab::Json.parse(response.body)
|
||||
notes = discussions.flat_map { |d| d['notes'] }
|
||||
|
||||
expect(discussions.count).to eq(4)
|
||||
expect(notes.count).to eq(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,19 +13,19 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
|
|||
source_type: 'group_entity',
|
||||
source_full_path: 'full/path/to/group1',
|
||||
destination_slug: 'destination group 1',
|
||||
destination_namespace: 'full/path/to/destination1'
|
||||
destination_namespace: 'parent-group'
|
||||
},
|
||||
{
|
||||
source_type: 'group_entity',
|
||||
source_full_path: 'full/path/to/group2',
|
||||
destination_slug: 'destination group 2',
|
||||
destination_namespace: 'full/path/to/destination2'
|
||||
destination_namespace: 'parent-group'
|
||||
},
|
||||
{
|
||||
source_type: 'project_entity',
|
||||
source_full_path: 'full/path/to/project1',
|
||||
destination_slug: 'destination project 1',
|
||||
destination_namespace: 'full/path/to/destination1'
|
||||
destination_namespace: 'parent-group'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
|
@ -33,129 +33,186 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
|
|||
subject { described_class.new(user, params, credentials) }
|
||||
|
||||
describe '#execute' do
|
||||
let_it_be(:source_version) do
|
||||
Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
|
||||
::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
|
||||
end
|
||||
context 'when gitlab version is 15.5 or higher' do
|
||||
let(:source_version) { { version: "15.6.0", enterprise: false } }
|
||||
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
|
||||
allow(instance).to receive(:instance_version).and_return(source_version)
|
||||
allow(instance).to receive(:instance_enterprise).and_return(false)
|
||||
end
|
||||
end
|
||||
context 'when a BulkImports::Error is raised while validating the instance version' do
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
|
||||
allow(client)
|
||||
.to receive(:validate_instance_version!)
|
||||
.and_raise(BulkImports::Error, "This is a BulkImports error.")
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates bulk import' do
|
||||
parent_group.add_owner(user)
|
||||
expect { subject.execute }.to change { BulkImport.count }.by(1)
|
||||
it 'rescues the error and raises a ServiceResponse::Error' do
|
||||
result = subject.execute
|
||||
|
||||
last_bulk_import = BulkImport.last
|
||||
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_version).to eq(source_version.to_s)
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_enterprise).to eq(false)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'bulk_import_group'
|
||||
)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'import_access_level',
|
||||
user: user,
|
||||
extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates bulk import entities' do
|
||||
expect { subject.execute }.to change { BulkImports::Entity.count }.by(3)
|
||||
end
|
||||
|
||||
it 'creates bulk import configuration' do
|
||||
expect { subject.execute }.to change { BulkImports::Configuration.count }.by(1)
|
||||
end
|
||||
|
||||
it 'enqueues BulkImportWorker' do
|
||||
expect(BulkImportWorker).to receive(:perform_async)
|
||||
|
||||
subject.execute
|
||||
end
|
||||
|
||||
it 'returns success ServiceResponse' do
|
||||
result = subject.execute
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_success
|
||||
end
|
||||
|
||||
it 'returns ServiceResponse with error if validation fails' do
|
||||
params[0][:source_full_path] = nil
|
||||
|
||||
result = subject.execute
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_error
|
||||
expect(result.message).to eq("Validation failed: Source full path can't be blank")
|
||||
end
|
||||
|
||||
context 'when the token is invalid' do
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
|
||||
allow(client).to receive(:instance_version).and_raise(BulkImports::NetworkError, "401 Unauthorized")
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_error
|
||||
expect(result.message).to eq("This is a BulkImports error.")
|
||||
end
|
||||
end
|
||||
|
||||
it 'rescues the error and raises a ServiceResponse::Error' do
|
||||
result = subject.execute
|
||||
context 'when required scopes are not present' do
|
||||
it 'returns ServiceResponse with error if token does not have api scope' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: source_version.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_error
|
||||
expect(result.message).to eq("401 Unauthorized")
|
||||
end
|
||||
end
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
|
||||
allow(client).to receive(:validate_instance_version!).and_raise(BulkImports::Error.scope_validation_failure)
|
||||
end
|
||||
|
||||
describe '#user-role' do
|
||||
context 'when there is a parent_namespace and the user is a member' do
|
||||
let(:group2) { create(:group, path: 'destination200', source_id: parent_group.id ) }
|
||||
let(:params) do
|
||||
[
|
||||
{
|
||||
source_type: 'group_entity',
|
||||
source_full_path: 'full/path/to/group1',
|
||||
destination_slug: 'destination200',
|
||||
destination_namespace: 'parent-group'
|
||||
}
|
||||
]
|
||||
result = subject.execute
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_error
|
||||
expect(result.message)
|
||||
.to eq(
|
||||
"Import aborted as the provided personal access token does not have the required 'api' scope."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'defines access_level from parent namespace membership' do
|
||||
parent_group.add_guest(user)
|
||||
subject.execute
|
||||
context 'when token validation succeeds' do
|
||||
it 'creates bulk import' do
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
|
||||
.to_return(status: 200, body: source_version.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { 'scopes' => ['api'] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
parent_group.add_owner(user)
|
||||
expect { subject.execute }.to change { BulkImport.count }.by(1)
|
||||
|
||||
last_bulk_import = BulkImport.last
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_version).to eq(source_version[:version])
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_enterprise).to eq(false)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'bulk_import_group'
|
||||
)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'import_access_level',
|
||||
user: user,
|
||||
extra: { user_role: 'Guest', import_type: 'bulk_import_group' }
|
||||
extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is a parent_namespace and the user is not a member' do
|
||||
let(:params) do
|
||||
[
|
||||
{
|
||||
source_type: 'group_entity',
|
||||
source_full_path: 'full/path/to/group1',
|
||||
destination_slug: 'destination-group-1',
|
||||
destination_namespace: 'parent-group'
|
||||
}
|
||||
]
|
||||
context 'when gitlab version is lower than 15.5' do
|
||||
let(:source_version) do
|
||||
Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
|
||||
::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
|
||||
end
|
||||
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
|
||||
allow(instance).to receive(:instance_version).and_return(source_version)
|
||||
allow(instance).to receive(:instance_enterprise).and_return(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates bulk import' do
|
||||
parent_group.add_owner(user)
|
||||
expect { subject.execute }.to change { BulkImport.count }.by(1)
|
||||
|
||||
last_bulk_import = BulkImport.last
|
||||
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_version).to eq(source_version.to_s)
|
||||
expect(last_bulk_import.user).to eq(user)
|
||||
expect(last_bulk_import.source_enterprise).to eq(false)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'bulk_import_group'
|
||||
)
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'import_access_level',
|
||||
user: user,
|
||||
extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates bulk import entities' do
|
||||
expect { subject.execute }.to change { BulkImports::Entity.count }.by(3)
|
||||
end
|
||||
|
||||
it 'creates bulk import configuration' do
|
||||
expect { subject.execute }.to change { BulkImports::Configuration.count }.by(1)
|
||||
end
|
||||
|
||||
it 'enqueues BulkImportWorker' do
|
||||
expect(BulkImportWorker).to receive(:perform_async)
|
||||
|
||||
subject.execute
|
||||
end
|
||||
|
||||
it 'returns success ServiceResponse' do
|
||||
result = subject.execute
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_success
|
||||
end
|
||||
|
||||
it 'returns ServiceResponse with error if validation fails' do
|
||||
params[0][:source_full_path] = nil
|
||||
|
||||
result = subject.execute
|
||||
|
||||
expect(result).to be_a(ServiceResponse)
|
||||
expect(result).to be_error
|
||||
expect(result.message).to eq("Validation failed: Source full path can't be blank")
|
||||
end
|
||||
|
||||
describe '#user-role' do
|
||||
context 'when there is a parent_namespace and the user is a member' do
|
||||
let(:group2) { create(:group, path: 'destination200', source_id: parent_group.id ) }
|
||||
let(:params) do
|
||||
[
|
||||
{
|
||||
source_type: 'group_entity',
|
||||
source_full_path: 'full/path/to/group1',
|
||||
destination_slug: 'destination200',
|
||||
destination_namespace: 'parent-group'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'defines access_level from parent namespace membership' do
|
||||
parent_group.add_guest(user)
|
||||
subject.execute
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'BulkImports::CreateService',
|
||||
action: 'create',
|
||||
label: 'import_access_level',
|
||||
user: user,
|
||||
extra: { user_role: 'Guest', import_type: 'bulk_import_group' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'defines access_level as not a member' do
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe BulkImports::GetImportableDataService do
|
||||
RSpec.describe BulkImports::GetImportableDataService, feature_category: :importers do
|
||||
describe '#execute' do
|
||||
include_context 'bulk imports requests context', 'https://gitlab.example.com'
|
||||
|
||||
|
|
@ -34,6 +34,18 @@ RSpec.describe BulkImports::GetImportableDataService do
|
|||
]
|
||||
end
|
||||
|
||||
let(:source_version) do
|
||||
Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
|
||||
::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
|
||||
end
|
||||
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
|
||||
allow(instance).to receive(:instance_version).and_return(source_version)
|
||||
allow(instance).to receive(:instance_enterprise).and_return(false)
|
||||
end
|
||||
end
|
||||
|
||||
subject do
|
||||
described_class.new(params, query_params, credentials).execute
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ RSpec.shared_context 'bulk imports requests context' do |url|
|
|||
let(:request_headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
before do
|
||||
stub_request(:get, "#{url}/api/v4/version?page=1&per_page=20&private_token=demo-pat")
|
||||
stub_request(:get, "#{url}/api/v4/version?private_token=demo-pat")
|
||||
.with(headers: request_headers)
|
||||
.to_return(
|
||||
status: 200,
|
||||
|
|
|
|||
Loading…
Reference in New Issue