Commit f8e050cf authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'ensure-pages-metadatum' into 'master'

Ensure that pages metadata exists when updating it

See merge request gitlab-org/gitlab!46669
parents 75f58060 a7537882
......@@ -616,12 +616,12 @@ const Api = {
return axios.get(url);
},
pipelineJobs(projectId, pipelineId) {
pipelineJobs(projectId, pipelineId, params) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectId))
.replace(':pipeline_id', encodeURIComponent(pipelineId));
return axios.get(url);
return axios.get(url, { params });
},
// Return all pipelines for a project or filter by query params
......
......@@ -230,7 +230,13 @@ export default {
:href="titleLink"
@click="handleFileNameClick"
>
<file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
<file-icon
:file-name="filePath"
:size="18"
aria-hidden="true"
css-classes="gl-mr-2"
:submodule="diffFile.submodule"
/>
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
......
......@@ -664,6 +664,7 @@ export const generateTreeList = files => {
addedLines: file.added_lines,
removedLines: file.removed_lines,
parentPath: parent ? `${parent.path}/` : '/',
submodule: file.submodule,
});
} else {
Object.assign(entry, {
......
......@@ -4,7 +4,7 @@
* Used in environments table.
*/
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
......@@ -14,6 +14,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
},
props: {
environment: {
......@@ -54,14 +55,13 @@ export default {
<template>
<gl-button
v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }"
v-gl-modal-directive="'stop-environment-modal'"
:loading="isLoading"
:title="title"
:aria-label="title"
icon="stop"
category="primary"
variant="danger"
data-toggle="modal"
data-target="#stop-environment-modal"
@click="onClick"
/>
</template>
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui';
import eventHub from '../event_hub';
import { __, s__ } from '~/locale';
export default {
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
components: {
GlModal: DeprecatedModal2,
GlModal,
GlSprintf,
},
......@@ -24,6 +23,20 @@ export default {
},
},
computed: {
primaryProps() {
return {
text: s__('Environments|Stop environment'),
attributes: [{ variant: 'danger' }],
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
},
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
......@@ -34,18 +47,23 @@ export default {
<template>
<gl-modal
:id="$options.id"
:footer-primary-button-text="s__('Environments|Stop environment')"
footer-primary-button-variant="danger"
@submit="onSubmit"
:modal-id="$options.id"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="onSubmit"
>
<template #header>
<h4 class="modal-title d-flex mw-100">
Stopping
<span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">
{{ environment.name }}?
</span>
</h4>
<template #modal-title>
<gl-sprintf :message="s__('Environments|Stopping %{environmentName}')">
<template #environmentName>
<span
v-gl-tooltip
:title="environment.name"
class="gl-text-truncate gl-ml-2 gl-mr-2 gl-flex-fill"
>
{{ environment.name }}?
</span>
</template>
</gl-sprintf>
</template>
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
......
......@@ -143,7 +143,7 @@ export default {
<div class="media-body">
<div class="gl-ml-3 float-left">
<span class="gl-font-weight-bold">
{{ __('This merge request is still a work in progress.') }}
{{ __('This merge request is still a draft.') }}
</span>
<span class="gl-display-block text-muted">{{
__("Draft merge requests can't be merged.")
......
......@@ -153,6 +153,7 @@ export default {
:folder="isTree"
:opened="file.opened"
:size="16"
:submodule="file.submodule"
/>
<gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
<template v-else>{{ file.name }}</template>
......
......@@ -3,6 +3,7 @@ import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import ReportSection from '~/reports/components/report_section.vue';
import { status } from '~/reports/constants';
import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import Flash from '~/flash';
import Api from '~/api';
......@@ -52,12 +53,27 @@ export default {
});
},
methods: {
checkHasSecurityReports(reportTypes) {
return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
jobs.some(({ artifacts = [] }) =>
async checkHasSecurityReports(reportTypes) {
let page = 1;
while (page) {
// eslint-disable-next-line no-await-in-loop
const { data: jobs, headers } = await Api.pipelineJobs(this.projectId, this.pipelineId, {
per_page: 100,
page,
});
const hasSecurityReports = jobs.some(({ artifacts = [] }) =>
artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
),
);
);
if (hasSecurityReports) {
return true;
}
page = parseIntPagination(normalizeHeaders(headers)).nextPage;
}
return false;
},
activatePipelinesTab() {
if (window.mrTabs) {
......
---
title: Add a /draft alias to the /wip quick action
merge_request: 46277
author:
type: added
---
title: Display submodules in MR tree and file header
merge_request: 46840
author:
type: fixed
---
title: Add usage ping for web users of geo secondaries
merge_request: 46278
author:
type: added
---
title: Ensure security report is displayed correctly in merge requests with a lot of CI jobs
merge_request: 46870
author:
type: fixed
---
name: gitlab_org_sitemap
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46661
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276915
milestone: '13.6'
type: development
group: group::editor
default_enabled: false
......@@ -275,6 +275,10 @@ Rails.application.routes.draw do
draw :profile
end
Gitlab.ee do
get '/sitemap' => 'sitemap#show', format: :xml
end
root to: "root#index"
get '*unmatched_route', to: 'application#route_not_found'
......
# frozen_string_literal: true
class AddIndexToOauthAccessGrantsResourceOwnerId < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_oauth_access_grants_on_resource_owner_id'
disable_ddl_transaction!
def up
add_concurrent_index :oauth_access_grants, %i[resource_owner_id application_id created_at], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :oauth_access_grants, INDEX_NAME
end
end
c269a999cabce99d26f3be303656bbb27f2b843b639755b112ad350d4cb5b5c6
\ No newline at end of file
......@@ -21257,6 +21257,8 @@ CREATE INDEX index_notification_settings_on_user_id ON notification_settings USI
CREATE UNIQUE INDEX index_notifications_on_user_id_and_source_id_and_source_type ON notification_settings USING btree (user_id, source_id, source_type);
CREATE INDEX index_oauth_access_grants_on_resource_owner_id ON oauth_access_grants USING btree (resource_owner_id, application_id, created_at);
CREATE UNIQUE INDEX index_oauth_access_grants_on_token ON oauth_access_grants USING btree (token);
CREATE INDEX index_oauth_access_tokens_on_application_id ON oauth_access_tokens USING btree (application_id);
......
---
stage: none
group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
description: 'Learn how to install, configure, update, and maintain your GitLab instance.'
---
......
......@@ -349,7 +349,9 @@ PUT /groups/:id/epics/:epic_iid
| `title` | string | no | The title of an epic |
| `description` | string | no | The description of an epic. Limited to 1,048,576 characters. |
| `confidential` | boolean | no | Whether the epic should be confidential |
| `labels` | string | no | The comma separated list of labels |
| `labels` | string | no | Comma-separated label names for an issue. Set to an empty string to unassign all labels. |
| `add_labels` | string | no | Comma-separated label names to add to an issue. |
| `remove_labels` | string | no | Comma-separated label names to remove from an issue. |
| `updated_at` | string | no | When the epic was updated. Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` . Requires administrator or project/group owner privileges ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/255309) in GitLab 13.5) |
| `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` or from milestones (since 11.3) |
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |
......
......@@ -411,6 +411,8 @@ job B:
- cat vendor/hello.txt
cache:
key: build-cache
paths:
- vendor/
```
Here's what happens behind the scenes:
......
---
description: "Internal users documentation."
type: concepts, reference, dev
stage: none
group: Development
info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
---
# Internal users
GitLab uses internal users (sometimes referred to as "bots") to perform
actions or functions that cannot be attributed to a regular user.
These users are created programatically throughout the codebase itself when
necessary, and do not count towards a license limit.
They are used when a traditional user account would not be applicable, for
example when generating alerts or automatic review feedback.
Technically, an internal user is a type of user, but they have reduced access
and a very specific purpose. They cannot be used for regular user actions,
such as authentication or API requests.
They have email addresses and names which can be attributed to any actions
they perform.
For example, when we [migrated](https://gitlab.com/gitlab-org/gitlab/-/issues/216120)
GitLab Snippets to [Versioned Snippets](../user/snippets.md#versioned-snippets)
in GitLab 13.0, we used an internal user to attribute the authorship of
snippets to itself when a snippet's author wasn't available for creating
repository commits, such as when the user has been disabled, so the Migration
Bot was used instead.
For this bot:
- The name was set to `GitLab Migration Bot`.
- The email was set to `noreply+gitlab-migration-bot@{instance host}`.
Other examples of internal users:
- [Alert Bot](../operations/metrics/alerts.md#trigger-actions-from-alerts)
- [Ghost User](../user/profile/account/delete_account.md#associated-records)
- [Support Bot](../user/project/service_desk.md#support-bot-user)
- Visual Review Bot
......@@ -72,6 +72,17 @@ With GitLab Enterprise Edition, you can also:
You can also [integrate](project/integrations/overview.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, Jira, and a lot more.
## User types
There are several types of users in GitLab:
- Regular users and GitLab.com users. <!-- Note: further description TBA -->
- [Groups](group/index.md) of users.
- GitLab [admin area](admin_area/index.md) user.
- [GitLab Administrator](../administration/index.md) with full access to
self-managed instances' features and settings.
- [Internal users](../development/internal_users.md).
## Projects
In GitLab, you can create [projects](project/index.md) to host
......
......@@ -27,7 +27,7 @@ There are several ways to flag a merge request as a Draft:
description will have the same effect.
- **Deprecated** Add `[WIP]` or `WIP:` to the start of the merge request's title.
**WIP** still works but was deprecated in favor of **Draft**. It will be removed in the next major version (GitLab 14.0).
- Add the `/wip` [quick action](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics)
- Add the `/draft` (or `/wip`) [quick action](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics)
in a comment in the merge request. This is a toggle, and can be repeated
to change the status back. Note that any other text in the comment will be discarded.
- Add `draft:`, `Draft:`, `fixup!`, or `Fixup!` to the beginning of a commit message targeting the
......@@ -43,7 +43,7 @@ Similar to above, when a Merge Request is ready to be merged, you can remove the
- Remove `[Draft]`, `Draft:` or `(Draft)` from the start of the merge request's title. Clicking on
**Remove the Draft: prefix from the title**, under the title box, when editing the merge
request's description, will have the same effect.
- Add the `/wip` [quick action](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics)
- Add the `/draft` (or `/wip`) [quick action](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics)
in a comment in the merge request. This is a toggle, and can be repeated
to change the status back. Note that any other text in the comment will be discarded.
- Click on the **Resolve Draft status** button near the bottom of the merge request description,
......
......@@ -40,6 +40,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/copy_metadata <#issue>` | ✓ | ✓ | | Copy labels and milestone from another issue in the project. |
| `/create_merge_request <branch name>` | ✓ | | | Create a new merge request starting from the current issue. |
| `/done` | ✓ | ✓ | ✓ | Mark to do as done. |
| `/draft` | | ✓ | | Toggle the draft status. |
| `/due <date>` | ✓ | | | Set due date. Examples of valid `<date>` include `in 2 days`, `this Friday` and `December 31st`. |
| `/duplicate <#issue>` | ✓ | | | Close this issue and mark as a duplicate of another issue. **(CORE)** Also, mark both as related. **(STARTER)** |
| `/epic <epic>` | ✓ | | | Add to epic `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic. **(PREMIUM)** |
......@@ -82,7 +83,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/unlock` | ✓ | ✓ | | Unlock the discussions. |
| `/unsubscribe` | ✓ | ✓ | ✓ | Unsubscribe from notifications. |
| `/weight <value>` | ✓ | | | Set weight. Valid options for `<value>` include `0`, `1`, `2`, and so on. **(STARTER)** |
| `/wip` | | ✓ | | Toggle the Work In Progress status. |
| `/wip` | | ✓ | | Toggle the draft status. |
| `/zoom <Zoom URL>` | ✓ | | | Add Zoom meeting to this issue ([introduced in GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16609)). |
## Autocomplete characters
......
......@@ -59,13 +59,13 @@ export default {
<span class="gl-font-weight-bold gl-mr-3">{{ $options.i18n.lastUpdated }}</span>
<span class="gl-white-space-nowrap">
<time-ago-tooltip class="gl-pr-3" :time="pipeline.createdAt" />
<gl-link :href="pipeline.path" target="_blank">#{{ pipeline.id }}</gl-link>
<gl-link :href="pipeline.path">#{{ pipeline.id }}</gl-link>
<pipeline-status-badge :pipeline="pipeline" class="gl-ml-3" />
</span>
</div>
<div v-if="autoFixMrsCount" data-testid="auto-fix-mrs-link">
<span class="gl-font-weight-bold gl-mr-3">{{ $options.i18n.autoFixSolutions }}</span>
<gl-link :href="autoFixMrsPath" target="_blank" class="gl-white-space-nowrap">{{
<gl-link :href="autoFixMrsPath" class="gl-white-space-nowrap">{{
sprintf($options.i18n.autoFixMrsLink, { mrsCount: autoFixMrsCount })
}}</gl-link>
</div>
......
# frozen_string_literal: true
class SitemapController < ApplicationController
skip_before_action :authenticate_user!
feature_category :metrics
def show
return render_404 unless Gitlab.com?
return render_404 unless Feature.enabled?(:gitlab_org_sitemap)
respond_to do |format|
format.xml do
response = Sitemap::CreateService.new.execute
xml_data = if response.success?
response.payload[:sitemap]
else
xml_error(response.message)
end
render inline: xml_data
end
end
end
private
def xml_error(message)
xml_builder = Builder::XmlMarkup.new(indent: 2)
xml_builder.instruct!
xml_builder.error message
end
end
# frozen_string_literal: true
module Sitemap
class CreateService
def execute
result = Gitlab::Sitemaps::Generator.execute
if result.is_a?(String)
error_response(result)
else
success_response(result)
end
end
private
def success_response(file)
Gitlab::AppLogger.info("Sitemap generated successfully")
ServiceResponse.success(payload: { sitemap: file.render } )
end
def error_response(message)
Gitlab::AppLogger.error("Sitemap error creating sitemap: #{message}")
ServiceResponse.error(
message: message
)
end
end
end
......@@ -3,7 +3,7 @@
- if message.present? && subscribable.present?
.container-fluid.container-limited.pt-3
.gl-alert.alert-dismissible.gitlab-ee-license-banner.hidden.js-gitlab-ee-license-banner.pb-5.border-width-1px.border-style-solid.border-color-default.border-radius-default{ role: 'alert', data: { license_expiry: subscribable.expires_at } }
.gl-alert.alert-dismissible.gitlab-ee-license-banner.hidden.js-gitlab-ee-license-banner.gl-pb-7.gl-border-1.gl-border-solid.gl-border-gray-100.gl-rounded-base{ role: 'alert', data: { license_expiry: subscribable.expires_at } }
%button.close.p-2{ type: 'button', 'aria-label' => 'Dismiss banner', data: { dismiss: 'alert', track_event: 'click_button', track_label: 'dismiss_subscribable_banner' } }
%span{ 'aria-hidden' => 'true' }
= sprite_icon('merge-request-close-m', size: 24)
......
---
title: Ooen pipeline status widget links in the same tab
merge_request: 46893
author:
type: changed
---
title: Add add/remove label helpers to Epic API
merge_request: 40465
author:
type: added
---
title: Generate dynamically sitemap through controller
merge_request: 46661
author:
type: changed
......@@ -108,8 +108,10 @@ module API
optional :end_date, as: :due_date_fixed, type: String, desc: 'The due date of an epic'
optional :due_date_is_fixed, type: Boolean, desc: 'Indicates due date should be sourced from due_date_fixed field not the issue milestones'
optional :labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names'
optional :add_labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names'
optional :remove_labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names'
optional :state_event, type: String, values: %w[reopen close], desc: 'State event for an epic'
at_least_one_of :title, :description, :start_date_fixed, :start_date_is_fixed, :due_date_fixed, :due_date_is_fixed, :labels, :state_event, :confidential
at_least_one_of :title, :description, :start_date_fixed, :start_date_is_fixed, :due_date_fixed, :due_date_is_fixed, :labels, :add_labels, :remove_labels, :state_event, :confidential
end
put ':id/(-/)epics/:epic_iid' do
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/194104')
......
......@@ -274,6 +274,22 @@ module EE
}, approval_rules_counts)
end
override :usage_activity_by_stage_enablement
def usage_activity_by_stage_enablement(time_period)
return super unless ::Gitlab::Geo.enabled?
super.merge({
geo_secondary_web_oauth_users: distinct_count(
OauthAccessGrant
.where(time_period)
.where(
application_id: GeoNode.secondary_nodes.select(:oauth_application_id)
),
:resource_owner_id
)
})
end
# Omitted because no user, creator or author associated: `campaigns_imported_from_github`, `ldap_group_links`
override :usage_activity_by_stage_manage
def usage_activity_by_stage_manage(time_period)
......
......@@ -10,19 +10,22 @@ module Gitlab
def execute
unless Gitlab.com?
return "The sitemap can only be generated for Gitlab.com"
return 'The sitemap can only be generated for Gitlab.com'
end
file = Sitemaps::SitemapFile.new
if gitlab_org_group
file.add_elements(generic_urls)
file.add_elements(gitlab_org_group)
file.add_elements(gitlab_org_subgroups)
file.add_elements(gitlab_org_projects)
file.save
return "The group '#{GITLAB_ORG_NAMESPACE}' was not found" unless gitlab_org_group
file.add_elements(generic_urls)
file.add_elements(gitlab_org_group)
file.add_elements(gitlab_org_subgroups)
file.add_elements(gitlab_org_projects)
if file.empty?
'No urls found to generate the sitemap'
else
"The group '#{GITLAB_ORG_NAMESPACE}' was not found"
file
end
end
......@@ -37,7 +40,7 @@ module Gitlab
end
def gitlab_org_group
@gitlab_org_group ||= GroupFinder.new(nil).execute(path: 'gitlab-org', parent_id: nil, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
@gitlab_org_group ||= GroupFinder.new(nil).execute(path: GITLAB_ORG_NAMESPACE, parent_id: nil, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
def gitlab_org_subgroups
......
......@@ -20,17 +20,23 @@ module Gitlab
end
def save
return if urls.empty?
return if empty?
File.write(SITEMAP_FILE_PATH, render)
end
def render
return if empty?
fragment = File.read(File.expand_path("fragments/sitemap_file.xml.builder", __dir__))
instance_eval fragment
end
def empty?
urls.empty?
end
private
def xml_builder
......
# frozen_string_literal: true
# Generating the urls for the project and groups is the most
# expensive part of the sitemap generation because we need
# to call the Rails route helpers.
#
# We could hardcode them but if a route changes the sitemap
# urls will be invalid.
module Gitlab
module Sitemaps
class UrlExtractor
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe SitemapController do
describe '#show' do
subject { get :show, format: :xml }
before do
allow(Gitlab).to receive(:com?).and_return(dot_com)
end
context 'when not Gitlab.com?' do
let(:dot_com) { false }
it 'returns :not_found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when Gitlab.com?' do
let(:dot_com) { true }
context 'with an authenticated user' do
let(:flag_value) { true }
before do
stub_feature_flags(gitlab_org_sitemap: flag_value)
allow(Sitemap::CreateService).to receive_message_chain(:new, :execute).and_return(result)
subject
end
shared_examples 'gitlab_org_sitemap flag is disabled' do
let(:flag_value) { false }
it 'returns :not_found' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when the sitemap generation raises an error' do
let(:result) { ServiceResponse.error(message: 'foo') }
it 'returns an xml error' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to include('<error>foo</error>')
end
it_behaves_like 'gitlab_org_sitemap flag is disabled'
end
context 'when the sitemap was created suscessfully' do
let(:result) { ServiceResponse.success(payload: { sitemap: 'foo' }) }
it 'returns sitemap' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq('foo')
end
it_behaves_like 'gitlab_org_sitemap flag is disabled'
end
end
end
end
end
......@@ -374,6 +374,22 @@ RSpec.describe Gitlab::UsageData do
end
end
describe 'usage_data_by_stage_enablement' do
it 'returns empty hash if geo is not enabled' do
expect(described_class.usage_activity_by_stage_enablement({})).to eq({})
end
it 'excludes data outside of the date range' do
create_list(:geo_node, 2).each do |node|
for_defined_days_back do
create(:oauth_access_grant, application: node.oauth_application)
end
end
expect(described_class.usage_activity_by_stage_enablement(described_class.last_28_days_time_period)).to eq(geo_secondary_web_oauth_users: 2)
end
end
describe 'usage_activity_by_stage_manage' do
it 'includes accurate usage_activity_by_stage data' do
stub_config(
......
......@@ -42,12 +42,7 @@ RSpec.describe Gitlab::Sitemaps::Generator do
let_it_be(:internal_subgroup_internal_project) { create(:project, :internal, namespace: internal_subgroup) }
it 'includes default explore routes and gitlab-org group routes' do
new_path = Rails.root.join('tmp/tests/sitemap.xml')
stub_const('Gitlab::Sitemaps::SitemapFile::SITEMAP_FILE_PATH', new_path)
subject
content = File.read(new_path)
content = subject.render
expect(content).to include('/explore/projects')
expect(content).to include('/explore/groups')
......@@ -63,8 +58,6 @@ RSpec.describe Gitlab::Sitemaps::Generator do
expect(content).not_to include(public_subgroup_internal_project.full_path)
expect(content).not_to include(internal_subgroup_private_project.full_path)
expect(content).not_to include(internal_subgroup_internal_project.full_path)
File.delete(new_path)
end
end
end
......
......@@ -10,6 +10,12 @@ RSpec.describe Gitlab::Sitemaps::SitemapFile do
end
describe '#render' do
it 'returns if no elements has been provided' do
expect(File).not_to receive(:read)
described_class.new.save # rubocop: disable Rails/SaveBang
end
it 'generates a valid sitemap file' do
freeze_time do
content = subject.render
......
......@@ -7,6 +7,7 @@ RSpec.describe API::Epics do
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:label) { create(:group_label, group: group) }
let(:label2) { create(:group_label, group: group, title: 'label-2') }
let!(:epic) { create(:labeled_epic, group: group, labels: [label]) }
let(:params) { nil }
......@@ -61,6 +62,13 @@ RSpec.describe API::Epics do
end
end
shared_context 'with labels' do
before do
create(:label_link, label: label, target: epic)
create(:label_link, label: label2, target: epic)
end
end
describe 'GET /groups/:id/epics' do
let(:url) { "/groups/#{group.path}/epics" }
let(:params) { { include_descendant_groups: true } }
......@@ -762,22 +770,47 @@ RSpec.describe API::Epics do
expect(json_response['labels']).to be_empty
end
it 'updates the epic with labels param as array' do
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 110)
context 'with labels' do
include_context 'with labels'
params[:labels] = ['label1', 'label2', 'foo, bar', '&,?']
it 'updates the epic with labels param as array' do
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 110)
put api(url, user), params: params
params[:labels] = ['label1', 'label2', 'foo, bar', '&,?']
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to include 'new title'
expect(json_response['description']).to include 'new description'
expect(json_response['labels']).to include 'label1'
expect(json_response['labels']).to include 'label2'
expect(json_response['labels']).to include 'foo'
expect(json_response['labels']).to include 'bar'
expect(json_response['labels']).to include '&'
expect(json_response['labels']).to include '?'
put api(url, user), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to include 'new title'
expect(json_response['description']).to include 'new description'
expect(json_response['labels']).to include 'label1'
expect(json_response['labels']).to include 'label2'
expect(json_response['labels']).to include 'foo'
expect(json_response['labels']).to include 'bar'
expect(json_response['labels']).to include '&'
expect(json_response['labels']).to include '?'
end
it 'when adding labels, keeps existing labels and adds new' do
put api(url, user), params: { add_labels: '1, 2' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to contain_exactly(label.title, label2.title, '1', '2')
end
it 'when removing labels, only removes those specified' do
put api(url, user), params: { remove_labels: label.title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to eq([label2.title])
end
it 'when removing all labels, keeps no labels' do
put api(url, user), params: { remove_labels: "#{label.title}, #{label2.title}" }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to be_empty
end
end
context 'when state_event is close' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Sitemap::CreateService do
describe '#execute' do
subject { described_class.new.execute}
it 'returns the successful service response with the sitemap content' do
sitemap_file = Gitlab::Sitemaps::SitemapFile.new
allow(sitemap_file).to receive(:render).and_return('foo')
allow(Gitlab::Sitemaps::Generator).to receive(:execute).and_return(sitemap_file)
expect(subject).to be_success
expect(subject.payload[:sitemap]).to eq 'foo'
end
context 'when the sitemap generator returns an error' do
it 'returns an error service response' do
allow(Gitlab).to receive(:com?).and_return(false)
expect(subject).to be_error
expect(subject.message).to eq 'The sitemap can only be generated for Gitlab.com'
end
end
end
end
......@@ -20,6 +20,7 @@ module Atlassian
commits: commits,
branches: branches,
merge_requests: merge_requests,
user_notes_count: user_notes_count(merge_requests),
update_sequence_id: update_sequence_id
)
]
......@@ -37,6 +38,14 @@ module Atlassian
private
def user_notes_count(merge_requests)
return unless merge_requests
Note.count_for_collection(merge_requests.map(&:id), 'MergeRequest').map do |count_group|
[count_group.noteable_id, count_group.count]
end.to_h
end
def jwt_token(http_method, uri)
claims = Atlassian::Jwt.build_claims(
Atlassian::JiraConnect.app_key,
......
......@@ -20,7 +20,13 @@ module Atlassian
end
expose :title
expose :author, using: JiraConnect::Serializers::AuthorEntity
expose :user_notes_count, as: :commentCount
expose :commentCount do |mr|
if options[:user_notes_count]
options[:user_notes_count].fetch(mr.id, 0)
else
mr.user_notes_count
end
end
expose :source_branch, as: :sourceBranch
expose :target_branch, as: :destinationBranch
expose :lastUpdate do |mr|
......
......@@ -21,7 +21,11 @@ module Atlassian
JiraConnect::Serializers::BranchEntity.represent options[:branches], project: project, update_sequence_id: options[:update_sequence_id]
end
expose :pullRequests do |project, options|
JiraConnect::Serializers::PullRequestEntity.represent options[:merge_requests], project: project, update_sequence_id: options[:update_sequence_id]
JiraConnect::Serializers::PullRequestEntity.represent(
options[:merge_requests],
update_sequence_id: options[:update_sequence_id],
user_notes_count: options[:user_notes_count]
)
end
end
end
......
......@@ -56,21 +56,21 @@ module Gitlab
@updates[:merge] = params[:merge_request_diff_head_sha]
end
desc 'Toggle the Work In Progress status'
desc 'Toggle the Draft status'
explanation do
noun = quick_action_target.to_ability_name.humanize(capitalize: false)
if quick_action_target.work_in_progress?
_("Unmarks this %{noun} as Work In Progress.")
_("Unmarks this %{noun} as a draft.")
else
_("Marks this %{noun} as Work In Progress.")
_("Marks this %{noun} as a draft.")
end % { noun: noun }
end
execution_message do
noun = quick_action_target.to_ability_name.humanize(capitalize: false)
if quick_action_target.work_in_progress?
_("Unmarked this %{noun} as Work In Progress.")
_("Unmarked this %{noun} as a draft.")
else
_("Marked this %{noun} as Work In Progress.")
_("Marked this %{noun} as a draft.")
end % { noun: noun }
end
......@@ -80,7 +80,7 @@ module Gitlab
# Allow it to mark as WIP on MR creation page _or_ through MR notes.
(quick_action_target.new_record? || current_user.can?(:"update_#{quick_action_target.to_ability_name}", quick_action_target))
end
command :wip do
command :draft, :wip do
@updates[:wip_event] = quick_action_target.work_in_progress? ? 'unwip' : 'wip'
end
......
......@@ -527,6 +527,7 @@ module Gitlab
key => {
configure: usage_activity_by_stage_configure(time_period),
create: usage_activity_by_stage_create(time_period),
enablement: usage_activity_by_stage_enablement(time_period),
manage: usage_activity_by_stage_manage(time_period),
monitor: usage_activity_by_stage_monitor(time_period),
package: usage_activity_by_stage_package(time_period),
......@@ -582,6 +583,11 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
# Empty placeholder allows this to match the pattern used by other sections
def usage_activity_by_stage_enablement(time_period)
{}
end
# Omitted because no user, creator or author associated: `campaigns_imported_from_github`, `ldap_group_links`
# rubocop: disable CodeReuse/ActiveRecord
def usage_activity_by_stage_manage(time_period)
......
......@@ -10451,6 +10451,9 @@ msgstr ""
msgid "Environments|Stopping"
msgstr ""
msgid "Environments|Stopping %{environmentName}"
msgstr ""
msgid "Environments|There was an error fetching the logs. Please try again."
msgstr ""
......@@ -16305,7 +16308,7 @@ msgstr ""
msgid "Marked For Deletion At - %{deletion_time}"
msgstr ""
msgid "Marked this %{noun} as Work In Progress."
msgid "Marked this %{noun} as a draft."
msgstr ""
msgid "Marked this issue as a duplicate of %{duplicate_param}."
......@@ -16317,7 +16320,7 @@ msgstr ""
msgid "Marked to do as done."
msgstr ""
msgid "Marks this %{noun} as Work In Progress."
msgid "Marks this %{noun} as a draft."
msgstr ""
msgid "Marks this issue as a duplicate of %{duplicate_reference}."
......@@ -27514,7 +27517,7 @@ msgstr ""
msgid "This merge request is locked."
msgstr ""
msgid "This merge request is still a work in progress."
msgid "This merge request is still a draft."
msgstr ""
msgid "This merge request was merged. To apply this suggestion, edit this file directly."
......@@ -28694,10 +28697,10 @@ msgstr ""
msgid "Unlocks the discussion."
msgstr ""
msgid "Unmarked this %{noun} as Work In Progress."
msgid "Unmarked this %{noun} as a draft."
msgstr ""
msgid "Unmarks this %{noun} as Work In Progress."
msgid "Unmarks this %{noun} as a draft."
msgstr ""
msgid "Unreachable"
......
......@@ -50,3 +50,4 @@ UsageData/DistinctCountByLargeForeignKey:
- 'owner_id'
- 'project_id'
- 'user_id'
- 'resource_owner_id'
......@@ -33,7 +33,7 @@ RSpec.describe 'Merge request > User resolves Work in Progress', :js do
it 'retains merge request data after clicking Resolve WIP status' do
expect(page.find('.ci-widget-content')).to have_content("Pipeline ##{pipeline.id}")
expect(page).to have_content "This merge request is still a work in progress."
expect(page).to have_content "This merge request is still a draft."
page.within('.mr-state-widget') do
click_button('Mark as ready')
......@@ -45,7 +45,7 @@ RSpec.describe 'Merge request > User resolves Work in Progress', :js do
# merge request widget refreshes, which masks missing elements
# that should already be present.
expect(page.find('.ci-widget-content', wait: 0)).to have_content("Pipeline ##{pipeline.id}")
expect(page).not_to have_content('This merge request is still a work in progress.')
expect(page).not_to have_content('This merge request is still a draft.')
end
end
end
......@@ -710,24 +710,23 @@ describe('Api', () => {
});
describe('pipelineJobs', () => {
it('fetches the jobs for a given pipeline', done => {
const projectId = 123;
const pipelineId = 456;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
const payload = [
{
name: 'test',
},
];
mock.onGet(expectedUrl).reply(httpStatus.OK, payload);
it.each([undefined, {}, { foo: true }])(
'fetches the jobs for a given pipeline given %p params',
async params => {
const projectId = 123;
const pipelineId = 456;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
const payload = [
{
name: 'test',
},
];
mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, payload);
Api.pipelineJobs(projectId, pipelineId)
.then(({ data }) => {
expect(data).toEqual(payload);
})
.then(done)
.catch(done.fail);
});
const { data } = await Api.pipelineJobs(projectId, pipelineId, params);
expect(data).toEqual(payload);
},
);
});
describe('createBranch', () => {
......
......@@ -6,6 +6,7 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
import { diffViewerModes } from '~/ide/constants';
......@@ -207,6 +208,14 @@ describe('DiffFileHeader component', () => {
});
expect(findFileActions().exists()).toBe(false);
});
it('renders submodule icon', () => {
createComponent({
diffFile: submoduleDiffFile,
});
expect(wrapper.find(FileIcon).props('submodule')).toBe(true);
});
});
describe('for any file', () => {
......
......@@ -84,7 +84,7 @@ describe('Wip', () => {
it('should have correct elements', () => {
expect(el.classList.contains('mr-widget-body')).toBeTruthy();
expect(el.innerText).toContain('This merge request is still a work in progress.');
expect(el.innerText).toContain('This merge request is still a draft.');
expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
expect(el.querySelector('button').innerText).toContain('Merge');
expect(el.querySelector('.js-remove-wip').innerText.replace(/\s\s+/g, ' ')).toContain(
......
......@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
describe('File row component', () => {
......@@ -151,4 +152,18 @@ describe('File row component', () => {
expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold');
});
it('renders submodule icon', () => {
const submodule = true;
createComponent({
file: {
...file(),
submodule,
},
level: 0,
});
expect(wrapper.find(FileIcon).props('submodule')).toBe(submodule);
});
});
......@@ -5,7 +5,7 @@ import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_a
jest.mock('~/flash');
describe('Grouped security reports app', () => {
describe('Security reports app', () => {
let wrapper;
let mrTabsMock;
......@@ -21,6 +21,8 @@ describe('Grouped security reports app', () => {
});
};
const anyParams = expect.any(Object);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMrTabsMock = () => {
......@@ -43,10 +45,12 @@ describe('Grouped security reports app', () => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponent();
return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders the expected message', () => {
......@@ -75,10 +79,12 @@ describe('Grouped security reports app', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponent();
return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders nothing', () => {
......@@ -86,6 +92,42 @@ describe('Grouped security reports app', () => {
});
});
describe('security artifacts on last page of multi-page response', () => {
const numPages = 3;
beforeEach(() => {
jest
.spyOn(Api, 'pipelineJobs')
.mockImplementation(async (projectId, pipelineId, { page }) => {
const requestedPage = parseInt(page, 10);
if (requestedPage < numPages) {
return {
// Some jobs with no relevant artifacts
data: [{}, {}],
headers: { 'x-next-page': String(requestedPage + 1) },
};
} else if (requestedPage === numPages) {
return {
data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }],
};
}
throw new Error('Test failed due to request of non-existent jobs page');
});
createComponent();
return wrapper.vm.$nextTick();
});
it('fetches all pages', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
});
describe('given an error from the API', () => {
let error;
......@@ -93,10 +135,12 @@ describe('Grouped security reports app', () => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponent();
return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders nothing', () => {
......
......@@ -20,8 +20,11 @@ RSpec.describe Atlassian::JiraConnect::Client do
end
describe '#store_dev_info' do
it "calls the API with auth headers" do
expected_jwt = Atlassian::Jwt.encode(
let_it_be(:project) { create_default(:project, :repository) }
let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
let(:expected_jwt) do
Atlassian::Jwt.encode(
Atlassian::Jwt.build_claims(
Atlassian::JiraConnect.app_key,
'/rest/devinfo/0.10/bulk',
......@@ -29,7 +32,9 @@ RSpec.describe Atlassian::JiraConnect::Client do
),
'sample_secret'
)
end
before do
stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post)
.with(
headers: {
......@@ -37,8 +42,18 @@ RSpec.describe Atlassian::JiraConnect::Client do
'Content-Type' => 'application/json'
}
)
end
it "calls the API with auth headers" do
subject.store_dev_info(project: project)
end
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new { subject.store_dev_info(project: project, merge_requests: merge_requests) }.count
merge_requests << create(:merge_request, :unique_branches)
subject.store_dev_info(project: create(:project))
expect { subject.store_dev_info(project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::PullRequestEntity do
let_it_be(:project) { create_default(:project, :repository) }
let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
let_it_be(:notes) { create_list(:note, 2, system: false, noteable: merge_requests.first) }
subject { described_class.represent(merge_requests).as_json }
it 'exposes commentCount' do
expect(subject.first[:commentCount]).to eq(2)
end
context 'with user_notes_count option' do
let(:user_notes_count) { merge_requests.map { |merge_request| [merge_request.id, 1] }.to_h }
subject { described_class.represent(merge_requests, user_notes_count: user_notes_count).as_json }
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new do
described_class.represent(merge_requests, user_notes_count: user_notes_count)
end.count
merge_requests << create(:merge_request, :unique_branches)
expect { subject }.not_to exceed_query_limit(control_count)
end
it 'uses counts from user_notes_count' do
expect(subject.map { |entity| entity[:commentCount] }).to match_array([1, 1, 1])
end
context 'when count is missing for some MRs' do
let(:user_notes_count) { [[merge_requests.last.id, 1]].to_h }
it 'uses 0 as default when count for the MR is not available' do
expect(subject.map { |entity| entity[:commentCount] }).to match_array([0, 0, 1])
end
end
end
end
......@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Ci::AppendBuildTraceService do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline) }
before do
stub_feature_flags(ci_enable_live_trace: true)
......
......@@ -312,8 +312,8 @@ RSpec.describe QuickActions::InterpretService do
end
end
shared_examples 'wip command' do
it 'returns wip_event: "wip" if content contains /wip' do
shared_examples 'draft command' do
it 'returns wip_event: "wip" if content contains /draft' do
_, updates, _ = service.execute(content, issuable)
expect(updates).to eq(wip_event: 'wip')
......@@ -322,12 +322,12 @@ RSpec.describe QuickActions::InterpretService do
it 'returns the wip message' do
_, _, message = service.execute(content, issuable)
expect(message).to eq("Marked this #{issuable.to_ability_name.humanize(capitalize: false)} as Work In Progress.")
expect(message).to eq("Marked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
end
end
shared_examples 'unwip command' do
it 'returns wip_event: "unwip" if content contains /wip' do
shared_examples 'undraft command' do
it 'returns wip_event: "unwip" if content contains /draft' do
issuable.update!(title: issuable.wip_title)
_, updates, _ = service.execute(content, issuable)
......@@ -338,7 +338,7 @@ RSpec.describe QuickActions::InterpretService do
issuable.update!(title: issuable.wip_title)
_, _, message = service.execute(content, issuable)
expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as Work In Progress.")
expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
end
end
......@@ -1026,16 +1026,26 @@ RSpec.describe QuickActions::InterpretService do
let(:issuable) { issue }
end
it_behaves_like 'wip command' do
it_behaves_like 'draft command' do
let(:content) { '/wip' }
let(:issuable) { merge_request }
end
it_behaves_like 'unwip command' do
it_behaves_like 'undraft command' do
let(:content) { '/wip' }
let(:issuable) { merge_request }
end
it_behaves_like 'draft command' do
let(:content) { '/draft' }
let(:issuable) { merge_request }
end
it_behaves_like 'undraft command' do
let(:content) { '/draft' }
let(:issuable) { merge_request }
end
it_behaves_like 'empty command' do
let(:content) { '/remove_due_date' }
let(:issuable) { merge_request }
......@@ -1896,13 +1906,13 @@ RSpec.describe QuickActions::InterpretService do
end
end
describe 'wip command' do
let(:content) { '/wip' }
describe 'draft command' do
let(:content) { '/draft' }
it 'includes the new status' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Marks this merge request as Work In Progress.'])
expect(explanations).to eq(['Marks this merge request as a draft.'])
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment