Commit 9ff20606 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 869b4111 82a8940b
......@@ -89,17 +89,25 @@ module Issuables
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids(label_names)
group_labels = Label
.where(project_id: nil)
.where(title: label_names)
.where(group_id: root_namespace.self_and_descendant_ids)
find_label_ids_uncached(label_names)
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
project_labels = Label
.where(group_id: nil)
.where(title: label_names)
.where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
# This returns an array of label IDs per label name. It is possible for a label name
# to have multiple IDs because we allow labels with the same name if they are on a different
# project or group.
#
# For example, if we pass in `['bug', 'feature']`, this will return something like:
# `[ [1, 2], [3] ]`
#
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids_uncached(label_names)
return [] if label_names.empty?
group_labels = group_labels_for_root_namespace.where(title: label_names)
project_labels = project_labels_for_root_namespace.where(title: label_names)
Label
.from_union([group_labels, project_labels], remove_duplicates: false)
......@@ -109,8 +117,18 @@ module Issuables
.values
.map { |labels| labels.map(&:last) }
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_for_root_namespace
Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def project_labels_for_root_namespace
Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
......@@ -153,3 +171,5 @@ module Issuables
end
end
end
Issuables::LabelFilter.prepend_mod
......@@ -16,7 +16,12 @@ module Ci
private
def running_timed_out_builds
Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
if Feature.enabled?(:ci_new_query_for_running_stuck_jobs, default_enabled: :yaml)
running_builds = Ci::Build.running.created_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago).order(created_at: :asc, project_id: :asc) # rubocop: disable CodeReuse/ActiveRecord
Ci::Build.id_in(running_builds).updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
else
Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
end
end
end
end
......
---
name: ci_new_query_for_running_stuck_jobs
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71013
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339264
milestone: '14.4'
type: development
group: group::pipeline execution
default_enabled: false
# frozen_string_literal: true
class IndexLabelsUsingVarcharPatternOps < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
NEW_TITLE_INDEX_NAME = 'index_labels_on_title_varchar'
NEW_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_varchar_unique'
NEW_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_varchar_unique'
NEW_GROUP_ID_INDEX_NAME = 'index_labels_on_group_id'
OLD_TITLE_INDEX_NAME = 'index_labels_on_title'
OLD_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_unique'
OLD_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_unique'
OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_project_id_and_title'
def up
add_concurrent_index :labels, :title, order: { title: :varchar_pattern_ops }, name: NEW_TITLE_INDEX_NAME
add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_PROJECT_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_GROUP_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, :group_id, name: NEW_GROUP_ID_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_GROUP_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
end
def down
add_concurrent_index :labels, :title, name: OLD_TITLE_INDEX_NAME
add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, name: OLD_PROJECT_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, name: OLD_GROUP_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :project_id, :title], unique: true, name: OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_GROUP_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_GROUP_ID_INDEX_NAME
end
end
4430d4e0d688c85768201ab09056d60151fdc949b4b5f4ebc5397a99b9ec5f83
\ No newline at end of file
......@@ -25451,17 +25451,17 @@ CREATE INDEX index_label_priorities_on_priority ON label_priorities USING btree
CREATE UNIQUE INDEX index_label_priorities_on_project_id_and_label_id ON label_priorities USING btree (project_id, label_id);
CREATE UNIQUE INDEX index_labels_on_group_id_and_project_id_and_title ON labels USING btree (group_id, project_id, title);
CREATE INDEX index_labels_on_group_id ON labels USING btree (group_id);
CREATE UNIQUE INDEX index_labels_on_group_id_and_title_unique ON labels USING btree (group_id, title) WHERE (project_id IS NULL);
CREATE UNIQUE INDEX index_labels_on_group_id_and_title_varchar_unique ON labels USING btree (group_id, title varchar_pattern_ops) WHERE (project_id IS NULL);
CREATE INDEX index_labels_on_project_id ON labels USING btree (project_id);
CREATE UNIQUE INDEX index_labels_on_project_id_and_title_unique ON labels USING btree (project_id, title) WHERE (group_id IS NULL);
CREATE UNIQUE INDEX index_labels_on_project_id_and_title_varchar_unique ON labels USING btree (project_id, title varchar_pattern_ops) WHERE (group_id IS NULL);
CREATE INDEX index_labels_on_template ON labels USING btree (template) WHERE template;
CREATE INDEX index_labels_on_title ON labels USING btree (title);
CREATE INDEX index_labels_on_title_varchar ON labels USING btree (title varchar_pattern_ops);
CREATE INDEX index_labels_on_type_and_project_id ON labels USING btree (type, project_id);
---
stage: Platforms
group: Scalability
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/#assignments
---
# GitLab Application Service Level Indicators (SLIs)
> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
It is possible to define [Service Level Indicators
(SLIs)](https://en.wikipedia.org/wiki/Service_level_indicator)
directly in the Ruby codebase. This keeps the definition of operations
and their success close to the implementation and allows the people
building features to easily define how these features should be
monitored.
Defining an SLI causes 2
[Prometheus
counters](https://prometheus.io/docs/concepts/metric_types/#counter)
to be emitted from the rails application:
- `gitlab_sli:<sli name>:total`: incremented for each operation.
- `gitlab_sli:<sli_name>:success_total`: incremented for successful
operations.
## Existing SLIs
1. [`rails_request_apdex`](rails_request_apdex.md)
## Defining a new SLI
An SLI can be defined using the `Gitlab::Metrics::Sli` class.
Before the first scrape, it is important to have [initialized the SLI
with all possible
label-combinations](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics). This
avoid confusing results when using these counters in calculations.
To initialize an SLI, use the `.inilialize_sli` class method, for
example:
```ruby
Gitlab::Metrics::Sli.initialize_sli(:received_email, [
{
feature_category: :issue_tracking,
email_type: :create_issue
},
{
feature_category: :service_desk,
email_type: :service_desk
},
{
feature_category: :code_review,
email_type: :create_merge_request
}
])
```
Metrics must be initialized before they get
scraped for the first time. This could be done at the start time of the
process that will emit them, in which case we need to pay attention
not to increase application's boot time too much. This is preferable
if possible.
Alternatively, if initializing would take too long, this can be done
during the first scrape. We need to make sure we don't do it for every
scrape. This can be done as follows:
```ruby
def initialize_request_slis_if_needed!
return if Gitlab::Metrics::Sli.initialized?(:rails_request_apdex)
Gitlab::Metrics::Sli.initialize_sli(:rails_request_apdex, possible_request_labels)
end
```
Also pay attention to do it for the different metrics
endpoints we have. Currently the
[`WebExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/web_exporter.rb)
and the
[`HealthController`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/controllers/health_controller.rb)
for Rails and
[`SidekiqExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/sidekiq_exporter.rb)
for Sidekiq.
## Tracking operations for an SLI
Tracking an operation in the newly defined SLI can be done like this:
```ruby
Gitlab::Metrics::Sli[:received_email].increment(
labels: {
feature_category: :service_desk,
email_type: :service_desk
},
success: issue_created?
)
```
Calling `#increment` on this SLI will increment the total Prometheus counter
```prometheus
gitlab_sli:received_email:total{ feature_category='service_desk', email_type='service_desk' }
```
If the `success:` argument passed is truthy, then the success counter
will also be incremented:
```prometheus
gitlab_sli:received_email:success_total{ feature_category='service_desk', email_type='service_desk' }
```
## Using the SLI in service monitoring and alerts
When the application is emitting metrics for the new SLI, those need
to be consumed in the service catalog to result in alerts, and be
included in the error budget for stage groups and GitLab.com's overall
availability.
This is currently being worked on in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573). As
part of [this
issue](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1307)
we will update the documentation.
For any question, please don't hesitate to createan issue in [the
Scalability issue
tracker](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues)
or come find us in
[#g_scalability](https://gitlab.slack.com/archives/CMMF8TKR9) on Slack.
---
stage: Platforms
group: Scalability
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/#assignments
---
# Rails request apdex SLI
> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
NOTE:
This SLI is not yet used in [error budgets for stage
groups](../stage_group_dashboards.md#error-budget) or service
monitoring. This is being worked on in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573).
The request apdex SLI is [an SLI defined in the application](index.md)
that measures the duration of successful requests as an indicator for
application performance. This includes the REST and GraphQL API, and the
regular controller endpoints. It consists of these counters:
1. `gitlab_sli:rails_request_apdex:total`: This counter gets
incremented for every request that did not result in a response
with a 5xx status code. This means that slow failures don't get
counted twice: The request is already counted in the error-SLI.
1. `gitlab_sli:rails_request_apdex:success_total`: This counter gets
incremented for every successful request that performed faster than
the [defined target duration](#adjusting-request-target-duration).
Both these counters are labeled with:
1. `endpoint_id`: The identification of the Rails Controller or the
Grape-API endpoint
1. `feature_category`: The feature category specified for that
controller or API endpoint.
## Request Apdex SLO
These counters can be combined into a success ratio, the objective for
this ratio is defined in the service catalog per service:
1. [Web: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/web.jsonnet#L19)
1. [API: 0.995](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/api.jsonnet#L19)
1. [Git: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/git.jsonnet#L22)
This means that for this SLI to meet SLO, the ratio recorded needs to
be higher than those defined above.
For example: for the web-service, we want at least 99.8% of requests
to be faster than their target duration.
These are the targets we use for alerting and service montoring. So
durations should be set keeping those into account.
Both successful measurements and unsuccessful ones have an impact on the
error budget for stage groups.
## Adjusting request target duration
Not all endpoints perform the same type of work, so it is possible to
define different durations for different endpoints.
Long-running requests are more expensive for our
infrastructure: while one request is being served, the thread remains
occupied for the duration of that request. So nothing else can be handled by that
thread. Because of Ruby's Global VM Lock, the thread might keep the
lock and stall other requests handled by the same Puma worker
process. The request is in fact a noisy neighbor for other requests
handled by the worker. This is why the upper bound for a target
duration is capped at 5 seconds.
## Increasing the target duration (setting a slower target)
Increasing the target duration on an existing endpoint can be done on
a case-by-case basis. Please take the following into account:
1. Apdex is about perceived performance, if a user is actively waiting
for the result of a request, waiting 5 seconds might not be
acceptable. While if the endpoint is used by an automation
requiring a lot of data, 5 seconds could be okay.
A product manager can help to identify how an endpoint is used.
1. The workload for some endpoints can sometimes differ greatly
depending on the parameters specified by the caller. The target
duration needs to accomodate that. In some cases, it might be
interesting to define a separate [application
SLI](index.md#defining-a-new-sli) for what the endpoint is doing.
When the endpoints in certain cases turn into no-ops, making them
very fast, we should ignore these fast requests when setting the
target. For example, if the `MergeRequests::DraftsController` is
hit for every merge request being viewed, but doesn't need to
render anything in most cases, then we should pick the target that
would still accomodate the endpoint performing work.
1. Consider the dependent resources consumed by the endpoint. If the endpoint
loads a lot of data from Gitaly or the database and this is causing
it to not perform satisfactory. It could be better to optimize the
way the data is loaded rather than increasing the target duration.
In cases like this, it might be appropriate to temporarily increase
the duration to make the endpoint meet SLO, if this is bearable for
the infrastructure. In such cases, please link an issue from a code
comment.
If the endpoint consumes a lot of CPU time, we should also consider
this: these kinds of requests are the kind of noisy neighbors we
should try to keep as short as possible.
1. Traffic characteristics should also be taken into account: if the
trafic to the endpoint is bursty, like CI traffic spinning up a
big batch of jobs hitting the same endpoint, then having these
endpoints take 5s is not acceptable from an infrastructure point of
view. We cannot scale up the fleet fast enough to accomodate for
the incoming slow requests alongside the regular traffic.
When increasing the target duration for an existing endpoint, please
involve a [Scalability team
member](https://about.gitlab.com/handbook/engineering/infrastructure/team/scalability/#team-members)
in the review. We can use request rates and durations available in the
logs to come up with a recommendation. Picking a threshold can be done
using the same process as for [decreasing a target
duration](#decreasing-a-target-duration-setting-a-faster-target), picking a duration that is
higher than the SLO for the service.
We shouldn't set the longest durations on endpoints in the merge
requests that introduces them, since we don't yet have data to support
the decision.
## Decreasing a target duration (setting a faster target)
When decreasing the target duration, we need to make sure the endpoint
still meets SLO for the fleet that handles the request. You can use the
information in the logs to determine this:
1. Open [this table in
Kibana](https://log.gprd.gitlab.net/goto/bbb6465c68eb83642269e64a467df3df)
1. The table loads information for the busiest endpoints by
default. You can speed things up by adding a filter for
`json.caller_id.keyword` and adding the identifier you're intersted
in (for example: `Projects::RawController#show`).
1. Check the [appropriate percentile duration](#request-apdex-slo) for
the service the endpoint is handled by. The overall duration should
be lower than the target you intend to set.
1. If the overall duration is below the intended targed. Please also
check the peaks over time in [this
graph](https://log.gprd.gitlab.net/goto/9319c4a402461d204d13f3a4924a89fc)
in Kibana. Here, the percentile in question should not peak above
the target duration we want to set.
Since decreasing a threshold too much could result in alerts for the
apdex degradation, please also involve a Scalability team member in
the merge reqeust.
## How to adjust the target duration
The target duration can be specified similar to how endpoints [get a
feature category](../feature_categorization/index.md).
For endpoints that don't have a specific target, the default of 1s
(medium) will be used.
The following configurations are available:
| Name | Duration in seconds | Notes |
|------------|---------------------|-----------------------------------------------|
| :very_fast | 0.25s | |
| :fast | 0.5s | |
| :medium | 1s | This is the default when nothing is specified |
| :slow | 5s | |
### Rails controller
A duration can be specified for all actions in a controller like this:
```ruby
class Boards::ListsController < ApplicationController
target_duration :fast
end
```
To specify the duration also for certain actions in a controller, they
can be specified like this:
```ruby
class Boards::ListsController < ApplicationController
target_duration :fast, [:index, :show]
end
```
### Grape endpoints
To specify the duration for an entire API class, this can be done as
follows:
```ruby
module API
class Issues < ::API::Base
target_duration :slow
end
end
```
To specify the duration also for certain actions in a API class, they
can be specified like this:
```ruby
module API
class Issues < ::API::Base
target_duration :fast, [
'/groups/:id/issues',
'/groups/:id/issues_statistics'
]
end
end
```
Or, we can specify a custom duration per endpoint:
```ruby
get 'client/features', target_duration: :fast do
# endpoint logic
end
```
......@@ -334,6 +334,7 @@ See [database guidelines](database/index.md).
- [Features inside `.gitlab/`](features_inside_dot_gitlab.md)
- [Dashboards for stage groups](stage_group_dashboards.md)
- [Preventing transient bugs](transient/prevention-patterns.md)
- [GitLab Application SLIs](application_slis/index.md)
## Other GitLab Development Kit (GDK) guides
......
---
stage: Enablement
group: Infrastructure
stage: Platforms
group: Scalability
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/#assignments
---
......@@ -58,6 +58,12 @@ component can have 2 indicators:
[Web](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/web.jsonnet#L154)
services, that threshold is **5 seconds**.
We're working on making this target configurable per endpoint in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525). Learn
how to [customize the request
apdex](application_slis/rails_request_apdex.md), this new apdex
measurement is not yet part of the error budget.
For Sidekiq job execution, the threshold depends on the [job
urgency](sidekiq_style_guide.md#job-urgency). It is
[currently](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/lib/sidekiq-helpers.libsonnet#L25-38)
......
......@@ -180,6 +180,19 @@ For example:
1. GitLab automatically removes the `priority::low` label, as an issue should not
have two priority labels at the same time.
### Filter by scoped labels
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12285) in GitLab 14.4.
To filter issue, merge request, or epic lists for ones with labels that belong to a given scope, enter
`<scope>::*` in the searched label name.
For example, filtering by the `platform::*` label returns issues that have `platform::iOS`,
`platform::Android`, or `platform::Linux` labels.
NOTE:
This is not available on the [issues or merge requests dashboard pages](../search/index.md#issues-and-merge-requests).
### Workflows with scoped labels
Suppose you wanted a custom field in issues to track the operating system platform
......
......@@ -64,9 +64,6 @@ export default {
return {
currentSubscription: {},
activationNotification: null,
activationListeners: {
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.displayActivationNotification,
},
subscriptionHistory: [],
};
},
......@@ -78,6 +75,11 @@ export default {
return this.hasActiveLicense || this.hasValidSubscriptionData;
},
},
created() {
this.$options.activationListeners = {
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.displayActivationNotification,
};
},
methods: {
displayActivationNotification(license) {
if (isInFuture(new Date(license.startsAt))) {
......@@ -123,14 +125,14 @@ export default {
v-if="canShowSubscriptionDetails"
:subscription="currentSubscription"
:subscription-list="subscriptionHistory"
v-on="activationListeners"
v-on="$options.activationListeners"
/>
<div v-else class="row">
<div class="col-12 col-lg-8 offset-lg-2">
<h3 class="gl-mb-7 gl-mt-6 gl-text-center" data-testid="subscription-activation-title">
{{ $options.i18n.noActiveSubscription }}
</h3>
<subscription-activation-card v-on="activationListeners" />
<subscription-activation-card v-on="$options.activationListeners" />
<div class="row gl-mt-7">
<div class="col-lg-6">
<subscription-trial-card />
......
......@@ -36,10 +36,12 @@ export default {
data() {
return {
error: null,
activationListeners: {
[SUBSCRIPTION_ACTIVATION_FAILURE_EVENT]: this.handleActivationFailure,
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.handleActivationSuccess,
},
};
},
created() {
this.$options.activationListeners = {
[SUBSCRIPTION_ACTIVATION_FAILURE_EVENT]: this.handleActivationFailure,
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.handleActivationSuccess,
};
},
methods: {
......@@ -76,7 +78,7 @@ export default {
</template>
</gl-sprintf>
</p>
<subscription-activation-form class="gl-p-5" v-on="activationListeners" />
<subscription-activation-form class="gl-p-5" v-on="$options.activationListeners" />
<template #footer>
<gl-link
v-if="licenseUploadPath"
......
......@@ -15,6 +15,7 @@ import {
INVALID_CODE_ERROR_MESSAGE,
SUBSCRIPTION_ACTIVATION_FAILURE_EVENT,
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT,
subscriptionActivationForm,
subscriptionQueries,
} from '../constants';
......@@ -93,6 +94,7 @@ export default {
submit() {
if (!this.form.state) {
this.form.showValidation = true;
this.$emit(SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT);
return;
}
this.form.showValidation = false;
......@@ -127,6 +129,7 @@ export default {
this.handleError(error);
})
.finally(() => {
this.$emit(SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT);
this.isLoading = false;
});
},
......
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
import {
activateLabel,
cancelLabel,
activateSubscription,
subscriptionActivationInsertCode,
SUBSCRIPTION_ACTIVATION_FAILURE_EVENT,
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT,
} from '../constants';
import SubscriptionActivationErrors from './subscription_activation_errors.vue';
import SubscriptionActivationForm from './subscription_activation_form.vue';
export default {
actionCancel: { text: __('Cancel') },
actionPrimary: {
text: activateLabel,
},
bodyText: subscriptionActivationInsertCode,
title: activateSubscription,
name: 'SubscriptionActivationModal',
......@@ -41,13 +38,37 @@ export default {
data() {
return {
error: null,
activationListeners: {
[SUBSCRIPTION_ACTIVATION_FAILURE_EVENT]: this.handleActivationFailure,
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.handleActivationSuccess,
},
isLoading: false,
};
},
computed: {
actionCancel() {
return { text: cancelLabel };
},
actionPrimary() {
return {
text: activateLabel,
attributes: [
{
variant: 'confirm',
category: 'primary',
loading: this.isLoading,
},
],
};
},
},
created() {
this.$options.activationListeners = {
[SUBSCRIPTION_ACTIVATION_FAILURE_EVENT]: this.handleActivationFailure,
[SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT]: this.handleActivationSuccess,
[SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT]: this.handleActivationFinalized,
};
},
methods: {
handleActivationFinalized() {
this.isLoading = false;
},
handleActivationFailure(error) {
this.error = error;
},
......@@ -60,6 +81,7 @@ export default {
this.$emit('change', event);
},
handlePrimary() {
this.isLoading = true;
this.$refs.form.submit();
},
removeError() {
......@@ -74,8 +96,8 @@ export default {
:visible="visible"
:modal-id="modalId"
:title="$options.title"
:action-cancel="$options.actionCancel"
:action-primary="$options.actionPrimary"
:action-cancel="actionCancel"
:action-primary="actionPrimary"
@primary.prevent="handlePrimary"
@hidden="removeError"
@change="handleChange"
......@@ -85,7 +107,7 @@ export default {
<subscription-activation-form
ref="form"
:hide-submit-button="true"
v-on="activationListeners"
v-on="$options.activationListeners"
/>
</gl-modal>
</template>
......@@ -19,6 +19,7 @@ export const subscriptionActivationInsertCode = __(
export const howToActivateSubscription = s__(
'SuperSonics|Learn how to %{linkStart}activate your subscription%{linkEnd}.',
);
export const cancelLabel = __('Cancel');
export const activateLabel = s__('SuperSonics|Activate');
export const activateSubscription = s__('SuperSonics|Activate subscription');
export const activateCloudLicense = s__('SuperSonics|Activate cloud license');
......@@ -129,6 +130,7 @@ export const buySubscriptionCard = {
export const SUBSCRIPTION_ACTIVATION_FAILURE_EVENT = 'subscription-activation-failure';
export const SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT = 'subscription-activation-success';
export const SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT = 'subscription-activation-finalized';
export const INVALID_CODE_ERROR_MESSAGE = 'invalid activation code';
export const CONNECTIVITY_ERROR = 'CONNECTIVITY_ERROR';
......
<script>
import { GlButton, GlIcon, GlLink, GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import {
GlButton,
GlIcon,
GlLink,
GlSprintf,
GlSafeHtmlDirective as SafeHtml,
GlAlert,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import {
IssuableStates,
......@@ -30,6 +37,7 @@ export default {
GlIcon,
GlLink,
GlSprintf,
GlAlert,
IssuableList,
ExternalIssuesListEmptyState,
},
......@@ -70,6 +78,7 @@ export default {
[IssuableStates.Closed]: 0,
[IssuableStates.All]: 0,
},
errorMessage: null,
};
},
computed: {
......@@ -173,11 +182,9 @@ export default {
return filteredSearchValue;
},
onExternalIssuesQueryError(error, message) {
createFlash({
message: message || error.message,
captureError: true,
error,
});
this.errorMessage = message || error.message;
Sentry.captureException(error);
},
onIssuableListClickTab(selectedIssueState) {
this.currentPage = 1;
......@@ -225,7 +232,11 @@ export default {
</script>
<template>
<gl-alert v-if="errorMessage" class="gl-mt-3" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
<issuable-list
v-else
:namespace="projectFullPath"
:tabs="$options.IssuableListTabs"
:current-tab="currentState"
......
# frozen_string_literal: true
module EE
module Issuables
module LabelFilter
extend ::Gitlab::Utils::Override
SCOPED_LABEL_WILDCARD = '*'
private
override :find_label_ids_uncached
def find_label_ids_uncached(label_names)
return super unless root_namespace.licensed_feature_available?(:scoped_labels)
scoped_label_wildcards, label_names = extract_scoped_label_wildcards(label_names)
find_wildcard_label_ids(scoped_label_wildcards) + super(label_names)
end
def extract_scoped_label_wildcards(label_names)
label_names.partition { |name| name.ends_with?(::Label::SCOPED_LABEL_SEPARATOR + SCOPED_LABEL_WILDCARD) }
end
# This is similar to the CE version of `#find_label_ids_uncached` but the results
# are grouped by the wildcard prefix. With nested scoped labels, a label can match multiple prefixes.
# So a label_id can be present multiple times.
#
# For example, if we pass in `['workflow::*', 'workflow::backend::*']`, this will return something like:
# `[ [1, 2, 3], [1, 2] ]`
#
# rubocop: disable CodeReuse/ActiveRecord
def find_wildcard_label_ids(scoped_label_wildcards)
return [] if scoped_label_wildcards.empty?
scoped_label_prefixes = scoped_label_wildcards.map { |w| w.delete_suffix(SCOPED_LABEL_WILDCARD) }
relations = scoped_label_prefixes.flat_map do |prefix|
search_term = prefix + '%'
[
group_labels_for_root_namespace.where('title LIKE ?', search_term),
project_labels_for_root_namespace.where('title LIKE ?', search_term)
]
end
labels = ::Label
.from_union(relations, remove_duplicates: false)
.reorder(nil)
.pluck(:title, :id)
group_by_prefix(labels, scoped_label_prefixes).values
end
# rubocop: enable CodeReuse/ActiveRecord
def group_by_prefix(labels, prefixes)
labels.each_with_object({}) do |(title, id), ids_by_prefix|
prefixes.each do |prefix|
next unless title.start_with?(prefix)
ids_by_prefix[prefix] ||= []
ids_by_prefix[prefix] << id
end
end
end
end
end
end
......@@ -159,6 +159,22 @@ RSpec.describe EpicsFinder do
it 'returns all epics with given label' do
expect(epics(label_name: label.title)).to contain_exactly(labeled_epic)
end
context 'with scoped label wildcard' do
let_it_be(:scoped_label_1) { create(:group_label, group: group, title: 'devops::plan') }
let_it_be(:scoped_label_2) { create(:group_label, group: group, title: 'devops::create') }
let_it_be(:scoped_labeled_epic_1) { create(:labeled_epic, group: group, labels: [scoped_label_1]) }
let_it_be(:scoped_labeled_epic_2) { create(:labeled_epic, group: group, labels: [scoped_label_2]) }
before do
stub_licensed_features(epics: true, scoped_labels: true)
end
it 'returns all epics that match the wildcard' do
expect(epics(label_name: 'devops::*')).to contain_exactly(scoped_labeled_epic_1, scoped_labeled_epic_2)
end
end
end
context 'by state' do
......
......@@ -10,6 +10,101 @@ RSpec.describe IssuesFinder do
context 'scope: all' do
let(:scope) { 'all' }
describe 'filter by scoped label wildcard' do
let_it_be(:search_user) { create(:user) }
let_it_be(:group_devops_plan_label) { create(:group_label, group: group, title: 'devops::plan') }
let_it_be(:group_wfe_in_dev_label) { create(:group_label, group: group, title: 'workflow::frontend::in dev') }
let_it_be(:group_wfe_in_review_label) { create(:group_label, group: group, title: 'workflow::frontend::in review') }
let_it_be(:subgroup_devops_create_label) { create(:group_label, group: subgroup, title: 'devops::create') }
let_it_be(:project_wbe_in_dev_label) { create(:label, project: project3, title: 'workflow::backend::in dev') }
let_it_be(:project_label) { create(:label, project: project3) }
let_it_be(:devops_plan_be_in_dev_issue) { create(:labeled_issue, project: project3, labels: [group_devops_plan_label, project_wbe_in_dev_label]) }
let_it_be(:project_fe_in_dev_issue) { create(:labeled_issue, project: project3, labels: [project_label, group_wfe_in_dev_label]) }
let_it_be(:devops_create_issue) { create(:labeled_issue, project: project3, labels: [subgroup_devops_create_label]) }
let_it_be(:be_in_dev_issue) { create(:labeled_issue, project: project3, labels: [project_wbe_in_dev_label]) }
let_it_be(:project_fe_in_review_issue) { create(:labeled_issue, project: project3, labels: [project_label, group_wfe_in_review_label]) }
before_all do
project3.add_developer(search_user)
end
before do
stub_licensed_features(scoped_labels: true)
end
let(:base_params) { { project_id: project3.id } }
context 'when scoped labels are unavailable' do
let(:params) { base_params.merge(label_name: 'devops::*') }
before do
stub_licensed_features(scoped_labels: false)
end
it 'does not return any results' do
expect(issues).to be_empty
end
end
context 'when project scope is not given' do
let(:params) { { label_name: 'devops::*' } }
it 'does not return any results' do
expect(issues).to be_empty
end
end
context 'with a single wildcard filter' do
let(:params) { base_params.merge(label_name: 'devops::*') }
it 'returns issues that have labels that match the wildcard' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, devops_create_issue)
end
end
context 'with multiple wildcard filters' do
let(:params) { base_params.merge(label_name: ['devops::*', 'workflow::backend::*']) }
it 'returns issues that have labels that match both wildcards' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue)
end
end
context 'combined with a regular label filter' do
let(:params) { base_params.merge(label_name: [project_label.name, 'workflow::frontend::*']) }
it 'returns issues that have labels that match the wildcard and the regular label' do
expect(issues).to contain_exactly(project_fe_in_dev_issue, project_fe_in_review_issue)
end
end
context 'with nested prefix' do
let(:params) { base_params.merge(label_name: 'workflow::*') }
it 'returns issues that have labels that match the prefix' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, be_in_dev_issue, project_fe_in_dev_issue, project_fe_in_review_issue)
end
end
context 'with overlapping prefixes' do
let(:params) { base_params.merge(label_name: ['workflow::*', 'workflow::backend::*']) }
it 'returns issues that have labels that match both prefixes' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, be_in_dev_issue)
end
end
context 'using NOT' do
let(:params) { base_params.merge(not: { label_name: 'devops::*' }) }
it 'returns issues that do not have labels that match the wildcard' do
expect(issues).to contain_exactly(issue4, project_fe_in_dev_issue, project_fe_in_review_issue, be_in_dev_issue)
end
end
end
describe 'filter by weight' do
let_it_be(:issue_with_weight_1) { create(:issue, project: project3, weight: 1) }
let_it_be(:issue_with_weight_42) { create(:issue, project: project3, weight: 42) }
......
......@@ -30,5 +30,23 @@ RSpec.describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merged_merge_request)
end
end
context 'filtering by scoped label wildcard' do
let_it_be(:scoped_label_1) { create(:label, project: project4, title: 'devops::plan') }
let_it_be(:scoped_label_2) { create(:label, project: project4, title: 'devops::create') }
let_it_be(:scoped_labeled_merge_request_1) { create(:labeled_merge_request, source_project: project4, source_branch: 'branch1', labels: [scoped_label_1]) }
let_it_be(:scoped_labeled_merge_request_2) { create(:labeled_merge_request, source_project: project4, source_branch: 'branch2', labels: [scoped_label_2]) }
before do
stub_licensed_features(scoped_labels: true)
end
it 'returns all merge requests that match the wildcard' do
merge_requests = described_class.new(user, { project_id: project4.id, label_name: 'devops::*' }).execute
expect(merge_requests).to contain_exactly(scoped_labeled_merge_request_1, scoped_labeled_merge_request_2)
end
end
end
end
......@@ -7,12 +7,14 @@ import {
INVALID_CODE_ERROR,
SUBSCRIPTION_ACTIVATION_FAILURE_EVENT,
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT,
subscriptionQueries,
subscriptionActivationForm,
} from 'ee/admin/subscriptions/show/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { preventDefault, stopPropagation } from '../../test_helpers';
import {
activateLicenseMutationResponse,
......@@ -121,6 +123,10 @@ describe('SubscriptionActivationForm', () => {
expect(mutationMock).toHaveBeenCalledTimes(0);
});
it(`emits the ${SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT} event`, () => {
expect(wrapper.emitted(SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT).length).toBe(1);
});
describe('adds text that does not match the pattern', () => {
beforeEach(async () => {
await findActivationCodeInput().vm.$emit('input', `${fakeActivationCode}2021-asdf`);
......@@ -152,6 +158,12 @@ describe('SubscriptionActivationForm', () => {
subscriptionActivationForm.acceptTermsFeedback,
);
});
it(`emits the ${SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT} event`, async () => {
findActivateSubscriptionForm().vm.$emit('submit', createFakeEvent());
await waitForPromises();
expect(wrapper.emitted(SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT).length).toBe(2);
});
});
});
});
......
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import SubscriptionActivationErrors from 'ee/admin/subscriptions/show/components/subscription_activation_errors.vue';
import SubscriptionActivationForm from 'ee/admin/subscriptions/show/components/subscription_activation_form.vue';
import SubscriptionActivationModal from 'ee/admin/subscriptions/show/components/subscription_activation_modal.vue';
......@@ -10,29 +11,29 @@ import {
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
subscriptionActivationInsertCode,
} from 'ee/admin/subscriptions/show/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { preventDefault } from '../../test_helpers';
import { activateLicenseMutationResponse } from '../mock_data';
const modalId = 'fake-modal-id';
describe('SubscriptionActivationModal', () => {
let wrapper;
const modalId = 'fake-modal-id';
const findGlModal = () => wrapper.findComponent(GlModal);
const firePrimaryEvent = () => findGlModal().vm.$emit('primary', { preventDefault });
const findSubscriptionActivationErrors = () =>
wrapper.findComponent(SubscriptionActivationErrors);
const findSubscriptionActivationForm = () => wrapper.findComponent(SubscriptionActivationForm);
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(SubscriptionActivationModal, {
propsData: {
modalId,
visible: false,
...props,
},
}),
);
const createComponent = (options = {}) => {
const { props = {}, mountFn = shallowMount } = options;
wrapper = mountFn(SubscriptionActivationModal, {
propsData: {
modalId,
visible: false,
...props,
},
});
};
afterEach(() => {
......@@ -78,19 +79,34 @@ describe('SubscriptionActivationModal', () => {
});
describe('subscription activation', () => {
const fakeEvent = 'fake-modal-event';
let submitSpy;
describe('when the "primary" button is clicked', () => {
beforeEach(async () => {
createComponent({ mountFn: mount, props: { visible: true } });
// Wait for $refs.form to be present
await nextTick();
submitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit');
});
describe('when submitting the form', () => {
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm, 'handlePrimary')
.mockImplementation(() => wrapper.vm.$emit(fakeEvent));
findGlModal().vm.$emit('primary', { preventDefault });
it('submits the form', () => {
firePrimaryEvent();
expect(submitSpy).toHaveBeenCalled();
});
it('shows loading in the button', async () => {
submitSpy.mockImplementation(() => {});
firePrimaryEvent();
// Wait for submit to emit event
await nextTick();
expect(findGlModal().props('actionPrimary').attributes[0].loading).toEqual(true);
});
it('emits the correct event', () => {
expect(wrapper.emitted(fakeEvent)).toEqual([[]]);
it('stops loading in the button', async () => {
firePrimaryEvent();
// Wait for submit to emit event
await nextTick();
expect(findGlModal().props('actionPrimary').attributes[0].loading).toEqual(false);
});
});
......
import { GlAlert } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { shallowMount, createLocalVue, mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
......@@ -8,7 +10,6 @@ import jiraIssuesResolver from 'ee/integrations/jira/issues_list/graphql/resolve
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { i18n } from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
......@@ -61,9 +62,21 @@ describe('ExternalIssuesListRoot', () => {
const mockLabel = 'ecosystem';
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findAlert = () => wrapper.findComponent(GlAlert);
const createLabelFilterEvent = (data) => ({ type: 'labels', value: { data } });
const createSearchFilterEvent = (data) => ({ type: 'filtered-search-term', value: { data } });
const expectErrorHandling = (expectedRenderedErrorMessage) => {
const issuesList = findIssuableList();
const alert = findAlert();
expect(issuesList.exists()).toBe(false);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(expectedRenderedErrorMessage);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
};
const createComponent = ({
apolloProvider = createMockApolloProvider(),
provide = mockProvide,
......@@ -300,13 +313,17 @@ describe('ExternalIssuesListRoot', () => {
});
describe('error handling', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
});
describe('when request fails', () => {
it.each`
APIErrors | expectedRenderedErrorMessage
${['API error']} | ${'API error'}
${undefined} | ${i18n.errorFetchingIssues}
`(
'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrors"',
'displays error alert with "$expectedRenderedErrorMessage" when API responds with "$APIErrors"',
async ({ APIErrors, expectedRenderedErrorMessage }) => {
jest.spyOn(axios, 'get');
mock
......@@ -316,17 +333,13 @@ describe('ExternalIssuesListRoot', () => {
createComponent();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: expectedRenderedErrorMessage,
captureError: true,
error: expect.any(Object),
});
expectErrorHandling(expectedRenderedErrorMessage);
},
);
});
describe('when GraphQL network error is encountered', () => {
it('calls `createFlash` correctly with default error message', async () => {
it('displays error alert with default error message', async () => {
createComponent({
apolloProvider: createMockApolloProvider({
Query: {
......@@ -336,35 +349,24 @@ describe('ExternalIssuesListRoot', () => {
});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: i18n.errorFetchingIssues,
captureError: true,
error: expect.any(Object),
});
expectErrorHandling(i18n.errorFetchingIssues);
});
});
});
describe('pagination', () => {
it.each`
scenario | issuesListLoadFailed | issues | shouldShowPaginationControls
${'fails'} | ${true} | ${[]} | ${false}
${'returns no issues'} | ${false} | ${[]} | ${false}
${`returns some issues`} | ${false} | ${mockExternalIssues} | ${true}
scenario | issues | shouldShowPaginationControls
${'returns no issues'} | ${[]} | ${false}
${`returns some issues`} | ${mockExternalIssues} | ${true}
`(
'sets `showPaginationControls` prop to $shouldShowPaginationControls when request $scenario',
async ({ issuesListLoadFailed, issues, shouldShowPaginationControls }) => {
async ({ issues, shouldShowPaginationControls }) => {
jest.spyOn(axios, 'get');
mock
.onGet(mockProvide.issuesFetchPath)
.replyOnce(
issuesListLoadFailed ? httpStatus.INTERNAL_SERVER_ERROR : httpStatus.OK,
issues,
{
'x-page': 1,
'x-total': issues.length,
},
);
mock.onGet(mockProvide.issuesFetchPath).replyOnce(httpStatus.OK, issues, {
'x-page': 1,
'x-total': issues.length,
});
createComponent();
await waitForPromises();
......
......@@ -17,20 +17,47 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
job.update!(job_attributes)
end
context 'when job is running' do
let(:status) { 'running' }
around do |example|
freeze_time { example.run }
end
shared_examples 'running builds' do
context 'when job is running' do
let(:status) { 'running' }
let(:outdated_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago - 30.minutes }
let(:fresh_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago + 30.minutes }
context 'when job is outdated' do
let(:created_at) { outdated_time }
let(:updated_at) { outdated_time }
it_behaves_like 'job is dropped'
end
context 'when job was updated_at more than an hour ago' do
let(:updated_at) { 2.hours.ago }
context 'when job is fresh' do
let(:created_at) { fresh_time }
let(:updated_at) { fresh_time }
it_behaves_like 'job is dropped'
it_behaves_like 'job is unchanged'
end
context 'when job freshly updated' do
let(:created_at) { outdated_time }
let(:updated_at) { fresh_time }
it_behaves_like 'job is unchanged'
end
end
end
context 'when job was updated in less than 1 hour ago' do
let(:updated_at) { 30.minutes.ago }
include_examples 'running builds'
it_behaves_like 'job is unchanged'
context 'when ci_new_query_for_running_stuck_jobs flag is disabled' do
before do
stub_feature_flags(ci_new_query_for_running_stuck_jobs: false)
end
include_examples 'running builds'
end
%w(success skipped failed canceled scheduled pending).each do |status|
......@@ -51,15 +78,4 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
end
end
end
context 'for deleted project' do
let(:status) { 'running' }
let(:updated_at) { 2.days.ago }
before do
job.project.update!(pending_delete: true)
end
it_behaves_like 'job is dropped'
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