Commit a88e6f92 authored by Mike Lewis's avatar Mike Lewis

Merge branch 'master' into updating-slack-config-helptext

parents ad1d082a d6dc0201
<script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
export default {
name: 'ResolveWithIssueButton',
components: {
Icon,
GlDeprecatedButton,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -22,13 +20,12 @@ export default {
<template>
<div class="btn-group" role="group">
<gl-deprecated-button
<gl-button
v-gl-tooltip
:href="url"
:title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
>
<icon name="issue-new" />
</gl-deprecated-button>
icon="issue-new"
/>
</div>
</template>
......@@ -23,7 +23,6 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
import MultilineCommentForm from './multiline_comment_form.vue';
export default {
name: 'NoteableNote',
......@@ -34,7 +33,6 @@ export default {
noteActions,
NoteBody,
TimelineEntryItem,
MultilineCommentForm,
},
mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: {
......@@ -147,14 +145,16 @@ export default {
return getEndLineNumber(this.lineRange);
},
showMultiLineComment() {
if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
if (this.isEditing) return true;
if (
!this.glFeatures.multilineComments ||
!this.discussionRoot ||
this.startLineNumber.length === 0 ||
this.endLineNumber.length === 0
)
return false;
return this.line && this.startLineNumber !== this.endLineNumber;
},
showMultilineCommentForm() {
return Boolean(this.isEditing && this.note.position && this.diffFile && this.line);
},
commentLineOptions() {
const sideA = this.line.type === 'new' ? 'right' : 'left';
const sideB = sideA === 'left' ? 'right' : 'left';
......@@ -344,17 +344,9 @@ export default {
:data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item"
>
<div v-if="showMultiLineComment" data-testid="multiline-comment">
<multiline-comment-form
v-if="showMultilineCommentForm"
v-model="commentLineStart"
:line="line"
:comment-line-options="commentLineOptions"
:line-range="note.position.line_range"
class="gl-mb-3 gl-text-gray-700 gl-pb-3"
/>
<div
v-else
v-if="showMultiLineComment"
data-testid="multiline-comment"
class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
>
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
......@@ -366,7 +358,6 @@ export default {
</template>
</gl-sprintf>
</div>
</div>
<div v-once class="timeline-icon">
<user-avatar-link
:link-href="author.path"
......
......@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
......@@ -22,9 +21,6 @@ export default {
MilestoneCombobox,
TagField,
},
directives: {
autofocusonshow,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
......@@ -40,9 +36,9 @@ export default {
'manageMilestonesPath',
'projectId',
]),
...mapGetters('detail', ['isValid']),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
subtitleText() {
return sprintf(
......@@ -86,6 +82,9 @@ export default {
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
saveButtonLabel() {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
......@@ -102,13 +101,17 @@ export default {
];
},
},
created() {
this.fetchRelease();
mounted() {
// eslint-disable-next-line promise/catch-or-return
this.initializeRelease().then(() => {
// Focus the first non-disabled input element
this.$el.querySelector('input:enabled').focus();
});
},
methods: {
...mapActions('detail', [
'fetchRelease',
'updateRelease',
'initializeRelease',
'saveRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
......@@ -119,7 +122,7 @@ export default {
<template>
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()">
<form v-if="showForm" @submit.prevent="saveRelease()">
<tag-field />
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
......@@ -127,8 +130,6 @@ export default {
id="release-title"
ref="releaseTitleInput"
v-model="releaseTitle"
v-autofocusonshow
autofocus
type="text"
class="form-control"
/>
......@@ -162,8 +163,8 @@ export default {
data-supports-quick-actions="false"
:aria-label="__('Release notes')"
:placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()"
@keydown.meta.enter="saveRelease()"
@keydown.ctrl.enter="saveRelease()"
></textarea>
</template>
</markdown-field>
......@@ -178,10 +179,11 @@ export default {
category="primary"
variant="success"
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
>{{ __('Save changes') }}</gl-button
data-testid="submit-button"
>
{{ saveButtonLabel }}
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
......
......@@ -3,76 +3,114 @@ import api from '~/api';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
export const receiveReleaseSuccess = ({ commit }, data) =>
commit(types.RECEIVE_RELEASE_SUCCESS, data);
export const receiveReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
export const initializeRelease = ({ commit, dispatch, getters }) => {
if (getters.isExistingRelease) {
// When editing an existing release,
// fetch the release object from the API
return dispatch('fetchRelease');
}
// When creating a new release, initialize the
// store with an empty release object
commit(types.INITIALIZE_EMPTY_RELEASE);
return Promise.resolve();
};
export const fetchRelease = ({ dispatch, state }) => {
dispatch('requestRelease');
export const fetchRelease = ({ commit, state }) => {
commit(types.REQUEST_RELEASE);
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {
const release = {
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
})
.catch(error => {
dispatch('receiveReleaseError', error);
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
});
};
export const updateReleaseTagName = ({ commit }, tagName) =>
commit(types.UPDATE_RELEASE_TAG_NAME, tagName);
export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom);
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
redirectTo(
rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath,
);
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
export const receiveUpdateReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
};
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
};
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
export const saveRelease = ({ commit, dispatch, getters }) => {
commit(types.REQUEST_SAVE_RELEASE);
const updatedRelease = convertObjectPropsToSnakeCase(
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
export const createRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson(
{
name: release.name,
description: release.description,
milestones,
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
},
{ deep: true },
state.createFrom,
);
return api
.createRelease(state.projectId, apiJson)
.then(({ data }) => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
})
.catch(error => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while creating a new release'));
});
};
export const updateRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
});
let updatedRelease = null;
return (
api
.updateRelease(state.projectId, state.tagName, updatedRelease)
.updateRelease(state.projectId, state.tagName, apiJson)
/**
* Currently, we delete all existing links and then
......@@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => {
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed.
*/
.then(({ data }) => {
// Save this response since we need it later in the Promise chain
updatedRelease = data;
.then(() => {
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, release.tagName, l.id),
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
getters.releaseLinksToCreate.map(l =>
api.createReleaseLink(
state.projectId,
release.tagName,
convertObjectPropsToSnakeCase(l, { deep: true }),
),
apiJson.assets.links.map(l =>
api.createReleaseLink(state.projectId, state.release.tagName, l),
),
);
})
.then(() => dispatch('receiveUpdateReleaseSuccess'))
.then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch(error => {
dispatch('receiveUpdateReleaseError', error);
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
})
);
};
export const navigateToReleasesPage = ({ state }) => {
redirectTo(state.releasesPagePath);
};
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
};
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
......@@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility';
* `false` if the app is creating a new release.
*/
export const isExistingRelease = state => {
return Boolean(state.originalRelease);
return Boolean(state.tagName);
};
/**
......
export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE';
export const REQUEST_RELEASE = 'REQUEST_RELEASE';
export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
......@@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
......
......@@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => {
};
export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
};
},
[types.REQUEST_RELEASE](state) {
state.isFetchingRelease = true;
},
......@@ -39,14 +51,14 @@ export default {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) {
[types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
[types.RECEIVE_SAVE_RELEASE_SUCCESS](state) {
state.updateError = undefined;
state.isUpdatingRelease = false;
},
[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
[types.RECEIVE_SAVE_RELEASE_ERROR](state, error) {
state.updateError = error;
state.isUpdatingRelease = false;
},
......
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
/**
* Converts a release object into a JSON object that can sent to the public
* API to create or update a release.
* @param {Object} release The release object to convert
* @param {string} createFrom The ref to create a new tag from, if necessary
*/
export const releaseToApiJson = (release, createFrom = null) => {
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return convertObjectPropsToSnakeCase(
{
tagName: release.tagName,
ref: createFrom,
name: release.name,
description: release.description,
milestones,
assets: release.assets,
},
{ deep: true },
);
};
/**
* Converts a JSON release object returned by the Release API
* into the structure this Vue application can work with.
* @param {Object} json The JSON object received from the release API
*/
export const apiJsonToRelease = json => {
const release = convertObjectPropsToCamelCase(json, { deep: true });
release.milestones = release.milestones || [];
return release;
};
......@@ -12,7 +12,8 @@ module TriggerableHooks
merge_request_hooks: :merge_requests_events,
job_hooks: :job_events,
pipeline_hooks: :pipeline_events,
wiki_page_hooks: :wiki_page_events
wiki_page_hooks: :wiki_page_events,
deployment_hooks: :deployment_events
}.freeze
extend ActiveSupport::Concern
......
......@@ -148,6 +148,7 @@ class Deployment < ApplicationRecord
def execute_hooks
deployment_data = Gitlab::DataBuilder::Deployment.build(self)
project.execute_hooks(deployment_data, :deployment_hooks) if Feature.enabled?(:deployment_webhooks, project)
project.execute_services(deployment_data, :deployment_hooks)
end
......
......@@ -17,7 +17,8 @@ class ProjectHook < WebHook
:merge_request_hooks,
:job_hooks,
:pipeline_hooks,
:wiki_page_hooks
:wiki_page_hooks,
:deployment_hooks
]
belongs_to :project
......
......@@ -75,8 +75,6 @@ module Git
end
def merge_request_branches_for(changes)
return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
@merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
end
end
......
---
title: Fix multiline comment rendering
merge_request: 38721
author:
type: fixed
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# CI/CD development documentation
Development guides that are specific to CI/CD are listed here.
......
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# Development guide for GitLab CI/CD templates
This document explains how to develop [GitLab CI/CD templates](../../ci/examples/README.md).
......
---
redirect_to: 'documentation/styleguide.md'
---
This document was moved to [another location](documentation/styleguide.md).
---
redirect_to: 'feature_flags/index.md'
---
This document was moved to [another location](feature_flags/index.md).
......@@ -91,7 +91,7 @@ export default {
<template>
<div class="audit-log-table" data-testid="audit-events-table" :data-qa-selector="qaSelector">
<gl-table class="mt-3" :fields="$options.fields" :items="events" show-empty>
<gl-table class="gl-mt-5" :fields="$options.fields" :items="events" show-empty stacked="md">
<template #cell(author)="{ value: { url, name } }">
<url-table-cell :url="url" :name="name" />
</template>
......
......@@ -30,14 +30,14 @@ module Projects
def render_csv
CsvBuilders::SingleBatch.new(
::Gitlab::Ci::Parsers::Security::ScannedResources.new.scanned_resources_for_csv(@scanned_resources),
@scanned_resources,
{
'Method': 'request_method',
'Scheme': 'scheme',
'Host': 'host',
'Port': 'port',
'Path': 'path',
'Query String': 'query_string'
'Scheme': 'url_scheme',
'Host': 'url_host',
'Port': 'url_port',
'Path': 'url_path',
'Query String': 'url_query'
}
).render
end
......
......@@ -9,22 +9,6 @@ module BillingPlansHelper
number_to_currency(value, unit: '$', strip_insignificant_zeros: true, format: "%u%n")
end
def current_plan?(plan)
plan.purchase_link&.action == 'current_plan'
end
def plan_purchase_link(href, link_text)
if href
link_to link_text, href, class: 'btn btn-success'
else
button_tag link_text, class: 'btn disabled'
end
end
def new_gitlab_com_trial_url
"#{EE::SUBSCRIPTIONS_URL}/trials/new?gl_com=true"
end
def subscription_plan_data_attributes(group, plan)
return {} unless group
......@@ -36,26 +20,12 @@ module BillingPlansHelper
}
end
def plan_upgrade_url(group, plan)
return unless group && plan&.id
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end
def use_new_purchase_flow?(namespace)
namespace.group? &&
namespace.actual_plan_name == Plan::FREE &&
Feature.enabled?(:free_group_new_purchase_flow, current_user)
end
def plan_purchase_url(group, plan)
if use_new_purchase_flow?(group)
new_subscriptions_path(plan_id: plan.id, namespace_id: group.id)
else
"#{plan.purchase_link.href}&gl_namespace_id=#{group.id}"
end
end
def show_contact_sales_button?(purchase_link_action)
experiment_enabled?(:contact_sales_btn_in_app) &&
purchase_link_action == 'upgrade'
......@@ -89,4 +59,20 @@ module BillingPlansHelper
def namespace_for_user?(namespace)
namespace == current_user.namespace
end
private
def plan_purchase_url(group, plan)
if use_new_purchase_flow?(group)
new_subscriptions_path(plan_id: plan.id, namespace_id: group.id)
else
"#{plan.purchase_link.href}&gl_namespace_id=#{group.id}"
end
end
def plan_upgrade_url(group, plan)
return unless group && plan&.id
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end
end
......@@ -21,8 +21,8 @@ module Security
scanned_resources = scanned_resources.first(@limit) if @limit
acc[type] = scanned_resources.map do |resource|
{
'request_method' => resource['method'],
'url' => resource['url']
'request_method' => resource.request_method,
'url' => resource.request_uri.to_s
}
end
end
......
---
title: Audit events log table is now responsive to different display sizes
merge_request: 38803
author:
type: changed
......@@ -11,7 +11,8 @@ module Gitlab
report_data = parse_report(json_data)
raise SecurityReportParserError, "Invalid report format" unless report_data.is_a?(Hash)
report.scanned_resources = report_data.dig('scan', 'scanned_resources') || []
report.scanned_resources = create_scanned_resources(report_data.dig('scan', 'scanned_resources'))
create_scanner(report, report_data.dig('scan', 'scanner'))
collate_remediations(report_data).each do |vulnerability|
......@@ -26,6 +27,17 @@ module Gitlab
protected
def create_scanned_resources(scanned_resources)
return [] unless scanned_resources
scanned_resources.map do |sr|
uri = URI.parse(sr['url'])
::Gitlab::Ci::Reports::Security::ScannedResource.new(uri, sr['method'])
rescue URI::InvalidURIError
nil
end.compact
end
def parse_report(json_data)
Gitlab::Json.parse!(json_data)
end
......
......@@ -13,21 +13,6 @@ module Gitlab
scanned_resources_sum
end
def scanned_resources_for_csv(scanned_resources)
scanned_resources.map do |sr|
uri = URI.parse(sr['url'] || '')
OpenStruct.new({
request_method: sr['method'],
scheme: uri.scheme,
host: uri.host,
port: uri.port,
path: uri.path,
query_string: uri.query,
raw_url: sr['url']
})
end
end
private
def parse_report_json(blob)
......
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class ScannedResource
include Gitlab::Utils::StrongMemoize
attr_reader :request_method
attr_reader :request_uri
delegate :scheme, :host, :port, :path, :query, to: :request_uri, prefix: :url
def initialize(uri, request_method)
raise ArgumentError unless uri.is_a?(URI)
@request_method = request_method
@request_uri = uri
end
end
end
end
end
end
......@@ -30,13 +30,6 @@ RSpec.describe Projects::Security::ScannedResourcesController do
end
end
it 'returns a CSV representation of the scanned resources' do
expect_next_instance_of(::Gitlab::Ci::Parsers::Security::ScannedResources) do |instance|
expect(instance).to receive(:scanned_resources_for_csv).and_return([])
end
expect(subject).to have_gitlab_http_status(:ok)
end
context 'when the pipeline id is missing' do
let_it_be(:action_params) { { project_id: project, namespace_id: project.namespace } }
......
......@@ -3,20 +3,6 @@
require 'spec_helper'
RSpec.describe BillingPlansHelper do
describe '#current_plan?' do
it 'returns true when current_plan' do
plan = Hashie::Mash.new(purchase_link: { action: 'current_plan' })
expect(helper.current_plan?(plan)).to be_truthy
end
it 'return false when not current_plan' do
plan = Hashie::Mash.new(purchase_link: { action: 'upgrade' })
expect(helper.current_plan?(plan)).to be_falsy
end
end
describe '#subscription_plan_data_attributes' do
let(:customer_portal_url) { "https://customers.gitlab.com/subscriptions" }
......@@ -60,8 +46,6 @@ RSpec.describe BillingPlansHelper do
end
describe '#use_new_purchase_flow?' do
using RSpec::Parameterized::TableSyntax
where free_group_new_purchase: [true, false],
type: ['Group', nil],
plan: Plan.all_plans
......
......@@ -24,6 +24,74 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
expect(report.findings.map(&:confidence)).not_to include("undefined")
end
context 'parsing scanned resources' do
describe 'when the URL is invalid' do
let(:raw_json) do
{
"vulnerabilities": [],
"remediations": [],
"dependency_files": [],
"scan": {
"scanned_resources": [
{
"method": "GET",
"type": "url",
"url": "not a URL"
}
]
}
}
end
it 'skips invalid URLs' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources).to be_empty
end
end
describe 'when the URLs are valid' do
let(:raw_json) do
{
"vulnerabilities": [],
"remediations": [],
"dependency_files": [],
"scan": {
"scanned_resources": [
{
"method": "GET",
"type": "url",
"url": "http://example.com/1"
},
{
"method": "POST",
"type": "url",
"url": "http://example.com/2"
},
{
"method": "GET",
"type": "url",
"url": "http://example.com/3"
}
]
}
}
end
it 'creates a scanned resource for each URL' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources.length).to eq(3)
end
it 'converts the JSON to Scanned Resource objects' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources.first).to be_a(::Gitlab::Ci::Reports::Security::ScannedResource)
end
end
end
context 'parsing remediations' do
let(:raw_json) do
{
......
......@@ -32,61 +32,4 @@ RSpec.describe Gitlab::Ci::Parsers::Security::ScannedResources do
it { is_expected.to be(0) }
end
end
describe '#scanned_resources_for_csv' do
subject { parser.scanned_resources_for_csv(scanned_resources) }
context 'when there are scanned resources' do
let(:scanned_resources) do
[
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001" },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/" },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/login?foo=bar" },
{ "method" => "POST", "type" => "url", "url" => "http://railsgoat:3001/users" }
]
end
it 'converts the hash to OpenStructs', :aggregate_failures do
expect(subject.length).to eq(4)
resource = subject[2]
expect(resource.request_method).to eq('GET')
expect(resource.scheme).to eq('http')
expect(resource.host).to eq('railsgoat')
expect(resource.port).to eq(3001)
expect(resource.path).to eq('/login')
expect(resource.query_string).to eq('foo=bar')
end
end
context 'when there is an invalid URL' do
let(:scanned_resources) do
[
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001" },
{ "method" => "GET", "type" => "url", "url" => "notaURL" },
{ "method" => "GET", "type" => "url", "url" => "" },
{ "method" => "GET", "type" => "url", "url" => nil },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/login?foo=bar" }
]
end
it 'returns a blank object with full URL string', :aggregate_failures do
expect(subject.length).to eq(5)
invalid_url = subject[1]
expect(invalid_url.request_method).to eq('GET')
expect(invalid_url.scheme).to be_nil
expect(invalid_url.raw_url).to eq('notaURL')
blank_url = subject[2]
expect(blank_url.request_method).to eq('GET')
expect(blank_url.scheme).to be_nil
expect(blank_url.raw_url).to eq('')
nil_url = subject[3]
expect(nil_url.request_method).to eq('GET')
expect(nil_url.scheme).to be_nil
expect(nil_url.raw_url).to be_nil
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Security::ScannedResource do
let(:url) { 'http://example.com:3001/1?foo=bar' }
let(:request_method) { 'GET' }
context 'when the URI is not a URI' do
subject { ::Gitlab::Ci::Reports::Security::ScannedResource.new(url, request_method) }
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
context 'when the URL is valid' do
subject { ::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse(url), request_method) }
it 'sets the URL attributes' do
expect(subject.request_method).to eq(request_method)
expect(subject.request_uri.to_s).to eq(url)
expect(subject.url_scheme).to eq('http')
expect(subject.url_host).to eq('example.com')
expect(subject.url_port).to eq(3001)
expect(subject.url_path).to eq('/1')
expect(subject.url_query).to eq('foo=bar')
end
end
end
......@@ -82,13 +82,29 @@ RSpec.describe Security::MergeReportsService, '#execute' do
let(:report_1_findings) { [finding_id_1, finding_id_2_loc_1, finding_cwe_2, finding_wasc_1] }
let(:scanned_resource) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com'), 'GET')
end
let(:scanned_resource_1) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com'), 'POST')
end
let(:scanned_resource_2) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com/2'), 'GET')
end
let(:scanned_resource_3) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com/3'), 'GET')
end
let(:report_1) do
build(
:ci_reports_security_report,
scanners: [scanner_1, scanner_2],
findings: report_1_findings,
identifiers: report_1_findings.flat_map(&:identifiers),
scanned_resources: ['example.com', 'example.com/1', 'example.com/2']
scanned_resources: [scanned_resource, scanned_resource_1, scanned_resource_2]
)
end
......@@ -100,7 +116,7 @@ RSpec.describe Security::MergeReportsService, '#execute' do
scanners: [scanner_2],
findings: report_2_findings,
identifiers: finding_id_2_loc_2.identifiers,
scanned_resources: ['example.com', 'example.com/3']
scanned_resources: [scanned_resource, scanned_resource_1, scanned_resource_3]
)
end
......@@ -153,10 +169,10 @@ RSpec.describe Security::MergeReportsService, '#execute' do
it 'deduplicates scanned resources' do
expect(subject.scanned_resources).to(
eq([
'example.com',
'example.com/1',
'example.com/2',
'example.com/3'
scanned_resource,
scanned_resource_1,
scanned_resource_2,
scanned_resource_3
])
)
end
......
......@@ -7093,6 +7093,9 @@ msgstr ""
msgid "Create project label"
msgstr ""
msgid "Create release"
msgstr ""
msgid "Create requirement"
msgstr ""
......@@ -20099,6 +20102,9 @@ msgstr ""
msgid "Releases|New Release"
msgstr ""
msgid "Release|Something went wrong while creating a new release"
msgstr ""
msgid "Release|Something went wrong while getting the release details"
msgstr ""
......
import { GlDeprecatedButton } from '@gitlab/ui';
import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
......@@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => {
});
it('it should have a link with the provided link property as href', () => {
const button = wrapper.find(GlDeprecatedButton);
const button = wrapper.find(GlButton);
expect(button.attributes().href).toBe(url);
});
......
......@@ -83,19 +83,35 @@ describe('issue_note', () => {
});
});
it('should render multiline comment if editing discussion root', () => {
wrapper.setProps({ discussionRoot: true });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(true);
});
it('should only render if it has everything it needs', () => {
const position = {
line_range: {
start: {
line_code: 'abc_1_1',
type: null,
old_line: '',
new_line: '',
},
end: {
line_code: 'abc_2_2',
type: null,
old_line: '2',
new_line: '2',
},
},
};
const line = {
line_code: 'abc_1_1',
type: null,
old_line: '1',
new_line: '1',
};
wrapper.setProps({
note: { ...note, position },
discussionRoot: true,
line,
});
it('should only render multiline comment form if it has everything it needs', () => {
wrapper.setProps({ line: { line_code: '' } });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(false);
});
......
......@@ -27,8 +27,8 @@ describe('Release edit/new component', () => {
};
actions = {
fetchRelease: jest.fn(),
updateRelease: jest.fn(),
initializeRelease: jest.fn(),
saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
};
......@@ -64,6 +64,8 @@ describe('Release edit/new component', () => {
glFeatures: featureFlags,
},
});
wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus'));
};
beforeEach(() => {
......@@ -87,8 +89,18 @@ describe('Release edit/new component', () => {
factory();
});
it('calls fetchRelease when the component is created', () => {
expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
it('calls initializeRelease when the component is created', () => {
expect(actions.initializeRelease).toHaveBeenCalledTimes(1);
});
it('focuses the first non-disabled input element once the page is shown', () => {
const firstEnabledInput = wrapper.element.querySelector('input:enabled');
const allInputs = wrapper.element.querySelectorAll('input');
allInputs.forEach(input => {
const expectedFocusCalls = input === firstEnabledInput ? 1 : 0;
expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls);
});
});
it('renders the description text at the top of the page', () => {
......@@ -109,9 +121,9 @@ describe('Release edit/new component', () => {
expect(findSubmitButton().attributes('type')).toBe('submit');
});
it('calls updateRelease when the form is submitted', () => {
it('calls saveRelease when the form is submitted', () => {
wrapper.find('form').trigger('submit');
expect(actions.updateRelease).toHaveBeenCalledTimes(1);
expect(actions.saveRelease).toHaveBeenCalledTimes(1);
});
});
......@@ -143,6 +155,34 @@ describe('Release edit/new component', () => {
});
});
describe('when creating a new release', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isExistingRelease: () => false,
},
},
},
},
});
});
it('renders the submit button with the text "Create release"', () => {
expect(findSubmitButton().text()).toBe('Create release');
});
});
describe('when editing an existing release', () => {
beforeEach(factory);
it('renders the submit button with the text "Save changes"', () => {
expect(findSubmitButton().text()).toBe('Save changes');
});
});
describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm);
......
......@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store;
let wrapper;
const createComponent = ({ originalRelease }) => {
const createComponent = ({ tagName }) => {
store = createStore({
modules: {
detail: createDetailModule({}),
},
});
store.state.detail.originalRelease = originalRelease;
store.state.detail.tagName = tagName;
wrapper = shallowMount(TagField, { store });
};
......@@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => {
beforeEach(() => {
const originalRelease = { name: 'Version 1.0' };
createComponent({ originalRelease });
createComponent({ tagName: 'v1.0' });
});
it('renders the TagFieldExisting component', () => {
......@@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => {
beforeEach(() => {
createComponent({ originalRelease: null });
createComponent({ tagName: null });
});
it('renders the TagFieldNew component', () => {
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { cloneDeep, merge } from 'lodash';
import { cloneDeep } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types';
import { release as originalRelease } from '../../../mock_data';
......@@ -10,7 +10,9 @@ import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
jest.mock('~/flash', () => jest.fn());
......@@ -25,15 +27,26 @@ describe('Release detail actions', () => {
let mock;
let error;
beforeEach(() => {
state = createState({
const setupState = (updates = {}) => {
const getters = {
isExistingRelease: true,
};
state = {
...createState({
projectId: '18',
tagName: 'v1.3',
tagName: release.tag_name,
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
});
}),
...getters,
...updates,
};
};
beforeEach(() => {
release = cloneDeep(originalRelease);
mock = new MockAdapter(axios);
gon.api_version = 'v4';
......@@ -45,73 +58,109 @@ describe('Release detail actions', () => {
mock.restore();
});
describe('requestRelease', () => {
it(`commits ${types.REQUEST_RELEASE}`, () =>
testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }]));
describe('when creating a new release', () => {
beforeEach(() => {
setupState({ isExistingRelease: false });
});
describe('receiveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveReleaseSuccess, release, state, [
{ type: types.RECEIVE_RELEASE_SUCCESS, payload: release },
]));
describe('initializeRelease', () => {
it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => {
testAction(actions.initializeRelease, undefined, state, [
{ type: types.INITIALIZE_EMPTY_RELEASE },
]);
});
});
describe('receiveReleaseError', () => {
it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
testAction(actions.receiveReleaseError, error, state, [
{ type: types.RECEIVE_RELEASE_ERROR, payload: error },
]));
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
testAction(
actions.saveRelease,
undefined,
state,
[{ type: types.REQUEST_SAVE_RELEASE }],
[{ type: 'createRelease' }],
);
});
});
});
it('shows a flash with an error message', () => {
actions.receiveReleaseError({ commit: jest.fn() }, error);
describe('when editing an existing release', () => {
beforeEach(setupState);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
describe('initializeRelease', () => {
it('dispatches "fetchRelease"', () => {
testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]);
});
});
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => {
testAction(
actions.saveRelease,
undefined,
state,
[{ type: types.REQUEST_SAVE_RELEASE }],
[{ type: 'updateRelease' }],
);
});
});
});
describe('actions that behave the same whether creating a new release or editing an existing release', () => {
beforeEach(setupState);
describe('fetchRelease', () => {
let getReleaseUrl;
beforeEach(() => {
state.projectId = '18';
state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
});
it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
mock.onGet(getReleaseUrl).replyOnce(200, release);
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release);
});
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[
{ type: 'requestRelease' },
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: 'receiveReleaseSuccess',
payload: convertObjectPropsToCamelCase(release, { deep: true }),
type: types.REQUEST_RELEASE,
},
],
);
{
type: types.RECEIVE_RELEASE_SUCCESS,
payload: apiJsonToRelease(release, { deep: true }),
},
]);
});
});
it(`dispatches requestRelease and receiveReleaseError with an error object`, () => {
mock.onGet(getReleaseUrl).replyOnce(500);
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
});
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: types.REQUEST_RELEASE,
},
{
type: types.RECEIVE_RELEASE_ERROR,
payload: expect.any(Error),
},
]);
});
it(`shows a flash message`, () => {
return actions.fetchRelease({ commit: jest.fn(), state }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
);
});
});
});
});
describe('updateReleaseTagName', () => {
it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
......@@ -149,6 +198,23 @@ describe('Release detail actions', () => {
});
});
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
});
});
describe('addEmptyAssetLink', () => {
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
return testAction(actions.addEmptyAssetLink, undefined, state, [
{ type: types.ADD_EMPTY_ASSET_LINK },
]);
});
});
describe('updateAssetLinkUrl', () => {
it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
const params = {
......@@ -197,85 +263,133 @@ describe('Release detail actions', () => {
});
});
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
describe('receiveSaveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
]));
describe('when the releaseShowPage feature flag is enabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: true } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
it("redirects to the release's dedicated page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(release._links.self);
});
});
describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
{ type: types.REQUEST_UPDATE_RELEASE },
]));
describe('when the releaseShowPage feature flag is disabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: false } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
describe('receiveUpdateReleaseSuccess', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
]));
it("redirects to the project's main Releases page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath);
});
});
});
it('redirects to the releases page if releaseShowPage feature flag is enabled', () => {
const rootState = { featureFlags: { releaseShowPage: true } };
const updatedState = merge({}, state, {
releasesPagePath: 'path/to/releases/page',
release: {
_links: {
self: 'path/to/self',
},
},
describe('createRelease', () => {
let createReleaseUrl;
let releaseLinksToCreate;
beforeEach(() => {
const camelCasedRelease = convertObjectPropsToCamelCase(release);
releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
setupState({
release: camelCasedRelease,
releaseLinksToCreate,
});
actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
});
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
const expectedRelease = releaseToApiJson({
...state.release,
assets: {
links: releaseLinksToCreate,
},
});
describe('when the releaseShowPage feature flag is disabled', () => {});
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
});
describe('receiveUpdateReleaseError', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
testAction(actions.receiveUpdateReleaseError, error, state, [
{ type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
]));
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
return testAction(
actions.createRelease,
undefined,
state,
[],
[
{
type: 'receiveSaveReleaseSuccess',
payload: apiJsonToRelease(release, { deep: true }),
},
],
);
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
});
it('shows a flash with an error message', () => {
actions.receiveUpdateReleaseError({ commit: jest.fn() }, error);
it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => {
return testAction(actions.createRelease, undefined, state, [
{
type: types.RECEIVE_SAVE_RELEASE_ERROR,
payload: expect.any(Error),
},
]);
});
it(`shows a flash message`, () => {
return actions
.createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} })
.then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details',
'Something went wrong while creating a new release',
);
});
});
});
});
describe('updateRelease', () => {
let getters;
let dispatch;
let commit;
let callOrder;
beforeEach(() => {
state.release = convertObjectPropsToCamelCase(release);
state.projectId = '18';
state.tagName = state.release.tagName;
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
};
setupState({
release: convertObjectPropsToCamelCase(release),
...getters,
});
dispatch = jest.fn();
commit = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve();
return Promise.resolve({ data: release });
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
......@@ -287,22 +401,11 @@ describe('Release detail actions', () => {
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
describe('when the network request to the Release API is successful', () => {
it('dispatches receiveSaveReleaseSuccess', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseSuccess'],
]);
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseError', error],
['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
]);
});
});
......@@ -321,15 +424,18 @@ describe('Release detail actions', () => {
[
state.projectId,
state.tagName,
{
name: state.release.name,
description: state.release.description,
milestones: state.release.milestones.map(milestone => milestone.title),
releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
}),
],
]);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToDelete.length,
);
getters.releaseLinksToDelete.forEach(link => {
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
state.projectId,
......@@ -338,9 +444,38 @@ describe('Release detail actions', () => {
);
});
expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length);
expect(api.createReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToCreate.length,
);
getters.releaseLinksToCreate.forEach(link => {
expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link);
expect(api.createReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link,
);
});
});
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
});
});
it('shows a flash message', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details',
);
});
});
});
});
......
......@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => {
const state = { originalRelease: { name: 'The first release' } };
const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true);
});
it('returns false if the release is a new release that has not yet been saved to the database', () => {
const state = { originalRelease: null };
const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false);
});
......
......@@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
it('set state.release to an empty release object', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
});
});
});
describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
......@@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true);
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined();
......@@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false);
......
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
it('converts a release JavaScript object into JSON that the Release API can accept', () => {
const release = {
tagName: 'tag-name',
name: 'Release name',
description: 'Release description',
milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
assets: {
links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
},
};
const expectedJson = {
tag_name: 'tag-name',
ref: null,
name: 'Release name',
description: 'Release description',
milestones: ['13.2', '13.3'],
assets: {
links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }],
},
};
expect(releaseToApiJson(release)).toEqual(expectedJson);
});
describe('when createFrom is provided', () => {
it('adds the provided createFrom ref to the JSON as a "ref" property', () => {
const createFrom = 'main';
const release = {};
const expectedJson = {
ref: createFrom,
};
expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson);
});
});
describe('when release.milestones is falsy', () => {
it('includes a "milestone" property in the returned result as an empty array', () => {
const release = {};
const expectedJson = {
milestones: [],
};
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
});
describe('apiJsonToRelease', () => {
it('converts JSON received from the Release API into an object usable by the Vue application', () => {
const json = {
tag_name: 'tag-name',
assets: {
links: [
{
link_type: 'other',
},
],
},
};
const expectedRelease = {
tagName: 'tag-name',
assets: {
links: [
{
linkType: 'other',
},
],
},
milestones: [],
};
expect(apiJsonToRelease(json)).toEqual(expectedRelease);
});
});
});
......@@ -190,18 +190,6 @@ RSpec.describe Git::ProcessRefChangesService do
subject.execute
end
context 'refresh_only_existing_merge_requests_on_push disabled' do
before do
stub_feature_flags(refresh_only_existing_merge_requests_on_push: false)
end
it 'refreshes all merge requests' do
expect(UpdateMergeRequestsWorker).to receive(:perform_async).exactly(3).times
subject.execute
end
end
end
end
......
......@@ -49,5 +49,29 @@ RSpec.describe Deployments::FinishedWorker do
expect(ProjectServiceWorker).not_to have_received(:perform_async)
end
it 'execute webhooks' do
deployment = create(:deployment)
project = deployment.project
web_hook = create(:project_hook, deployment_events: true, project: project)
expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service|
expect(service).to receive(:async_execute)
end
worker.perform(deployment.id)
end
it 'does not execute webhooks if feature flag is disabled' do
stub_feature_flags(deployment_webhooks: false)
deployment = create(:deployment)
project = deployment.project
create(:project_hook, deployment_events: true, project: project)
expect(WebHookService).not_to receive(:new)
worker.perform(deployment.id)
end
end
end
......@@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
"@gitlab/svgs@1.157.0":
version "1.157.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.157.0.tgz#ada33c2b706836a2f5baa2c539f1348791d74859"
integrity sha512-H07Rn4Cy2QW+wnadvuFBSIWrtn8l4hGFLn62f1fT0iYZy58zb/q5/FsShxk9cSKnZYNkXp8I4Nnk/4R7y1MEOw==
"@gitlab/ui@18.1.0":
version "18.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.1.0.tgz#36c1e292cae47d1580d2a3918fe5dd16893e2219"
integrity sha512-oXKTJ07hMFYxXZiJOgbNzVCpz/ooz0rY7D3ISG9ocawGVFVjrwLj41wgNtOzYAnQntxUcgvxNeBt3X6SS/zeTg==
"@gitlab/svgs@1.158.0":
version "1.158.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.158.0.tgz#300d416184a2b0e05f15a96547f726e1825b08a1"
integrity sha512-5OJl+7TsXN9PJhY6/uwi+mTwmDZa9n/6119rf77orQ/joFYUypaYhBmy/1TcKVPsy5Zs6KCxE1kmGsfoXc1TYA==
"@gitlab/ui@18.3.0":
version "18.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.3.0.tgz#c582eca1a0a851823700dabc7f4456feef882d9a"
integrity sha512-H0I3ExZJIqDd9rFDzyZwUerS3ZHDxRf2wHmAzMzK9smq/kr8aL5Pvb2E0KPcgDsVhGQCt7coCBN5NI0p+kf8oQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
......
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