Commit d978f9ab authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '229685-update-leave-group-modal' into 'master'

Resolve "Migrate deprecatedModal to GlModal in app/assets/javascripts/groups/components/app.vue"

See merge request gitlab-org/gitlab!41817
parents c69ec009 80a3ab33
......@@ -676,10 +676,14 @@
##################
.releases:rules:canonical-dot-com-gitlab-stable-branch-only:
rules:
- if: '$CI_COMMIT_MESSAGE =~ /\[merge-train skip\]/'
when: never
- if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_PATH == "gitlab-org/gitlab" && $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable-ee$/'
.releases:rules:canonical-dot-com-security-gitlab-stable-branch-only:
rules:
- if: '$CI_COMMIT_MESSAGE =~ /\[merge-train skip\]/'
when: never
- if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_PATH == "gitlab-org/security/gitlab" && $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable-ee$/'
#################
......
......@@ -183,19 +183,6 @@ RSpec/ContextWording:
RSpec/ExpectChange:
Enabled: false
# Offense count: 47
RSpec/ExpectGitlabTracking:
Exclude:
- 'spec/controllers/projects/registry/repositories_controller_spec.rb'
- 'spec/controllers/projects/registry/tags_controller_spec.rb'
- 'spec/controllers/projects/settings/operations_controller_spec.rb'
- 'spec/lib/api/helpers_spec.rb'
- 'spec/requests/api/project_container_repositories_spec.rb'
- 'spec/support/shared_examples/controllers/trackable_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/discussions_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/packages_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/tracking_shared_examples.rb'
# Offense count: 751
RSpec/ExpectInHook:
Enabled: false
......
2b90359f63697b1e052f1385c79daffb1768a311
1c4fdefdaf88730c025b5c7ba7ddc42c268043d4
<script>
import { ApolloMutation } from 'vue-apollo';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
......
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import DesignNavigation from './design_navigation.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
......
import { propertyOf } from 'lodash';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
......
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
......
<script>
import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__, sprintf } from '~/locale';
import { getFilename } from '~/lib/utils/file_upload';
......@@ -12,8 +14,6 @@ import DesignVersionDropdown from '../components/upload/design_version_dropdown.
import DesignDropzone from '../components/upload/design_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import {
UPLOAD_DESIGN_ERROR,
......
......@@ -16,6 +16,11 @@ export default {
</script>
<template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite v-model="value" file-name="*.yml" :editor-options="{ readOnly: true }" />
<editor-lite
v-model="value"
file-name="*.yml"
:editor-options="{ readOnly: true }"
@editor-ready="$emit('editor-ready')"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import TextEditor from './components/text_editor.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import getBlobContent from './graphql/queries/blob_content.graphql';
......@@ -10,7 +11,10 @@ export default {
components: {
GlLoadingIcon,
GlAlert,
GlTabs,
GlTab,
TextEditor,
PipelineGraph,
},
props: {
projectPath: {
......@@ -31,6 +35,7 @@ export default {
return {
error: null,
content: '',
editorIsReady: false,
};
},
apollo: {
......@@ -66,10 +71,16 @@ export default {
const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError;
return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
},
pipelineData() {
// Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141
return {};
},
},
i18n: {
unknownError: __('Unknown Error'),
errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
},
};
</script>
......@@ -79,7 +90,19 @@ export default {
<gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
<div class="gl-mt-4">
<gl-loading-icon v-if="loading" size="lg" />
<text-editor v-else v-model="content" />
<div v-else class="file-editor">
<gl-tabs>
<!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed -->
<text-editor v-model="content" @editor-ready="editorIsReady = true" />
</gl-tab>
<gl-tab :title="$options.i18n.tabGraph">
<pipeline-graph :pipeline-data="pipelineData" />
</gl-tab>
</gl-tabs>
</div>
</div>
</div>
</template>
......@@ -16,12 +16,7 @@ import { performanceMarkAndMeasure } from '~/performance_utils';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets';
import {
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_VISIBILITY_PRIVATE,
} from '../constants';
import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { markBlobPerformance } from '../utils/blob';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
......@@ -41,15 +36,7 @@ export default {
GlLoadingIcon,
},
mixins: [getSnippetMixin],
apollo: {
defaultVisibility: {
query: defaultVisibilityQuery,
manual: true,
result({ data: { selectedLevel } }) {
this.selectedLevelDefault = selectedLevel;
},
},
},
inject: ['selectedLevel'],
props: {
markdownPreviewPath: {
type: String,
......@@ -73,9 +60,12 @@ export default {
data() {
return {
isUpdating: false,
newSnippet: false,
actions: [],
selectedLevelDefault: SNIPPET_VISIBILITY_PRIVATE,
snippet: {
title: '',
description: '',
visibilityLevel: this.selectedLevel,
},
};
},
computed: {
......@@ -112,13 +102,6 @@ export default {
}
return this.snippet.webUrl;
},
newSnippetSchema() {
return {
title: '',
description: '',
visibilityLevel: this.selectedLevelDefault,
};
},
},
beforeCreate() {
performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START });
......@@ -145,20 +128,6 @@ export default {
Flash(sprintf(defaultErrorMsg, { err }));
this.isUpdating = false;
},
onNewSnippetFetched() {
this.newSnippet = true;
this.snippet = this.newSnippetSchema;
},
onExistingSnippetFetched() {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
}
},
getAttachedFiles() {
const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]'));
return fileInputs.map(node => node.value);
......@@ -209,7 +178,7 @@ export default {
</script>
<template>
<form
class="snippet-form js-requires-input js-quick-submit common-note-form"
class="snippet-form js-quick-submit common-note-form"
:data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
data-testid="snippet-edit-form"
@submit.prevent="handleFormSubmit"
......
<script>
import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
import { defaultSnippetVisibilityLevels } from '../utils/blob';
import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants';
......@@ -12,16 +11,7 @@ export default {
GlFormRadioGroup,
GlLink,
},
apollo: {
defaultVisibility: {
query: defaultVisibilityQuery,
manual: true,
result({ data: { visibilityLevels, multipleLevelsRestricted } }) {
this.visibilityLevels = defaultSnippetVisibilityLevels(visibilityLevels);
this.multipleLevelsRestricted = multipleLevelsRestricted;
},
},
},
inject: ['visibilityLevels', 'multipleLevelsRestricted'],
props: {
helpLink: {
type: String,
......@@ -38,11 +28,10 @@ export default {
required: true,
},
},
data() {
return {
visibilityLevels: [],
multipleLevelsRestricted: false,
};
computed: {
defaultVisibilityLevels() {
return defaultSnippetVisibilityLevels(this.visibilityLevels);
},
},
SNIPPET_LEVELS_DISABLED,
SNIPPET_LEVELS_RESTRICTED,
......@@ -59,7 +48,7 @@ export default {
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
<gl-form-radio-group :checked="value" stacked v-bind="$attrs" v-on="$listeners">
<gl-form-radio
v-for="option in visibilityLevels"
v-for="option in defaultVisibilityLevels"
:key="option.value"
:value="option.value"
class="mb-3"
......@@ -78,7 +67,9 @@ export default {
</gl-form-group>
<div class="text-muted" data-testid="restricted-levels-info">
<template v-if="!visibilityLevels.length">{{ $options.SNIPPET_LEVELS_DISABLED }}</template>
<template v-if="!defaultVisibilityLevels.length">{{
$options.SNIPPET_LEVELS_DISABLED
}}</template>
<template v-else-if="multipleLevelsRestricted">{{
$options.SNIPPET_LEVELS_RESTRICTED
}}</template>
......
......@@ -24,17 +24,14 @@ export default function appFactory(el, Component) {
...restDataset
} = el.dataset;
apolloProvider.clients.defaultClient.cache.writeData({
data: {
return new Vue({
el,
apolloProvider,
provide: {
visibilityLevels: JSON.parse(visibilityLevels),
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
},
});
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(Component, {
props: {
......
......@@ -21,9 +21,9 @@ export const getSnippetMixin = {
},
result(res) {
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
},
skip() {
return this.newSnippet;
},
},
},
......@@ -36,7 +36,7 @@ export const getSnippetMixin = {
data() {
return {
snippet: {},
newSnippet: false,
newSnippet: !this.snippetGid,
blobs: blobsDefault,
};
},
......
query defaultSnippetVisibility {
visibilityLevels @client
selectedLevel @client
multipleLevelsRestricted @client
}
......@@ -75,7 +75,7 @@ module Projects
[
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
:auto_cancel_pending_pipelines, :ci_config_path, :auto_rollback_enabled,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled]
].tap do |list|
......
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
__typename
id
issue(iid: $iid) {
__typename
userPermissions {
__typename
createDesign
}
}
......
#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
__typename
id
issue(iid: $iid) {
__typename
designCollection {
__typename
copyState
designs(atVersion: $atVersion) {
__typename
nodes {
...DesignListItem
__typename
id
event
filename
notesCount
image
imageV432x230
currentUserTodos(state: pending) {
__typename
nodes {
__typename
id
}
}
}
}
versions {
__typename
nodes {
...VersionListItem
__typename
id
sha
}
}
}
......
......@@ -13,10 +13,20 @@ module Resolvers
required: true,
description: 'The type of measurement/statistics to retrieve'
def resolve(identifier:)
argument :recorded_after, Types::TimeType,
required: false,
description: 'Measurement recorded after this date'
argument :recorded_before, Types::TimeType,
required: false,
description: 'Measurement recorded before this date'
def resolve(identifier:, recorded_before: nil, recorded_after: nil)
authorize!
::Analytics::InstanceStatistics::Measurement
.recorded_after(recorded_after)
.recorded_before(recorded_before)
.with_identifier(identifier)
.order_by_latest
end
......
......@@ -15,7 +15,9 @@ module Resolvers
def preloads
{
jobs: [:statuses]
jobs: [:statuses],
upstream: [:triggered_by_pipeline],
downstream: [:triggered_pipelines]
}
end
end
......
......@@ -56,12 +56,24 @@ module Types
description: 'Specifies if a pipeline can be canceled',
method: :cancelable?,
null: false
field :jobs,
::Types::Ci::JobType.connection_type,
null: true,
description: 'Jobs belonging to the pipeline',
method: :statuses
field :source_job, Types::Ci::JobType, null: true,
description: 'Job where pipeline was triggered from'
field :downstream, Types::Ci::PipelineType.connection_type, null: true,
description: 'Pipelines this pipeline will trigger',
method: :triggered_pipelines_with_preloads
field :upstream, Types::Ci::PipelineType, null: true,
description: 'Pipeline that triggered the pipeline',
method: :triggered_by_pipeline
field :path, GraphQL::STRING_TYPE, null: true,
description: "Relative path to the pipeline's page",
resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_pipeline_path(obj.project, obj) }
field :project, Types::ProjectType, null: true,
description: 'Project the pipeline belongs to'
end
end
end
......
......@@ -36,6 +36,8 @@ module Analytics
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
scope :recorded_after, -> (date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? }
scope :recorded_before, -> (date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? }
def self.measurement_identifier_values
identifiers.values
......
......@@ -6,6 +6,8 @@
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
- add_page_startup_graphql_call('design_management/get_design_list', { fullPath: @project.full_path, iid: @issue.iid.to_s, atVersion: nil })
- add_page_startup_graphql_call('design_management/design_permissions', { fullPath: @project.full_path, iid: @issue.iid.to_s })
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
......
......@@ -75,6 +75,8 @@
.settings-content
= render 'projects/registry/settings/index'
= render_if_exists 'projects/settings/ci_cd/auto_rollback', expanded: expanded
- if can?(current_user, :create_freeze_period, @project)
%section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) }
.settings-header
......
---
title: Resolve Implement GraphQL Startup.js for Design Management app
merge_request: 46660
author:
type: other
---
title: Add filtering by recorded date to instance statistics measurements GraphQL API
merge_request: 46344
author:
type: changed
---
title: Do not query snippet infromation on the new snippet's creation
merge_request: 46355
author:
type: fixed
---
title: Add auto_rollback_enabled column to project_ci_cd_settings table
merge_request: 45816
author:
type: other
---
title: 'GraphQL: Adds downstream, upstream, source job, path, and project to PipelineType'
merge_request: 45212
author:
type: added
---
name: cd_auto_rollback
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45816
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/35404
type: development
group: group::progressive delivery
default_enabled: false
# frozen_string_literal: true
class AddAutoRollbackSetting < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
add_column :project_ci_cd_settings, :auto_rollback_enabled, :boolean, default: false, null: false
end
end
def down
with_lock_retries do
remove_column :project_ci_cd_settings, :auto_rollback_enabled
end
end
end
3f24bfc2d18ffa5f171e027d4e7aaf9994b255e5806e2de57fd36b4a193db122
\ No newline at end of file
......@@ -14866,7 +14866,8 @@ CREATE TABLE project_ci_cd_settings (
merge_pipelines_enabled boolean,
default_git_depth integer,
forward_deployment_enabled boolean,
merge_trains_enabled boolean DEFAULT false
merge_trains_enabled boolean DEFAULT false,
auto_rollback_enabled boolean DEFAULT false NOT NULL
);
CREATE SEQUENCE project_ci_cd_settings_id_seq
......
......@@ -13797,6 +13797,31 @@ type Pipeline {
"""
detailedStatus: DetailedStatus!
"""
Pipelines this pipeline will trigger
"""
downstream(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): PipelineConnection
"""
Duration of the pipeline in seconds
"""
......@@ -13847,6 +13872,16 @@ type Pipeline {
securityReportTypes: [SecurityReportTypeEnum!]
): CiJobConnection
"""
Relative path to the pipeline's page
"""
path: String
"""
Project the pipeline belongs to
"""
project: Project
"""
Specifies if a pipeline can be retried
"""
......@@ -13862,6 +13897,11 @@ type Pipeline {
"""
sha: String!
"""
Job where pipeline was triggered from
"""
sourceJob: CiJob
"""
Stages of the pipeline
"""
......@@ -13903,6 +13943,11 @@ type Pipeline {
"""
updatedAt: Time!
"""
Pipeline that triggered the pipeline
"""
upstream: Pipeline
"""
Pipeline user
"""
......@@ -16452,6 +16497,16 @@ type Query {
Returns the last _n_ elements from the list.
"""
last: Int
"""
Measurement recorded after this date
"""
recordedAfter: Time
"""
Measurement recorded before this date
"""
recordedBefore: Time
): InstanceStatisticsMeasurementConnection
"""
......
......@@ -40697,6 +40697,59 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "downstream",
"description": "Pipelines this pipeline will trigger",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "PipelineConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "duration",
"description": "Duration of the pipeline in seconds",
......@@ -40832,6 +40885,34 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "path",
"description": "Relative path to the pipeline's page",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "project",
"description": "Project the pipeline belongs to",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Project",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "retryable",
"description": "Specifies if a pipeline can be retried",
......@@ -40882,6 +40963,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sourceJob",
"description": "Job where pipeline was triggered from",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "CiJob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "stages",
"description": "Stages of the pipeline",
......@@ -40985,6 +41080,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "upstream",
"description": "Pipeline that triggered the pipeline",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Pipeline",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "user",
"description": "Pipeline user",
......@@ -47741,6 +47850,26 @@
},
"defaultValue": null
},
{
"name": "recordedAfter",
"description": "Measurement recorded after this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "recordedBefore",
"description": "Measurement recorded before this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -2009,12 +2009,16 @@ Information about pagination in a connection..
| `finishedAt` | Time | Timestamp of the pipeline's completion |
| `id` | ID! | ID of the pipeline |
| `iid` | String! | Internal ID of the pipeline |
| `path` | String | Relative path to the pipeline's page |
| `project` | Project | Project the pipeline belongs to |
| `retryable` | Boolean! | Specifies if a pipeline can be retried |
| `securityReportSummary` | SecurityReportSummary | Vulnerability and scanned resource counts for each security scanner of the pipeline |
| `sha` | String! | SHA of the pipeline's commit |
| `sourceJob` | CiJob | Job where pipeline was triggered from |
| `startedAt` | Time | Timestamp when the pipeline was started |
| `status` | PipelineStatusEnum! | Status of the pipeline (CREATED, WAITING_FOR_RESOURCE, PREPARING, PENDING, RUNNING, FAILED, SUCCESS, CANCELED, SKIPPED, MANUAL, SCHEDULED) |
| `updatedAt` | Time! | Timestamp of the pipeline's last activity |
| `upstream` | Pipeline | Pipeline that triggered the pipeline |
| `user` | User | Pipeline user |
| `userPermissions` | PipelinePermissions! | Permissions for the current user on the resource |
......
......@@ -520,8 +520,7 @@ on the default branch. However, there are cases where you might want to use a
staging environment, and deploy to production manually. For this scenario, the
`STAGING_ENABLED` environment variable was introduced.
If you define `STAGING_ENABLED`, such as setting `STAGING_ENABLED` to
`1` as a CI/CD variable, then GitLab automatically deploys the application
If you define `STAGING_ENABLED` with a non-empty value, then GitLab automatically deploys the application
to a `staging` environment, and creates a `production_manual` job for
you when you're ready to manually deploy to production.
......@@ -532,8 +531,7 @@ you when you're ready to manually deploy to production.
You can use a [canary environment](../../user/project/canary_deployments.md) before
deploying any changes to production.
If you define `CANARY_ENABLED` in your project, such as setting `CANARY_ENABLED` to
`1` as a CI/CD variable, then two manual jobs are created:
If you define `CANARY_ENABLED` with a non-empty value, then two manual jobs are created:
- `canary` - Deploys the application to the canary environment.
- `production_manual` - Manually deploys the application to production.
......
......@@ -91,6 +91,10 @@ module EE
attrs += compliance_framework_params
if ::Gitlab::Ci::Features.auto_rollback_available?(project)
attrs << :auto_rollback_enabled
end
if allow_mirror_params?
attrs + mirror_params
else
......
......@@ -187,6 +187,7 @@ module EE
delegate :merge_pipelines_enabled, :merge_pipelines_enabled=, :merge_pipelines_enabled?, :merge_pipelines_were_disabled?, to: :ci_cd_settings
delegate :merge_trains_enabled?, to: :ci_cd_settings
delegate :auto_rollback_enabled, :auto_rollback_enabled=, :auto_rollback_enabled?, to: :ci_cd_settings
delegate :closest_gitlab_subscription, to: :namespace
validates :repository_size_limit,
......
......@@ -22,5 +22,9 @@ module EE
def merge_pipelines_were_disabled?
saved_change_to_attribute?(:merge_pipelines_enabled, from: true, to: false)
end
def auto_rollback_enabled?
super && ::Gitlab::Ci::Features.auto_rollback_available?(project)
end
end
end
......@@ -125,6 +125,7 @@ class License < ApplicationRecord
EEP_FEATURES.freeze
EEU_FEATURES = EEP_FEATURES + %i[
auto_rollback
container_scanning
coverage_fuzzing
credentials_inventory
......
- if Gitlab::Ci::Features.auto_rollback_available?(@project)
%section.settings.no-animate#auto-rollback-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= _("Automatic deployment rollbacks")
%button.gl-button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('AutoRollback|Automatically roll back to the last successful deployment when a critical problem is detected.')
.settings-content
.row
.col-lg-12
= form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'auto-rollback-settings') do |f|
= form_errors(@project)
%fieldset.builds-feature
.gl-form-group.form-group
.card
.card-body
.gl-form.form-check
= f.check_box :auto_rollback_enabled, class: 'gl-form-checkbox form-check-input'
= f.label :auto_rollback_enabled, class: 'form-check-label col-form-label' do
= s_('AutoRollback|Enable automatic rollbacks')
%small.form-text.text-gl-muted
= s_('AutoRollback|Automatic rollbacks start when a critical alert is triggered. If the last successful deployment fails to roll back automatically, it can still be done manually.')
-# This will be added once the documentation page has been created
-# = link_to _('More information'), help_page_path('topics/auto_rollback/index.md'), target: '_blank'
= f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-5", data: { qa_selector: 'save_changes_button' }
......@@ -298,7 +298,7 @@ RSpec.describe ProjectsController do
end
end
context 'when lisence is not sufficient' do
context 'when license is not sufficient' do
before do
stub_licensed_features(merge_pipelines: false)
end
......@@ -311,6 +311,48 @@ RSpec.describe ProjectsController do
end
end
context 'when auto_rollback_enabled param is specified' do
let(:params) { { auto_rollback_enabled: true } }
let(:request) do
put :update, params: { namespace_id: project.namespace, id: project, project: params }
end
before do
stub_licensed_features(auto_rollback: true)
end
it 'updates the attribute' do
request
expect(project.reload.auto_rollback_enabled).to be_truthy
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(auto_rollback: false)
end
it 'does not update the attribute' do
request
expect(project.reload.auto_rollback_enabled).to be_falsy
end
end
context 'when license is not sufficient' do
before do
stub_licensed_features(auto_rollback: false)
end
it 'does not update the attribute' do
request
expect(project.reload.auto_rollback_enabled).to be_falsy
end
end
end
context 'repository mirrors' do
let(:params) do
{
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Automatic Deployment Rollbacks' do
let(:project) { create(:project) }
let(:user) { create(:user) }
before do
sign_in(user)
end
context 'when the user is not authorised' do
it 'renders 404 page' do
visit project_settings_ci_cd_path(project)
expect(page).to have_gitlab_http_status(:not_found)
end
end
shared_examples 'the auto rollback feature is not available' do
it 'does not render the Automatic Deployment Rollbacks checkbox' do
project.add_maintainer(user)
visit project_settings_ci_cd_path(project)
expect(page).to have_gitlab_http_status(:ok)
expect(page).not_to have_selector('#auto-rollback-settings')
end
end
context 'when cd_auto_rollback and auto_rollback are disabled' do
before do
stub_feature_flags(cd_auto_rollback: false)
stub_licensed_features(auto_rollback: false)
end
it_behaves_like 'the auto rollback feature is not available'
end
context 'when cd_auto_rollback is disabled and auto_rollback is enabled' do
before do
stub_licensed_features(auto_rollback: true)
stub_feature_flags(cd_auto_rollback: false)
end
it_behaves_like 'the auto rollback feature is not available'
end
context 'when cd_auto_rollback is enabled and auto_rollback is disabled' do
before do
stub_feature_flags(cd_auto_rollback: true)
stub_licensed_features(auto_rollback: false)
end
it_behaves_like 'the auto rollback feature is not available'
end
context 'when cd_auto_rollback and auto_rollback are enabled' do
before do
stub_licensed_features(auto_rollback: true)
project.add_maintainer(user)
visit project_settings_ci_cd_path(project)
end
it 'checks the auto rollback checkbox when the checkbox is checked' do
expect(page.find('#project_auto_rollback_enabled')).not_to be_checked
within('#auto-rollback-settings') do
check('project_auto_rollback_enabled')
click_on('Save changes')
end
visit project_settings_ci_cd_path(project) # Reload from database
expect(page.find('#project_auto_rollback_enabled')).to be_checked
end
end
context 'when the checkbox is checked' do
before do
stub_licensed_features(auto_rollback: true)
project.add_maintainer(user)
project.update!(auto_rollback_enabled: true)
visit project_settings_ci_cd_path(project)
end
it 'unchecks the auto rollback checkbox' do
expect(page.find('#project_auto_rollback_enabled')).to be_checked
within('#auto-rollback-settings') do
uncheck('project_auto_rollback_enabled')
click_on('Save changes')
end
visit project_settings_ci_cd_path(project) # Reload from database
expect(page.find('#project_auto_rollback_enabled')).not_to be_checked
end
end
end
......@@ -69,6 +69,35 @@ RSpec.describe ProjectCiCdSetting do
end
end
describe '#auto_rollback_enabled?' do
using RSpec::Parameterized::TableSyntax
let(:project) { create(:project) }
where(:license_feature, :feature_flag, :actual_setting) do
true | true | true
false | true | true
true | false | true
false | false | true
true | true | false
false | true | false
true | false | false
false | false | false
end
with_them do
before do
stub_licensed_features(auto_rollback: license_feature)
stub_feature_flags(cd_auto_rollback: feature_flag)
project.auto_rollback_enabled = actual_setting
end
it 'is only enabled if set and both the license and the feature flag allows' do
expect(project.auto_rollback_enabled?).to be(actual_setting && license_feature && feature_flag)
end
end
end
describe '#merge_pipelines_were_disabled?' do
subject { project.merge_pipelines_were_disabled? }
......
......@@ -32,7 +32,7 @@ module Gitlab
end
# NOTE: The feature flag `disallow_to_create_merge_request_pipelines_in_target_project`
# is a safe switch to disable the feature for a parituclar project when something went wrong,
# is a safe switch to disable the feature for a particular project when something went wrong,
# therefore it's not supposed to be enabled by default.
def self.disallow_to_create_merge_request_pipelines_in_target_project?(target_project)
::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, target_project)
......@@ -63,6 +63,10 @@ module Gitlab
::Feature.enabled?(:ci_manual_bridges, project, default_enabled: true)
end
def self.auto_rollback_available?(project)
::Feature.enabled?(:cd_auto_rollback, project) && project&.feature_available?(:auto_rollback)
end
def self.seed_block_run_before_workflow_rules_enabled?(project)
::Feature.enabled?(:ci_seed_block_run_before_workflow_rules, project, default_enabled: false)
end
......
......@@ -3975,6 +3975,15 @@ msgstr ""
msgid "AutoRemediation|Introducing GitLab auto-fix"
msgstr ""
msgid "AutoRollback|Automatic rollbacks start when a critical alert is triggered. If the last successful deployment fails to roll back automatically, it can still be done manually."
msgstr ""
msgid "AutoRollback|Automatically roll back to the last successful deployment when a critical problem is detected."
msgstr ""
msgid "AutoRollback|Enable automatic rollbacks"
msgstr ""
msgid "Autocomplete"
msgstr ""
......@@ -3993,6 +4002,9 @@ msgstr ""
msgid "Automatic certificate management using Let's Encrypt"
msgstr ""
msgid "Automatic deployment rollbacks"
msgstr ""
msgid "Automatically close incident issues when the associated Prometheus alert resolves."
msgstr ""
......@@ -19668,6 +19680,12 @@ msgstr ""
msgid "Pipelines|Trigger user has insufficient permissions to project"
msgstr ""
msgid "Pipelines|Visualize"
msgstr ""
msgid "Pipelines|Write pipeline configuration"
msgstr ""
msgid "Pipelines|invalid"
msgstr ""
......
# frozen_string_literal: true
module QA
RSpec.describe 'Package', :orchestrated, :packages do
describe 'PyPI Repository' do
include Runtime::Fixtures
let(:package_name) { 'mypypipackage' }
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'pypi-package-project'
end
end
let!(:runner) do
Resource::Runner.fabricate! do |runner|
runner.name = "qa-runner-#{Time.now.to_i}"
runner.tags = ["runner-for-#{project.name}"]
runner.executor = :docker
runner.project = project
end
end
let(:gitlab_address_with_port) do
uri = URI.parse(Runtime::Scenario.gitlab_address)
"#{uri.scheme}://#{uri.host}:#{uri.port}"
end
before do
Flow::Login.sign_in
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.commit_message = 'Add .gitlab-ci.yml'
commit.add_files([{
file_path: '.gitlab-ci.yml',
content:
<<~YAML
image: python:latest
run:
script:
- pip install twine
- python setup.py sdist bdist_wheel
- "TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --repository-url #{gitlab_address_with_port}/api/v4/projects/${CI_PROJECT_ID}/packages/pypi dist/*"
tags:
- "runner-for-#{project.name}"
YAML
},
{
file_path: 'setup.py',
content:
<<~EOF
import setuptools
setuptools.setup(
name="mypypipackage",
version="0.0.1",
author="Example Author",
author_email="author@example.com",
description="A small example package",
packages=setuptools.find_packages(),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires='>=3.6',
)
EOF
}])
end
project.visit!
Page::Project::Menu.perform(&:click_ci_cd_pipelines)
Page::Project::Pipeline::Index.perform(&:click_on_latest_pipeline)
Page::Project::Pipeline::Show.perform do |pipeline|
pipeline.click_job('run')
end
Page::Project::Job::Show.perform do |job|
expect(job).to be_successful(timeout: 800)
end
end
after do
runner.remove_via_api!
end
it 'publishes a pypi package and deletes it', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1087' do
Page::Project::Menu.perform(&:click_packages_link)
Page::Project::Packages::Index.perform do |index|
expect(index).to have_package(package_name)
index.click_package(package_name)
end
Page::Project::Packages::Show.perform do |package|
package.click_delete
end
Page::Project::Packages::Index.perform do |index|
aggregate_failures do
expect(index).to have_content("Package deleted successfully")
expect(index).to have_no_package(package_name)
end
end
end
end
end
end
......@@ -50,18 +50,17 @@ RSpec.describe Projects::Registry::RepositoriesController do
tags: %w[rc1 latest])
end
it 'successfully renders container repositories' do
expect(Gitlab::Tracking).not_to receive(:event)
it 'successfully renders container repositories', :snowplow do
go_to_index
expect_no_snowplow_event
expect(response).to have_gitlab_http_status(:ok)
end
it 'tracks the event' do
expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_repositories')
it 'tracks the event', :snowplow do
go_to_index(format: :json)
expect_snowplow_event(category: anything, action: 'list_repositories')
end
it 'creates a root container repository' do
......@@ -132,11 +131,12 @@ RSpec.describe Projects::Registry::RepositoriesController do
expect(response).to have_gitlab_http_status(:no_content)
end
it 'tracks the event' do
expect(Gitlab::Tracking).to receive(:event).with(anything, 'delete_repository')
it 'tracks the event', :snowplow do
allow(DeleteContainerRepositoryWorker).to receive(:perform_async).with(user.id, repository.id)
delete_repository(repository)
expect_snowplow_event(category: anything, action: 'delete_repository')
end
end
end
......
......@@ -39,10 +39,10 @@ RSpec.describe Projects::Registry::TagsController do
expect(response).to include_pagination_headers
end
it 'tracks the event' do
expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_tags')
it 'tracks the event', :snowplow do
get_tags
expect_snowplow_event(category: anything, action: 'list_tags')
end
end
......@@ -148,11 +148,11 @@ RSpec.describe Projects::Registry::TagsController do
bulk_destroy_tags(tags)
end
it 'tracks the event' do
it 'tracks the event', :snowplow do
expect_delete_tags(tags)
expect(Gitlab::Tracking).to receive(:event).with(anything, 'delete_tag_bulk')
bulk_destroy_tags(tags)
expect_snowplow_event(category: anything, action: 'delete_tag_bulk')
end
end
end
......
......@@ -166,23 +166,22 @@ RSpec.describe Projects::Settings::OperationsController do
context 'updating each incident management setting' do
let(:new_incident_management_settings) { {} }
shared_examples 'a gitlab tracking event' do |params, event_key|
it "creates a gitlab tracking event #{event_key}" do
shared_examples 'a gitlab tracking event' do |params, event_key, **args|
it "creates a gitlab tracking event #{event_key}", :snowplow do
new_incident_management_settings = params
expect(Gitlab::Tracking).to receive(:event)
.with('IncidentManagement::Settings', event_key, any_args)
patch :update, params: project_params(project, incident_management_setting_attributes: new_incident_management_settings)
project.reload
expect_snowplow_event(category: 'IncidentManagement::Settings', action: event_key, **args)
end
end
it_behaves_like 'a gitlab tracking event', { create_issue: '1' }, 'enabled_issue_auto_creation_on_alerts'
it_behaves_like 'a gitlab tracking event', { create_issue: '0' }, 'disabled_issue_auto_creation_on_alerts'
it_behaves_like 'a gitlab tracking event', { issue_template_key: 'template' }, 'enabled_issue_template_on_alerts'
it_behaves_like 'a gitlab tracking event', { issue_template_key: nil }, 'disabled_issue_template_on_alerts'
it_behaves_like 'a gitlab tracking event', { issue_template_key: 'template' }, 'enabled_issue_template_on_alerts', label: "Template name", property: "template"
it_behaves_like 'a gitlab tracking event', { issue_template_key: nil }, 'disabled_issue_template_on_alerts', label: "Template name", property: ""
it_behaves_like 'a gitlab tracking event', { send_email: '1' }, 'enabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { send_email: '0' }, 'disabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { pagerduty_active: '1' }, 'enabled_pagerduty_webhook'
......
export const designListQueryResponse = {
data: {
project: {
__typename: 'Project',
id: '1',
issue: {
__typename: 'Issue',
designCollection: {
__typename: 'DesignCollection',
copyState: 'READY',
designs: {
__typename: 'DesignConnection',
nodes: [
{
__typename: 'Design',
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
......@@ -15,10 +20,12 @@ export const designListQueryResponse = {
image: 'image-1',
imageV432x230: 'image-1',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
{
__typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
......@@ -26,10 +33,12 @@ export const designListQueryResponse = {
image: 'image-2',
imageV432x230: 'image-2',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
{
__typename: 'Design',
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
......@@ -37,12 +46,14 @@ export const designListQueryResponse = {
image: 'image-3',
imageV432x230: 'image-3',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
],
},
versions: {
__typename: 'DesignVersion',
nodes: [],
},
},
......@@ -82,9 +93,11 @@ export const designUploadMutationUpdatedResponse = {
export const permissionsQueryResponse = {
data: {
project: {
__typename: 'Project',
id: '1',
issue: {
userPermissions: { createDesign: true },
__typename: 'Issue',
userPermissions: { __typename: 'UserPermissions', createDesign: true },
},
},
},
......@@ -92,6 +105,7 @@ export const permissionsQueryResponse = {
export const reorderedDesigns = [
{
__typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
......@@ -99,10 +113,12 @@ export const reorderedDesigns = [
image: 'image-2',
imageV432x230: 'image-2',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
{
__typename: 'Design',
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
......@@ -110,10 +126,12 @@ export const reorderedDesigns = [
image: 'image-1',
imageV432x230: 'image-1',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
{
__typename: 'Design',
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
......@@ -121,6 +139,7 @@ export const reorderedDesigns = [
image: 'image-3',
imageV432x230: 'image-3',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
},
},
......@@ -130,7 +149,9 @@ export const moveDesignMutationResponse = {
data: {
designManagementMove: {
designCollection: {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
nodes: [...reorderedDesigns],
},
},
......
......@@ -5,6 +5,8 @@ import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
......@@ -28,8 +30,6 @@ import {
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking';
......
......@@ -2,14 +2,14 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import { createMockClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo';
export default (handlers = []) => {
export default (handlers = [], resolvers = {}) => {
const fragmentMatcher = { match: () => true };
const cache = new InMemoryCache({
fragmentMatcher,
addTypename: false,
});
const mockClient = createMockClient({ cache });
const mockClient = createMockClient({ cache, resolvers });
if (Array.isArray(handlers)) {
handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value));
......
......@@ -32,4 +32,10 @@ describe('~/pipeline_editor/components/text_editor.vue', () => {
expect(findEditor().props('editorOptions')).toEqual({ readOnly: true });
expect(findEditor().props('fileName')).toBe('*.yml');
});
it('bubbles up editor-ready event', () => {
findEditor().vm.$emit('editor-ready');
expect(wrapper.emitted('editor-ready')).toHaveLength(1);
});
});
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
let wrapper;
const createComponent = ({ props = {}, loading = false } = {}, mountFn = shallowMount) => {
const createComponent = (
{ props = {}, data = {}, loading = false } = {},
mountFn = shallowMount,
) => {
wrapper = mountFn(PipelineEditorApp, {
propsData: {
projectPath: mockProjectPath,
......@@ -18,7 +22,11 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
ciConfigPath: mockCiConfigPath,
...props,
},
data() {
return data;
},
stubs: {
GlTabs,
TextEditor,
},
mocks: {
......@@ -35,23 +43,59 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert);
const findEditor = () => wrapper.find(EditorLite);
const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findEditorLite = () => wrapper.find(EditorLite);
it('displays content', async () => {
beforeEach(() => {
createComponent();
wrapper.setData({ content: mockCiYml });
await nextTick();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays content', () => {
createComponent({ data: { content: mockCiYml } });
expect(findLoadingIcon().exists()).toBe(false);
expect(findEditor().props('value')).toBe(mockCiYml);
expect(findEditorLite().props('value')).toBe(mockCiYml);
});
it('displays a loading icon if the query is loading', async () => {
it('displays a loading icon if the query is loading', () => {
createComponent({ loading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
describe('tabs', () => {
it('displays tabs and their content', () => {
createComponent({ data: { content: mockCiYml } });
expect(
findTabAt(0)
.find(EditorLite)
.exists(),
).toBe(true);
expect(
findTabAt(1)
.find(PipelineGraph)
.exists(),
).toBe(true);
});
it('displays editor tab lazily, until editor is ready', async () => {
createComponent({ data: { content: mockCiYml } });
expect(findTabAt(0).attributes('lazy')).toBe('true');
findEditorLite().vm.$emit('editor-ready');
await nextTick();
expect(findTabAt(0).attributes('lazy')).toBe(undefined);
});
});
describe('when in error state', () => {
class MockError extends Error {
constructor(message, data) {
......@@ -64,24 +108,18 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
}
}
beforeEach(() => {
createComponent(mount);
});
it('shows a generic error', async () => {
wrapper.setData({ error: new MockError('An error message') });
await nextTick();
it('shows a generic error', () => {
const error = new MockError('An error message');
createComponent({ data: { error } });
expect(findAlert().text()).toBe('CI file could not be loaded: An error message');
});
it('shows a ref missing error state', async () => {
it('shows a ref missing error state', () => {
const error = new MockError('Ref missing!', {
error: 'ref is missing, ref is empty',
});
wrapper.setData({ error });
await nextTick();
createComponent({ data: { error } });
expect(findAlert().text()).toMatch(
'CI file could not be loaded: ref is missing, ref is empty',
......@@ -93,8 +131,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
message: 'file not found',
});
wrapper.setData({ error });
await nextTick();
await wrapper.setData({ error });
expect(findAlert().text()).toMatch('CI file could not be loaded: file not found');
});
......
import { ApolloMutation } from 'vue-apollo';
import VueApollo, { ApolloMutation } from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
......@@ -10,7 +12,11 @@ import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import { SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_VISIBILITY_INTERNAL,
SNIPPET_VISIBILITY_PUBLIC,
} from '~/snippets/constants';
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import { testEntries } from '../test_utils';
......@@ -47,8 +53,12 @@ const createTestSnippet = () => ({
describe('Snippet Edit app', () => {
let wrapper;
let fakeApollo;
const relativeUrlRoot = '/foo/';
const originalRelativeUrlRoot = gon.relative_url_root;
const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
data: { snippets: { nodes: [createTestSnippet()] } },
});
const mutationTypes = {
RESOLVE: jest.fn().mockResolvedValue({
......@@ -78,12 +88,10 @@ describe('Snippet Edit app', () => {
props = {},
loading = false,
mutationRes = mutationTypes.RESOLVE,
selectedLevel = SNIPPET_VISIBILITY_PRIVATE,
withApollo = false,
} = {}) {
if (wrapper) {
throw new Error('wrapper already exists');
}
wrapper = shallowMount(SnippetEditApp, {
let componentData = {
mocks: {
$apollo: {
queries: {
......@@ -92,23 +100,35 @@ describe('Snippet Edit app', () => {
mutate: mutationRes,
},
},
};
if (withApollo) {
const localVue = createLocalVue();
localVue.use(VueApollo);
const requestHandlers = [[GetSnippetQuery, GetSnippetQuerySpy]];
fakeApollo = createMockApollo(requestHandlers);
componentData = {
localVue,
apolloProvider: fakeApollo,
};
}
wrapper = shallowMount(SnippetEditApp, {
...componentData,
stubs: {
ApolloMutation,
FormFooterActions,
},
provide: {
selectedLevel,
},
propsData: {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
...props,
},
data() {
return {
snippet: {
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
},
};
},
});
}
......@@ -152,16 +172,13 @@ describe('Snippet Edit app', () => {
if (nodes.length) {
wrapper.setData({
snippet: nodes[0],
newSnippet: false,
});
} else {
wrapper.setData({
newSnippet: true,
});
}
wrapper.vm.onSnippetFetch({
data: {
snippets: {
nodes,
},
},
});
};
describe('rendering', () => {
......@@ -228,6 +245,28 @@ describe('Snippet Edit app', () => {
});
describe('functionality', () => {
it('does not fetch snippet when create a new snippet', async () => {
createComponent({ props: { snippetGid: '' }, withApollo: true });
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(GetSnippetQuerySpy).not.toHaveBeenCalled();
});
describe('default visibility', () => {
it.each([SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC])(
'marks %s visibility by default',
async visibility => {
createComponent({
props: { snippetGid: '' },
selectedLevel: visibility,
});
expect(wrapper.vm.snippet.visibilityLevel).toEqual(visibility);
},
);
});
describe('form submission handling', () => {
it.each`
snippetArg | projectPath | uploadedFiles | input | mutation
......
import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
import { defaultSnippetVisibilityLevels } from '~/snippets/utils/blob';
import {
SNIPPET_VISIBILITY,
SNIPPET_VISIBILITY_PRIVATE,
......@@ -15,36 +14,25 @@ describe('Snippet Visibility Edit component', () => {
let wrapper;
const defaultHelpLink = '/foo/bar';
const defaultVisibilityLevel = 'private';
const defaultVisibility = defaultSnippetVisibilityLevels([0, 10, 20]);
function createComponent({
propsData = {},
visibilityLevels = defaultVisibility,
visibilityLevels = [0, 10, 20],
multipleLevelsRestricted = false,
deep = false,
} = {}) {
const method = deep ? mount : shallowMount;
const $apollo = {
queries: {
defaultVisibility: {
loading: false,
},
},
};
wrapper = method.call(this, SnippetVisibilityEdit, {
mock: { $apollo },
propsData: {
helpLink: defaultHelpLink,
isProjectSnippet: false,
value: defaultVisibilityLevel,
...propsData,
},
data() {
return {
visibilityLevels,
multipleLevelsRestricted,
};
provide: {
visibilityLevels,
multipleLevelsRestricted,
},
});
}
......@@ -108,7 +96,6 @@ describe('Snippet Visibility Edit component', () => {
it.each`
levels | resultOptions
${undefined} | ${[]}
${''} | ${[]}
${[]} | ${[]}
${[0]} | ${[RESULTING_OPTIONS[0]]}
......@@ -117,7 +104,7 @@ describe('Snippet Visibility Edit component', () => {
${[0, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[20]]}
${[10, 20]} | ${[RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]}
`('renders correct visibility options for $levels', ({ levels, resultOptions }) => {
createComponent({ visibilityLevels: defaultSnippetVisibilityLevels(levels), deep: true });
createComponent({ visibilityLevels: levels, deep: true });
expect(findRadiosData()).toEqual(resultOptions);
});
......@@ -132,7 +119,7 @@ describe('Snippet Visibility Edit component', () => {
'renders correct information about restricted visibility levels for $levels',
({ levels, levelsRestricted, resultText }) => {
createComponent({
visibilityLevels: defaultSnippetVisibilityLevels(levels),
visibilityLevels: levels,
multipleLevelsRestricted: levelsRestricted,
});
expect(findRestrictedInfo().text()).toBe(resultText);
......
......@@ -14,7 +14,9 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
subject { resolve_measurements({ identifier: 'projects' }, { current_user: current_user }) }
let(:arguments) { { identifier: 'projects' } }
subject { resolve_measurements(arguments, { current_user: current_user }) }
context 'when requesting project count measurements' do
context 'as an admin user' do
......@@ -40,6 +42,24 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when filtering by recorded_after and recorded_before' do
before do
arguments[:recorded_after] = 4.days.ago
arguments[:recorded_before] = 1.day.ago
end
it { is_expected.to match_array([project_measurement_new]) }
context 'when "incorrect" values are passed' do
before do
arguments[:recorded_after] = 1.day.ago
arguments[:recorded_before] = 4.days.ago
end
it { is_expected.to be_empty }
end
end
end
context 'when requesting pipeline counts by pipeline status' do
......
......@@ -176,10 +176,10 @@ RSpec.describe API::Helpers do
end
describe '#track_event' do
it "creates a gitlab tracking event" do
expect(Gitlab::Tracking).to receive(:event).with('foo', 'my_event')
it "creates a gitlab tracking event", :snowplow do
subject.track_event('my_event', category: 'foo')
expect_snowplow_event(category: 'foo', action: 'my_event')
end
it "logs an exception" do
......
......@@ -45,6 +45,34 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
it { is_expected.to match_array([measurement_1, measurement_2]) }
end
describe '.recorded_after' do
subject { described_class.recorded_after(8.days.ago) }
it { is_expected.to match_array([measurement_2, measurement_3]) }
context 'when nil is given' do
subject { described_class.recorded_after(nil) }
it 'does not apply filtering' do
expect(subject).to match_array([measurement_1, measurement_2, measurement_3])
end
end
end
describe '.recorded_before' do
subject { described_class.recorded_before(4.days.ago) }
it { is_expected.to match_array([measurement_1, measurement_3]) }
context 'when nil is given' do
subject { described_class.recorded_after(nil) }
it 'does not apply filtering' do
expect(subject).to match_array([measurement_1, measurement_2, measurement_3])
end
end
end
end
describe '#measurement_identifier_values' do
......
......@@ -55,4 +55,130 @@ RSpec.describe 'Query.project(fullPath).pipelines' do
expect(job_names).to contain_exactly('Job 1', 'Job 2')
end
end
describe 'upstream' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: first_user) }
let_it_be(:upstream_project) { create(:project, :repository, :public) }
let_it_be(:upstream_pipeline) { create(:ci_pipeline, project: upstream_project, user: first_user) }
let(:upstream_pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]).first['upstream'] }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
pipelines {
nodes {
upstream {
iid
}
}
}
}
}
)
end
before do
create(:ci_sources_pipeline, source_pipeline: upstream_pipeline, pipeline: pipeline )
post_graphql(query, current_user: first_user)
end
it_behaves_like 'a working graphql query'
it 'returns the upstream pipeline of a pipeline' do
expect(upstream_pipelines_graphql_data['iid'].to_i).to eq(upstream_pipeline.iid)
end
context 'when fetching the upstream pipeline from the pipeline' do
it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: first_user)
end
pipeline_2 = create(:ci_pipeline, project: project, user: first_user)
upstream_pipeline_2 = create(:ci_pipeline, project: upstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: upstream_pipeline_2, pipeline: pipeline_2 )
pipeline_3 = create(:ci_pipeline, project: project, user: first_user)
upstream_pipeline_3 = create(:ci_pipeline, project: upstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: upstream_pipeline_3, pipeline: pipeline_3 )
expect do
post_graphql(query, current_user: second_user)
end.not_to exceed_query_limit(control_count)
expect(response).to have_gitlab_http_status(:ok)
end
end
end
describe 'downstream' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: first_user) }
let(:pipeline_2) { create(:ci_pipeline, project: project, user: first_user) }
let_it_be(:downstream_project) { create(:project, :repository, :public) }
let_it_be(:downstream_pipeline_a) { create(:ci_pipeline, project: downstream_project, user: first_user) }
let_it_be(:downstream_pipeline_b) { create(:ci_pipeline, project: downstream_project, user: first_user) }
let(:pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]) }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
pipelines {
nodes {
downstream {
nodes {
iid
}
}
}
}
}
}
)
end
before do
create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downstream_pipeline_a)
create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downstream_pipeline_b)
post_graphql(query, current_user: first_user)
end
it_behaves_like 'a working graphql query'
it 'returns the downstream pipelines of a pipeline' do
downstream_pipelines_graphql_data = pipelines_graphql_data.map { |pip| pip['downstream']['nodes'] }.flatten
expect(
downstream_pipelines_graphql_data.map { |pip| pip['iid'].to_i }
).to contain_exactly(downstream_pipeline_a.iid, downstream_pipeline_b.iid)
end
context 'when fetching the downstream pipelines from the pipeline' do
it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: first_user)
end
downstream_pipeline_2a = create(:ci_pipeline, project: downstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downstream_pipeline_2a)
downsteam_pipeline_3a = create(:ci_pipeline, project: downstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downsteam_pipeline_3a)
downstream_pipeline_2b = create(:ci_pipeline, project: downstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downstream_pipeline_2b)
downsteam_pipeline_3b = create(:ci_pipeline, project: downstream_project, user: first_user)
create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downsteam_pipeline_3b)
expect do
post_graphql(query, current_user: second_user)
end.not_to exceed_query_limit(control_count)
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
......@@ -9,7 +9,8 @@ RSpec.describe 'InstanceStatisticsMeasurements' do
let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
let(:query) { graphql_query_for(:instanceStatisticsMeasurements, 'identifier: PROJECTS', 'nodes { count identifier }') }
let(:arguments) { 'identifier: PROJECTS' }
let(:query) { graphql_query_for(:instanceStatisticsMeasurements, arguments, 'nodes { count identifier }') }
before do
post_graphql(query, current_user: current_user)
......@@ -21,4 +22,14 @@ RSpec.describe 'InstanceStatisticsMeasurements' do
{ "count" => 5, 'identifier' => 'PROJECTS' }
])
end
context 'with recorded_at filters' do
let(:arguments) { %(identifier: PROJECTS, recordedAfter: "#{15.days.ago.to_date}", recordedBefore: "#{5.days.ago.to_date}") }
it 'returns filtered measurement objects' do
expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([
{ "count" => 10, 'identifier' => 'PROJECTS' }
])
end
end
end
......@@ -299,7 +299,7 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'rejected container repository access', :reporter, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
context 'for developer' do
context 'for developer', :snowplow do
let(:api_user) { developer }
context 'when there are multiple tags' do
......@@ -310,11 +310,11 @@ RSpec.describe API::ProjectContainerRepositories do
it 'properly removes tag' do
expect(service).to receive(:execute).with(root_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service }
expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag')
subject
expect(response).to have_gitlab_http_status(:ok)
expect_snowplow_event(category: described_class.name, action: 'delete_tag')
end
end
......@@ -326,11 +326,11 @@ RSpec.describe API::ProjectContainerRepositories do
it 'properly removes tag' do
expect(service).to receive(:execute).with(root_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service }
expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag')
subject
expect(response).to have_gitlab_http_status(:ok)
expect_snowplow_event(category: described_class.name, action: 'delete_tag')
end
end
end
......
# frozen_string_literal: true
RSpec.shared_examples 'a Trackable Controller' do
describe '#track_event' do
describe '#track_event', :snowplow do
before do
sign_in user
end
......@@ -14,9 +14,10 @@ RSpec.shared_examples 'a Trackable Controller' do
end
end
it 'tracks the action name' do
expect(Gitlab::Tracking).to receive(:event).with('AnonymousController', 'index')
it 'tracks the action name', :snowplow do
get :index
expect_snowplow_event(category: 'AnonymousController', action: 'index')
end
end
......@@ -29,8 +30,9 @@ RSpec.shared_examples 'a Trackable Controller' do
end
it 'tracks with the specified param' do
expect(Gitlab::Tracking).to receive(:event).with('SomeCategory', 'some_event', label: 'errorlabel')
get :index
expect_snowplow_event(category: 'SomeCategory', action: 'some_event', label: 'errorlabel')
end
end
end
......
......@@ -128,10 +128,10 @@ RSpec.shared_examples 'discussions API' do |parent_type, noteable_type, id_name,
stub_feature_flags(notes_create_service_tracking: false)
end
it 'does not track any events' do
expect(Gitlab::Tracking).not_to receive(:event)
it 'does not track any events', :snowplow do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), params: { body: 'hi!' }
expect_no_snowplow_event
end
end
......
......@@ -132,9 +132,9 @@ RSpec.shared_examples 'a package tracking event' do |category, action|
stub_feature_flags(collect_package_events: true)
end
it "creates a gitlab tracking event #{action}" do
expect(Gitlab::Tracking).to receive(:event).with(category, action)
it "creates a gitlab tracking event #{action}", :snowplow do
expect { subject }.to change { Packages::Event.count }.by(1)
expect_snowplow_event(category: category, action: action)
end
end
# frozen_string_literal: true
RSpec.shared_examples 'a gitlab tracking event' do |category, action|
it "creates a gitlab tracking event #{action}" do
expect(Gitlab::Tracking).to receive(:event).with(category, action)
it "creates a gitlab tracking event #{action}", :snowplow do
subject
expect_snowplow_event(category: category, action: action)
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