Commit 6b13a226 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 2ac93cb8
...@@ -8,7 +8,6 @@ import { ...@@ -8,7 +8,6 @@ import {
GlTable, GlTable,
GlSearchBoxByClick, GlSearchBoxByClick,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -76,8 +75,8 @@ export default { ...@@ -76,8 +75,8 @@ export default {
this.startPolling(`${this.indexPath}?search_term=${this.errorSearchQuery}`); this.startPolling(`${this.indexPath}?search_term=${this.errorSearchQuery}`);
}, },
trackViewInSentryOptions, trackViewInSentryOptions,
viewDetails(errorId) { getDetailsLink(errorId) {
visitUrl(`error_tracking/${errorId}/details`); return `error_tracking/${errorId}/details`;
}, },
}, },
}; };
...@@ -129,11 +128,7 @@ export default { ...@@ -129,11 +128,7 @@ export default {
</template> </template>
<template slot="error" slot-scope="errors"> <template slot="error" slot-scope="errors">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<gl-link <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
class="d-flex text-dark"
target="_blank"
@click="viewDetails(errors.item.id)"
>
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong> <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link> </gl-link>
<span class="text-secondary text-truncate"> <span class="text-secondary text-truncate">
......
...@@ -27,6 +27,8 @@ export default { ...@@ -27,6 +27,8 @@ export default {
:lines="entry.context" :lines="entry.context"
:file-path="entry.filename" :file-path="entry.filename"
:error-line="entry.lineNo" :error-line="entry.lineNo"
:error-fn="entry.function"
:error-column="entry.colNo"
:expanded="isFirstEntry(index)" :expanded="isFirstEntry(index)"
/> />
</div> </div>
......
<script> <script>
import { __, sprintf } from '~/locale';
import { GlTooltip } from '@gitlab/ui'; import { GlTooltip } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue';
...@@ -22,9 +23,20 @@ export default { ...@@ -22,9 +23,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
errorFn: {
type: String,
required: false,
default: '',
},
errorLine: { errorLine: {
type: Number, type: Number,
required: true, required: false,
default: 0,
},
errorColumn: {
type: Number,
required: false,
default: 0,
}, },
expanded: { expanded: {
type: Boolean, type: Boolean,
...@@ -38,12 +50,23 @@ export default { ...@@ -38,12 +50,23 @@ export default {
}; };
}, },
computed: { computed: {
linesLength() { hasCode() {
return this.lines.length; return Boolean(this.lines.length);
}, },
collapseIcon() { collapseIcon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right'; return this.isExpanded ? 'chevron-down' : 'chevron-right';
}, },
noCodeFn() {
return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : '';
},
noCodeLine() {
return this.errorLine
? sprintf(__('at line %{errorLine}%{errorColumn}'), {
errorLine: this.errorLine,
errorColumn: this.errorColumn ? `:${this.errorColumn}` : '',
})
: '';
},
}, },
methods: { methods: {
isHighlighted(lineNum) { isHighlighted(lineNum) {
...@@ -66,27 +89,31 @@ export default { ...@@ -66,27 +89,31 @@ export default {
<template> <template>
<div class="file-holder"> <div class="file-holder">
<div ref="header" class="file-title file-title-flex-parent"> <div ref="header" class="file-title file-title-flex-parent">
<div class="file-header-content "> <div class="file-header-content d-flex align-content-center">
<div class="d-inline-block cursor-pointer" @click="toggle()"> <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()">
<icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" />
</div> </div>
<div class="d-inline-block append-right-4"> <file-icon
<file-icon :file-name="filePath"
:file-name="filePath" :size="18"
:size="18" aria-hidden="true"
aria-hidden="true" css-classes="append-right-5"
css-classes="append-right-5" />
/> <strong
<strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> v-gl-tooltip
{{ filePath }} :title="filePath"
</strong> class="file-title-name d-inline-block overflow-hidden text-truncate"
</div> :class="{ 'limited-width': !hasCode }"
data-container="body"
>
{{ filePath }}
</strong>
<clipboard-button <clipboard-button
:title="__('Copy file path')" :title="__('Copy file path')"
:text="filePath" :text="filePath"
css-class="btn-default btn-transparent btn-clipboard" css-class="btn-default btn-transparent btn-clipboard position-static"
/> />
<span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span>
</div> </div>
</div> </div>
......
export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse(); export const stacktrace = state =>
state.stacktraceData.stack_trace_entries
? state.stacktraceData.stack_trace_entries.reverse()
: [];
export default () => {}; export default () => {};
...@@ -52,6 +52,11 @@ export default { ...@@ -52,6 +52,11 @@ export default {
header: s__('PerformanceBar|Redis calls'), header: s__('PerformanceBar|Redis calls'),
keys: ['cmd'], keys: ['cmd'],
}, },
{
metric: 'total',
header: s__('PerformanceBar|Frontend resources'),
keys: ['name', 'size'],
},
], ],
data() { data() {
return { currentRequestId: '' }; return { currentRequestId: '' };
......
/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
import Vue from 'vue'; import Vue from 'vue';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -53,12 +54,61 @@ export default ({ container }) => ...@@ -53,12 +54,61 @@ export default ({ container }) =>
PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId) PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
.then(res => { .then(res => {
this.store.addRequestDetails(requestId, res.data); this.store.addRequestDetails(requestId, res.data);
if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
}) })
.catch(() => .catch(() =>
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(`Error getting performance bar results for ${requestId}`), console.warn(`Error getting performance bar results for ${requestId}`),
); );
}, },
collectFrontendPerformanceMetrics() {
if (performance) {
const navigationEntries = performance.getEntriesByType('navigation');
const paintEntries = performance.getEntriesByType('paint');
const resourceEntries = performance.getEntriesByType('resource');
let durationString = '';
if (navigationEntries.length > 0) {
durationString = `BE ${this.formatMs(navigationEntries[0].responseEnd)} / `;
durationString += `FCP ${this.formatMs(paintEntries[1].startTime)} / `;
durationString += `DOM ${this.formatMs(navigationEntries[0].domContentLoadedEventEnd)}`;
}
let newEntries = resourceEntries.map(this.transformResourceEntry);
this.updateFrontendPerformanceMetrics(durationString, newEntries);
if ('PerformanceObserver' in window) {
// We start observing for more incoming timings
const observer = new PerformanceObserver(list => {
newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry));
this.updateFrontendPerformanceMetrics(durationString, newEntries);
});
observer.observe({ entryTypes: ['resource'] });
}
}
},
updateFrontendPerformanceMetrics(durationString, requestEntries) {
this.store.setRequestDetailsData(this.requestId, 'total', {
duration: durationString,
calls: requestEntries.length,
details: requestEntries,
});
},
transformResourceEntry(entry) {
const nf = new Intl.NumberFormat();
return {
name: entry.name.replace(document.location.origin, ''),
duration: Math.round(entry.duration),
size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached',
};
},
formatMs(msValue) {
const nf = new Intl.NumberFormat();
return `${nf.format(Math.round(msValue))}ms`;
},
}, },
render(createElement) { render(createElement) {
return createElement('performance-bar-app', { return createElement('performance-bar-app', {
......
...@@ -32,6 +32,16 @@ export default class PerformanceBarStore { ...@@ -32,6 +32,16 @@ export default class PerformanceBarStore {
return request; return request;
} }
setRequestDetailsData(requestId, metricKey, requestDetailsData) {
const selectedRequest = this.findRequest(requestId);
if (selectedRequest) {
selectedRequest.details = {
...selectedRequest.details,
[metricKey]: requestDetailsData,
};
}
}
requestsWithDetails() { requestsWithDetails() {
return this.requests.filter(request => request.details); return this.requests.filter(request => request.details);
} }
......
...@@ -17,6 +17,7 @@ const handleUserPopoverMouseOut = event => { ...@@ -17,6 +17,7 @@ const handleUserPopoverMouseOut = event => {
renderedPopover.$destroy(); renderedPopover.$destroy();
renderedPopover = null; renderedPopover = null;
} }
target.removeAttribute('aria-describedby');
}; };
/** /**
......
...@@ -12,6 +12,12 @@ ...@@ -12,6 +12,12 @@
} }
} }
.file-title-name {
&.limited-width {
max-width: 80%;
}
}
.line_content.old::before { .line_content.old::before {
content: none !important; content: none !important;
} }
......
...@@ -17,11 +17,11 @@ module Clusters ...@@ -17,11 +17,11 @@ module Clusters
include ::Clusters::Concerns::ApplicationData include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue include AfterCommitQueue
alias_method :original_set_initial_status, :set_initial_status
def set_initial_status def set_initial_status
return unless not_installable? return unless cluster&.platform_kubernetes_rbac?
return unless verify_cluster?
self.status = status_states[:installable] original_set_initial_status
end end
state_machine :status do state_machine :status do
...@@ -131,10 +131,6 @@ module Clusters ...@@ -131,10 +131,6 @@ module Clusters
[Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)] [Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)]
end end
def verify_cluster?
cluster&.application_helm_available? && cluster&.platform_kubernetes_rbac?
end
end end
end end
end end
...@@ -5,6 +5,7 @@ class Deployment < ApplicationRecord ...@@ -5,6 +5,7 @@ class Deployment < ApplicationRecord
include IidRoutes include IidRoutes
include AfterCommitQueue include AfterCommitQueue
include UpdatedAtFilterable include UpdatedAtFilterable
include Gitlab::Utils::StrongMemoize
belongs_to :project, required: true belongs_to :project, required: true
belongs_to :environment, required: true belongs_to :environment, required: true
...@@ -126,6 +127,12 @@ class Deployment < ApplicationRecord ...@@ -126,6 +127,12 @@ class Deployment < ApplicationRecord
@scheduled_actions ||= deployable.try(:other_scheduled_actions) @scheduled_actions ||= deployable.try(:other_scheduled_actions)
end end
def playable_build
strong_memoize(:playable_build) do
deployable.try(:playable?) ? deployable : nil
end
end
def includes_commit?(commit) def includes_commit?(commit)
return false unless commit return false unless commit
......
...@@ -78,7 +78,7 @@ class EnvironmentStatus ...@@ -78,7 +78,7 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline) def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline return [] unless pipeline
pipeline.environments.available.map do |environment| pipeline.environments.includes(:project).available.map do |environment|
next unless Ability.allowed?(user, :read_environment, environment) next unless Ability.allowed?(user, :read_environment, environment)
EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
......
...@@ -2250,12 +2250,13 @@ class Project < ApplicationRecord ...@@ -2250,12 +2250,13 @@ class Project < ApplicationRecord
# Git objects are only poolable when the project is or has: # Git objects are only poolable when the project is or has:
# - Hashed storage -> The object pool will have a remote to its members, using relative paths. # - Hashed storage -> The object pool will have a remote to its members, using relative paths.
# If the repository path changes we would have to update the remote. # If the repository path changes we would have to update the remote.
# - Public -> User will be able to fetch Git objects that might not exist # - not private -> The visibility level or repository access level has to be greater than private
# in their own repository. # to prevent fetching objects that might not exist
# - Repository -> Else the disk path will be empty, and there's nothing to pool # - Repository -> Else the disk path will be empty, and there's nothing to pool
def git_objects_poolable? def git_objects_poolable?
hashed_storage?(:repository) && hashed_storage?(:repository) &&
public? && visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
repository_access_level > ProjectFeature::PRIVATE &&
repository_exists? && repository_exists? &&
Gitlab::CurrentSettings.hashed_storage_enabled Gitlab::CurrentSettings.hashed_storage_enabled
end end
......
...@@ -37,6 +37,9 @@ class DeploymentEntity < Grape::Entity ...@@ -37,6 +37,9 @@ class DeploymentEntity < Grape::Entity
expose :commit, using: CommitEntity, if: -> (*) { include_details? } expose :commit, using: CommitEntity, if: -> (*) { include_details? }
expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
expose :playable_build, expose_nil: false, if: -> (*) { include_details? && can_create_deployment? } do |deployment, options|
JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
end
expose :cluster, using: ClusterBasicEntity expose :cluster, using: ClusterBasicEntity
...@@ -47,7 +50,7 @@ class DeploymentEntity < Grape::Entity ...@@ -47,7 +50,7 @@ class DeploymentEntity < Grape::Entity
end end
def can_create_deployment? def can_create_deployment?
can?(request.current_user, :create_deployment, request.project) can?(request.current_user, :create_deployment, project)
end end
def can_read_deployables? def can_read_deployables?
...@@ -56,6 +59,10 @@ class DeploymentEntity < Grape::Entity ...@@ -56,6 +59,10 @@ class DeploymentEntity < Grape::Entity
# because it triggers a policy evaluation that involves multiple # because it triggers a policy evaluation that involves multiple
# Gitaly calls that might not be cached. # Gitaly calls that might not be cached.
# #
can?(request.current_user, :read_build, request.project) can?(request.current_user, :read_build, project)
end
def project
request.try(:project) || options[:project]
end end
end end
...@@ -37,6 +37,10 @@ class EnvironmentStatusEntity < Grape::Entity ...@@ -37,6 +37,10 @@ class EnvironmentStatusEntity < Grape::Entity
es.deployment.try(:formatted_deployment_time) es.deployment.try(:formatted_deployment_time)
end end
expose :deployment, as: :details do |es, options|
DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build]))
end
expose :changes expose :changes
private private
......
# frozen_string_literal: true # frozen_string_literal: true
class MergeRequestPollWidgetEntity < IssuableEntity class MergeRequestPollWidgetEntity < Grape::Entity
include RequestAwareEntity
expose :auto_merge_strategy expose :auto_merge_strategy
expose :available_auto_merge_strategies do |merge_request| expose :available_auto_merge_strategies do |merge_request|
AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass
......
...@@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity ...@@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity
cancel_project_pipeline_path(pipeline.project, pipeline) cancel_project_pipeline_path(pipeline.project, pipeline)
end end
expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline|
pipeline.builds.failed
end
private private
alias_method :pipeline, :object alias_method :pipeline, :object
......
...@@ -36,3 +36,5 @@ module Issues ...@@ -36,3 +36,5 @@ module Issues
end end
end end
end end
Issues::BaseService.prepend_if_ee('EE::Issues::BaseService')
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
.col-lg-4 .col-lg-4
%h4.prepend-top-0= _('Notification events') %h4.prepend-top-0= _('Notification events')
%p %p
- notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank' - notification_link = link_to _('notification emails'), help_page_path('user/profile/notifications'), target: '_blank'
- paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe } - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
#{ paragraph.html_safe } #{ paragraph.html_safe }
.col-lg-8 .col-lg-8
......
---
title: Make internal projects poolable
merge_request: 19295
author: briankabiro
type: changed
---
title: Exposed deployment build manual actions for merge request page
merge_request: 20615
author:
type: changed
---
title: Update deploy instances color scheme
merge_request: 20890
author:
type: changed
---
title: Handle empty stacktrace and entries with no code
merge_request: 20458
author:
type: fixed
---
title: Fix opening Sentry error details in new tab
merge_request: 20611
author:
type: fixed
---
title: removes references of BoardService
merge_request: 20875
author: nuwe1
type: other
---
title: removes references of BoardService
merge_request: 20876
author: nuwe1
type: other
---
title: Resets aria-describedby on mouseleave
merge_request: 20092
author: carolcarvalhosa
type: fixed
---
title: Added Total/Frontend metrics to the performance bar
merge_request: 20725
author:
type: added
...@@ -16,6 +16,12 @@ It allows you to see (from left to right): ...@@ -16,6 +16,12 @@ It allows you to see (from left to right):
![Rugged profiling using the Performance Bar](img/performance_bar_rugged_calls.png) ![Rugged profiling using the Performance Bar](img/performance_bar_rugged_calls.png)
- time taken and number of Redis calls; click through for details of these calls - time taken and number of Redis calls; click through for details of these calls
![Redis profiling using the Performance Bar](img/performance_bar_redis_calls.png) ![Redis profiling using the Performance Bar](img/performance_bar_redis_calls.png)
- total load timings of the page; click through for details of these calls
- BE = Backend - Time that the actual base page took to load
- FCP = [First Contentful Paint](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint) - Time until something was visible to the user
- DOM = [DomContentLoaded](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/measure-crp) Event
- Number of Requests that the page loaded
![Frontend requests using the Performance Bar](img/performance_bar_frontend.png)
- a link to add a request's details to the performance bar; the request can be - a link to add a request's details to the performance bar; the request can be
added by its full URL (authenticated as the current user), or by the value of added by its full URL (authenticated as the current user), or by the value of
its `X-Request-Id` header its `X-Request-Id` header
......
...@@ -627,7 +627,8 @@ POST /projects/:id/issues ...@@ -627,7 +627,8 @@ POST /projects/:id/issues
| `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values.| | `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values.|
| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. | | `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. |
| `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. | | `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. |
| `epic_iid` **(ULTIMATE)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. | | `epic_id` **(ULTIMATE)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
| `epic_iid` **(ULTIMATE)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in 13.0](https://gitlab.com/gitlab-org/gitlab/issues/35157)) |
```bash ```bash
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug
...@@ -729,7 +730,8 @@ PUT /projects/:id/issues/:issue_iid ...@@ -729,7 +730,8 @@ PUT /projects/:id/issues/:issue_iid
| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | | `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` |
| `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. 0 | | `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. 0 |
| `discussion_locked` | boolean | no | Flag indicating if the issue's discussion is locked. If the discussion is locked only project members can add or edit comments. | | `discussion_locked` | boolean | no | Flag indicating if the issue's discussion is locked. If the discussion is locked only project members can add or edit comments. |
| `epic_iid` **(ULTIMATE)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. | | `epic_id` **(ULTIMATE)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
| `epic_iid` **(ULTIMATE)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in 13.0](https://gitlab.com/gitlab-org/gitlab/issues/35157)) |
```bash ```bash
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues/85?state_event=close curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues/85?state_event=close
......
...@@ -111,7 +111,7 @@ are as follows: ...@@ -111,7 +111,7 @@ are as follows:
contents of the pool repository are a Git clone of the source contents of the pool repository are a Git clone of the source
project repository. project repository.
- The occasion for creating a pool is when an existing eligible - The occasion for creating a pool is when an existing eligible
(public, hashed storage, non-forked) GitLab project gets forked and (non-private, hashed storage, non-forked) GitLab project gets forked and
this project does not belong to a pool repository yet. The fork this project does not belong to a pool repository yet. The fork
parent project becomes the source project of the new pool, and both parent project becomes the source project of the new pool, and both
the fork parent and the fork child project become members of the new the fork parent and the fork child project become members of the new
......
...@@ -14,7 +14,7 @@ With Deploy Boards you can gain more insight into deploys with benefits such as: ...@@ -14,7 +14,7 @@ With Deploy Boards you can gain more insight into deploys with benefits such as:
- Following a deploy from the start, not just when it's done - Following a deploy from the start, not just when it's done
- Watching the rollout of a build across multiple servers - Watching the rollout of a build across multiple servers
- Finer state detail (Waiting, Deploying, Finished, Unknown) - Finer state detail (Succeeded, Running, Failed, Pending, Unknown)
- See [Canary Deployments](canary_deployments.md) - See [Canary Deployments](canary_deployments.md)
Here's an example of a Deploy Board of the production environment. Here's an example of a Deploy Board of the production environment.
......
...@@ -15,3 +15,5 @@ module API ...@@ -15,3 +15,5 @@ module API
end end
end end
end end
API::Helpers::CommonHelpers.prepend_if_ee('EE::API::Helpers::CommonHelpers')
...@@ -5272,12 +5272,21 @@ msgstr "" ...@@ -5272,12 +5272,21 @@ msgstr ""
msgid "CycleAnalytics|All stages" msgid "CycleAnalytics|All stages"
msgstr "" msgstr ""
msgid "CycleAnalytics|Date"
msgstr ""
msgid "CycleAnalytics|Days to completion"
msgstr ""
msgid "CycleAnalytics|No stages selected" msgid "CycleAnalytics|No stages selected"
msgstr "" msgstr ""
msgid "CycleAnalytics|Stages" msgid "CycleAnalytics|Stages"
msgstr "" msgstr ""
msgid "CycleAnalytics|Total days to completion"
msgstr ""
msgid "CycleAnalytics|group dropdown filter" msgid "CycleAnalytics|group dropdown filter"
msgstr "" msgstr ""
...@@ -12269,6 +12278,9 @@ msgstr "" ...@@ -12269,6 +12278,9 @@ msgstr ""
msgid "PerformanceBar|Download" msgid "PerformanceBar|Download"
msgstr "" msgstr ""
msgid "PerformanceBar|Frontend resources"
msgstr ""
msgid "PerformanceBar|Gitaly calls" msgid "PerformanceBar|Gitaly calls"
msgstr "" msgstr ""
...@@ -17642,6 +17654,9 @@ msgstr "" ...@@ -17642,6 +17654,9 @@ msgstr ""
msgid "There was an error while fetching cycle analytics data." msgid "There was an error while fetching cycle analytics data."
msgstr "" msgstr ""
msgid "There was an error while fetching cycle analytics duration data."
msgstr ""
msgid "There was an error while fetching cycle analytics summary data." msgid "There was an error while fetching cycle analytics summary data."
msgstr "" msgstr ""
...@@ -20374,6 +20389,9 @@ msgstr "" ...@@ -20374,6 +20389,9 @@ msgstr ""
msgid "assign yourself" msgid "assign yourself"
msgstr "" msgstr ""
msgid "at line %{errorLine}%{errorColumn}"
msgstr ""
msgid "attach a new file" msgid "attach a new file"
msgstr "" msgstr ""
...@@ -20854,6 +20872,9 @@ msgstr "" ...@@ -20854,6 +20872,9 @@ msgstr ""
msgid "importing" msgid "importing"
msgstr "" msgstr ""
msgid "in %{errorFn} "
msgstr ""
msgid "in group %{link_to_group}" msgid "in group %{link_to_group}"
msgstr "" msgstr ""
......
...@@ -1226,9 +1226,9 @@ describe Projects::MergeRequestsController do ...@@ -1226,9 +1226,9 @@ describe Projects::MergeRequestsController do
environment2 = create(:environment, project: forked) environment2 = create(:environment, project: forked)
create(:deployment, :succeed, environment: environment2, sha: sha, ref: 'master', deployable: build) create(:deployment, :succeed, environment: environment2, sha: sha, ref: 'master', deployable: build)
# TODO address the last 5 queries # TODO address the last 3 queries
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/63952 (5 queries) # See https://gitlab.com/gitlab-org/gitlab-foss/issues/63952 (3 queries)
leeway = 5 leeway = 3
expect { get_ci_environments_status }.not_to exceed_all_query_limit(control_count + leeway) expect { get_ci_environments_status }.not_to exceed_all_query_limit(control_count + leeway)
end end
end end
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
GlLink, GlLink,
GlSearchBoxByClick, GlSearchBoxByClick,
} from '@gitlab/ui'; } from '@gitlab/ui';
import errorsList from './list_mock.json';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -18,11 +19,17 @@ describe('ErrorTrackingList', () => { ...@@ -18,11 +19,17 @@ describe('ErrorTrackingList', () => {
let wrapper; let wrapper;
let actions; let actions;
const findErrorListTable = () => wrapper.find('table');
const findErrorListRows = () => wrapper.findAll('tbody tr');
const findButton = () => wrapper.find(GlButton);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
function mountComponent({ function mountComponent({
errorTrackingEnabled = true, errorTrackingEnabled = true,
userCanEnableErrorTracking = true, userCanEnableErrorTracking = true,
stubs = { stubs = {
'gl-link': GlLink, 'gl-link': GlLink,
'gl-table': GlTable,
}, },
} = {}) { } = {}) {
wrapper = shallowMount(ErrorTrackingList, { wrapper = shallowMount(ErrorTrackingList, {
...@@ -47,7 +54,7 @@ describe('ErrorTrackingList', () => { ...@@ -47,7 +54,7 @@ describe('ErrorTrackingList', () => {
}; };
const state = { const state = {
errors: [], errors: errorsList,
loading: true, loading: true,
}; };
...@@ -75,61 +82,74 @@ describe('ErrorTrackingList', () => { ...@@ -75,61 +82,74 @@ describe('ErrorTrackingList', () => {
}); });
it('shows spinner', () => { it('shows spinner', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy(); expect(findLoadingIcon().exists()).toBe(true);
expect(wrapper.find(GlTable).exists()).toBeFalsy(); expect(findErrorListTable().exists()).toBe(false);
}); });
}); });
describe('results', () => { describe('results', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
mountComponent(); mountComponent();
}); });
it('shows table', () => { it('shows table', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); expect(findLoadingIcon().exists()).toBe(false);
expect(wrapper.find(GlTable).exists()).toBeTruthy(); expect(findErrorListTable().exists()).toBe(true);
expect(wrapper.find(GlButton).exists()).toBeTruthy(); expect(findButton().exists()).toBe(true);
});
it('shows list of errors in a table', () => {
expect(findErrorListRows().length).toEqual(store.state.list.errors.length);
});
it('each error in a list should have a link to the error page', () => {
const errorTitle = wrapper.findAll('tbody tr a');
errorTitle.wrappers.forEach((_, index) => {
expect(errorTitle.at(index).attributes('href')).toEqual(
expect.stringMatching(/error_tracking\/\d+\/details$/),
);
});
}); });
describe('filtering', () => { describe('filtering', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
it('shows search box', () => { it('shows search box', () => {
expect(wrapper.find(GlSearchBoxByClick).exists()).toBeTruthy(); expect(findSearchBox().exists()).toBe(true);
}); });
it('makes network request on submit', () => { it('makes network request on submit', () => {
expect(actions.startPolling).toHaveBeenCalledTimes(1); expect(actions.startPolling).toHaveBeenCalledTimes(1);
findSearchBox().vm.$emit('submit');
wrapper.find(GlSearchBoxByClick).vm.$emit('submit');
expect(actions.startPolling).toHaveBeenCalledTimes(2); expect(actions.startPolling).toHaveBeenCalledTimes(2);
}); });
}); });
}); });
describe('no results', () => { describe('no results', () => {
const findRefreshLink = () => wrapper.find('.js-try-again');
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
store.state.list.errors = [];
mountComponent(); mountComponent();
}); });
it('shows empty table', () => { it('shows empty table', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); expect(findLoadingIcon().exists()).toBe(false);
expect(wrapper.find(GlTable).exists()).toBeTruthy(); expect(findErrorListRows().length).toEqual(1);
expect(wrapper.find(GlButton).exists()).toBeTruthy(); expect(findButton().exists()).toBe(true);
}); });
it('shows a message prompting to refresh', () => { it('shows a message prompting to refresh', () => {
const refreshLink = wrapper.vm.$refs.empty.querySelector('a'); expect(findRefreshLink().text()).toContain('Check again');
expect(refreshLink.textContent.trim()).toContain('Check again');
}); });
it('restarts polling', () => { it('restarts polling', () => {
wrapper.find('.js-try-again').trigger('click'); findRefreshLink().trigger('click');
expect(actions.restartPolling).toHaveBeenCalled(); expect(actions.restartPolling).toHaveBeenCalled();
}); });
}); });
...@@ -140,10 +160,10 @@ describe('ErrorTrackingList', () => { ...@@ -140,10 +160,10 @@ describe('ErrorTrackingList', () => {
}); });
it('shows empty state', () => { it('shows empty state', () => {
expect(wrapper.find(GlEmptyState).exists()).toBeTruthy(); expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); expect(findLoadingIcon().exists()).toBe(false);
expect(wrapper.find(GlTable).exists()).toBeFalsy(); expect(findErrorListTable().exists()).toBe(false);
expect(wrapper.find(GlButton).exists()).toBeFalsy(); expect(findButton().exists()).toBe(false);
}); });
}); });
......
[
{
"id": "1",
"title": "PG::ConnectionBad: FATAL",
"type": "error",
"userCount": 0,
"count": "52",
"firstSeen": "2019-05-30T07:21:46Z",
"lastSeen": "2019-11-06T03:21:39Z"
},
{
"id": "2",
"title": "ActiveRecord::StatementInvalid",
"type": "error",
"userCount": 0,
"count": "12",
"firstSeen": "2019-10-19T03:53:56Z",
"lastSeen": "2019-11-05T03:51:54Z"
},
{
"id": "3",
"title": "Command has failed",
"type": "default",
"userCount": 0,
"count": "275",
"firstSeen": "2019-02-12T07:22:36Z",
"lastSeen": "2019-10-22T03:20:48Z"
}
]
\ No newline at end of file
...@@ -7,26 +7,23 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -7,26 +7,23 @@ import Icon from '~/vue_shared/components/icon.vue';
describe('Stacktrace Entry', () => { describe('Stacktrace Entry', () => {
let wrapper; let wrapper;
const lines = [
[22, ' def safe_thread(name, \u0026block)\n'],
[23, ' Thread.new do\n'],
[24, " Thread.current['sidekiq_label'] = name\n"],
[25, ' watchdog(name, \u0026block)\n'],
];
function mountComponent(props) { function mountComponent(props) {
wrapper = shallowMount(StackTraceEntry, { wrapper = shallowMount(StackTraceEntry, {
propsData: { propsData: {
filePath: 'sidekiq/util.rb', filePath: 'sidekiq/util.rb',
lines: [
[22, ' def safe_thread(name, \u0026block)\n'],
[23, ' Thread.new do\n'],
[24, " Thread.current['sidekiq_label'] = name\n"],
[25, ' watchdog(name, \u0026block)\n'],
],
errorLine: 24, errorLine: 24,
...props, ...props,
}, },
}); });
} }
beforeEach(() => {
mountComponent();
});
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
...@@ -34,16 +31,47 @@ describe('Stacktrace Entry', () => { ...@@ -34,16 +31,47 @@ describe('Stacktrace Entry', () => {
}); });
it('should render stacktrace entry collapsed', () => { it('should render stacktrace entry collapsed', () => {
mountComponent({ lines });
expect(wrapper.find(StackTraceEntry).exists()).toBe(true); expect(wrapper.find(StackTraceEntry).exists()).toBe(true);
expect(wrapper.find(ClipboardButton).exists()).toBe(true); expect(wrapper.find(ClipboardButton).exists()).toBe(true);
expect(wrapper.find(Icon).exists()).toBe(true); expect(wrapper.find(Icon).exists()).toBe(true);
expect(wrapper.find(FileIcon).exists()).toBe(true); expect(wrapper.find(FileIcon).exists()).toBe(true);
expect(wrapper.element.querySelectorAll('table').length).toBe(0); expect(wrapper.find('table').exists()).toBe(false);
}); });
it('should render stacktrace entry table expanded', () => { it('should render stacktrace entry table expanded', () => {
mountComponent({ expanded: true }); mountComponent({ expanded: true, lines });
expect(wrapper.element.querySelectorAll('tr.line_holder').length).toBe(4); expect(wrapper.find('table').exists()).toBe(true);
expect(wrapper.element.querySelectorAll('.line_content.old').length).toBe(1); expect(wrapper.findAll('tr.line_holder').length).toBe(4);
expect(wrapper.findAll('.line_content.old').length).toBe(1);
});
describe('no code block', () => {
const findFileHeaderContent = () => wrapper.find('.file-header-content').html();
it('should hide collapse icon and render error fn name and error line when there is no code block', () => {
const extraInfo = { errorLine: 34, errorFn: 'errorFn', errorColumn: 77 };
mountComponent({ expanded: false, lines: [], ...extraInfo });
expect(wrapper.find(Icon).exists()).toBe(false);
expect(findFileHeaderContent()).toContain(
`in ${extraInfo.errorFn} at line ${extraInfo.errorLine}:${extraInfo.errorColumn}`,
);
});
it('should render only lineNo:columnNO when there is no errorFn ', () => {
const extraInfo = { errorLine: 34, errorFn: null, errorColumn: 77 };
mountComponent({ expanded: false, lines: [], ...extraInfo });
expect(findFileHeaderContent()).not.toContain(`in ${extraInfo.errorFn}`);
expect(findFileHeaderContent()).toContain(`${extraInfo.errorLine}:${extraInfo.errorColumn}`);
});
it('should render only lineNo when there is no errorColumn ', () => {
const extraInfo = { errorLine: 34, errorFn: 'errorFn', errorColumn: null };
mountComponent({ expanded: false, lines: [], ...extraInfo });
expect(findFileHeaderContent()).toContain(
`in ${extraInfo.errorFn} at line ${extraInfo.errorLine}`,
);
expect(findFileHeaderContent()).not.toContain(`:${extraInfo.errorColumn}`);
});
}); });
}); });
import * as getters from '~/error_tracking/store/details/getters'; import * as getters from '~/error_tracking/store/details/getters';
describe('Sentry error details store getters', () => { describe('Sentry error details store getters', () => {
const state = {
stacktraceData: { stack_trace_entries: [1, 2] },
};
describe('stacktrace', () => { describe('stacktrace', () => {
it('should return empty stacktrace when there are no entries', () => {
const state = {
stacktraceData: { stack_trace_entries: null },
};
expect(getters.stacktrace(state)).toEqual([]);
});
it('should get stacktrace', () => { it('should get stacktrace', () => {
const state = {
stacktraceData: { stack_trace_entries: [1, 2] },
};
expect(getters.stacktrace(state)).toEqual([2, 1]); expect(getters.stacktrace(state)).toEqual([2, 1]);
}); });
}); });
......
...@@ -42,4 +42,21 @@ describe('PerformanceBarStore', () => { ...@@ -42,4 +42,21 @@ describe('PerformanceBarStore', () => {
expect(findUrl('id')).toEqual('html5-boilerplate'); expect(findUrl('id')).toEqual('html5-boilerplate');
}); });
}); });
describe('setRequestDetailsData', () => {
let store;
beforeEach(() => {
store = new PerformanceBarStore();
});
it('updates correctly specific details', () => {
store.addRequest('id', 'https://gitlab.com/');
store.setRequestDetailsData('id', 'test', {
calls: 123,
});
expect(store.findRequest('id').details.test.calls).toEqual(123);
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import Board from '~/boards/components/board'; import Board from '~/boards/components/board';
import List from '~/boards/models/list'; import List from '~/boards/models/list';
import { mockBoardService } from '../mock_data';
describe('Board component', () => { describe('Board component', () => {
let vm; let vm;
...@@ -35,13 +34,6 @@ describe('Board component', () => { ...@@ -35,13 +34,6 @@ describe('Board component', () => {
const setUpTests = (done, opts = {}) => { const setUpTests = (done, opts = {}) => {
loadFixtures('boards/show.html'); loadFixtures('boards/show.html');
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
createComponent(opts); createComponent(opts);
Vue.nextTick(done); Vue.nextTick(done);
...@@ -61,15 +53,6 @@ describe('Board component', () => { ...@@ -61,15 +53,6 @@ describe('Board component', () => {
}; };
describe('List', () => { describe('List', () => {
beforeEach(() => {
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
});
it('board is expandable when list type is closed', () => { it('board is expandable when list type is closed', () => {
expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true); expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
}); });
......
...@@ -7,13 +7,13 @@ import '~/boards/models/issue'; ...@@ -7,13 +7,13 @@ import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import { mockBoardService } from './mock_data'; import { setMockEndpoints } from './mock_data';
describe('Issue model', () => { describe('Issue model', () => {
let issue; let issue;
beforeEach(() => { beforeEach(() => {
gl.boardService = mockBoardService(); setMockEndpoints();
boardsStore.create(); boardsStore.create();
issue = new ListIssue({ issue = new ListIssue({
......
...@@ -38,6 +38,7 @@ describe('User Popovers', () => { ...@@ -38,6 +38,7 @@ describe('User Popovers', () => {
const shownPopover = document.querySelector('.popover'); const shownPopover = document.querySelector('.popover');
expect(shownPopover).not.toBeNull(); expect(shownPopover).not.toBeNull();
expect(targetLink.getAttribute('aria-describedby')).not.toBeNull();
expect(shownPopover.innerHTML).toContain(dummyUser.name); expect(shownPopover.innerHTML).toContain(dummyUser.name);
expect(UsersCache.retrieveById).toHaveBeenCalledWith(userId.toString()); expect(UsersCache.retrieveById).toHaveBeenCalledWith(userId.toString());
...@@ -47,6 +48,7 @@ describe('User Popovers', () => { ...@@ -47,6 +48,7 @@ describe('User Popovers', () => {
setTimeout(() => { setTimeout(() => {
// After Mouse leave it should be hidden now // After Mouse leave it should be hidden now
expect(document.querySelector('.popover')).toBeNull(); expect(document.querySelector('.popover')).toBeNull();
expect(targetLink.getAttribute('aria-describedby')).toBeNull();
done(); done();
}); });
}, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible }, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible
......
...@@ -442,4 +442,36 @@ describe Deployment do ...@@ -442,4 +442,36 @@ describe Deployment do
expect(deploy2.previous_environment_deployment).to be_nil expect(deploy2.previous_environment_deployment).to be_nil
end end
end end
describe '#playable_build' do
subject { deployment.playable_build }
context 'when there is a deployable build' do
let(:deployment) { create(:deployment, deployable: build) }
context 'when the deployable build is playable' do
let(:build) { create(:ci_build, :playable) }
it 'returns that build' do
is_expected.to eq(build)
end
end
context 'when the deployable build is not playable' do
let(:build) { create(:ci_build) }
it 'returns nil' do
is_expected.to be_nil
end
end
end
context 'when there is no deployable build' do
let(:deployment) { create(:deployment) }
it 'returns nil' do
is_expected.to be_nil
end
end
end
end end
...@@ -5088,12 +5088,24 @@ describe Project do ...@@ -5088,12 +5088,24 @@ describe Project do
it { is_expected.not_to be_git_objects_poolable } it { is_expected.not_to be_git_objects_poolable }
end end
context 'when the project is not public' do context 'when the project is private' do
let(:project) { create(:project, :private) } let(:project) { create(:project, :private) }
it { is_expected.not_to be_git_objects_poolable } it { is_expected.not_to be_git_objects_poolable }
end end
context 'when the project is public' do
let(:project) { create(:project, :repository, :public) }
it { is_expected.to be_git_objects_poolable }
end
context 'when the project is internal' do
let(:project) { create(:project, :repository, :internal) }
it { is_expected.to be_git_objects_poolable }
end
context 'when objects are poolable' do context 'when objects are poolable' do
let(:project) { create(:project, :repository, :public) } let(:project) { create(:project, :repository, :public) }
......
...@@ -107,6 +107,36 @@ describe DeploymentEntity do ...@@ -107,6 +107,36 @@ describe DeploymentEntity do
end end
end end
describe 'playable_build' do
let_it_be(:project) { create(:project, :repository) }
context 'when the deployment has a playable deployable' do
context 'when this build is ready to be played' do
let(:build) { create(:ci_build, :playable, :scheduled, pipeline: pipeline) }
it 'exposes only the play_path' do
expect(subject[:playable_build].keys).to contain_exactly(:play_path)
end
end
context 'when this build has failed' do
let(:build) { create(:ci_build, :playable, :failed, pipeline: pipeline) }
it 'exposes the play_path and the retry_path' do
expect(subject[:playable_build].keys).to contain_exactly(:play_path, :retry_path)
end
end
end
context 'when the deployment does not have a playable deployable' do
let(:build) { create(:ci_build) }
it 'is not exposed' do
expect(subject[:playable_build]).to be_nil
end
end
end
context 'when deployment details serialization was disabled' do context 'when deployment details serialization was disabled' do
include Gitlab::Routing include Gitlab::Routing
......
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
describe EnvironmentStatusEntity do describe EnvironmentStatusEntity do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:request) { double('request') } let(:request) { double('request', project: project) }
let(:deployment) { create(:deployment, :succeed, :review_app) } let(:deployment) { create(:deployment, :succeed, :review_app) }
let(:environment) { deployment.environment } let(:environment) { deployment.environment }
...@@ -28,6 +28,7 @@ describe EnvironmentStatusEntity do ...@@ -28,6 +28,7 @@ describe EnvironmentStatusEntity do
it { is_expected.to include(:external_url_formatted) } it { is_expected.to include(:external_url_formatted) }
it { is_expected.to include(:deployed_at) } it { is_expected.to include(:deployed_at) }
it { is_expected.to include(:deployed_at_formatted) } it { is_expected.to include(:deployed_at_formatted) }
it { is_expected.to include(:details) }
it { is_expected.to include(:changes) } it { is_expected.to include(:changes) }
it { is_expected.to include(:status) } it { is_expected.to include(:status) }
......
...@@ -218,5 +218,28 @@ describe PipelineEntity do ...@@ -218,5 +218,28 @@ describe PipelineEntity do
expect(subject[:merge_request_event_type]).to be_present expect(subject[:merge_request_event_type]).to be_present
end end
end end
context 'when pipeline has failed builds' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let_it_be(:build) { create(:ci_build, :success, pipeline: pipeline) }
let_it_be(:failed_1) { create(:ci_build, :failed, pipeline: pipeline) }
let_it_be(:failed_2) { create(:ci_build, :failed, pipeline: pipeline) }
context 'when the user can retry the pipeline' do
it 'exposes these failed builds' do
allow(entity).to receive(:can_retry?).and_return(true)
expect(subject[:failed_builds].map { |b| b[:id] }).to contain_exactly(failed_1.id, failed_2.id)
end
end
context 'when the user cannot retry the pipeline' do
it 'is nil' do
allow(entity).to receive(:can_retry?).and_return(false)
expect(subject[:failed_builds]).to be_nil
end
end
end
end end
end end
...@@ -58,6 +58,65 @@ describe Projects::GitDeduplicationService do ...@@ -58,6 +58,65 @@ describe Projects::GitDeduplicationService do
service.execute service.execute
end end
context 'when visibility level of the project' do
before do
allow(pool.source_project).to receive(:repository_access_level).and_return(ProjectFeature::ENABLED)
end
context 'is private' do
it 'does not call fetch' do
allow(pool.source_project).to receive(:visibility_level).and_return(Gitlab::VisibilityLevel::PRIVATE)
expect(pool.object_pool).not_to receive(:fetch)
service.execute
end
end
context 'is public' do
it 'calls fetch' do
allow(pool.source_project).to receive(:visibility_level).and_return(Gitlab::VisibilityLevel::PUBLIC)
expect(pool.object_pool).to receive(:fetch)
service.execute
end
end
context 'is internal' do
it 'calls fetch' do
allow(pool.source_project).to receive(:visibility_level).and_return(Gitlab::VisibilityLevel::INTERNAL)
expect(pool.object_pool).to receive(:fetch)
service.execute
end
end
end
context 'when the repository access level' do
before do
allow(pool.source_project).to receive(:visibility_level).and_return(Gitlab::VisibilityLevel::PUBLIC)
end
context 'is private' do
it 'does not call fetch' do
allow(pool.source_project).to receive(:repository_access_level).and_return(ProjectFeature::PRIVATE)
expect(pool.object_pool).not_to receive(:fetch)
service.execute
end
end
context 'is greater than private' do
it 'calls fetch' do
allow(pool.source_project).to receive(:repository_access_level).and_return(ProjectFeature::PUBLIC)
expect(pool.object_pool).to receive(:fetch)
service.execute
end
end
end
end end
it 'links the repository to the object pool' do it 'links the repository to the object pool' do
......
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