Commit d06de98f authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 7dea1523 d48341c4
......@@ -11,3 +11,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability';
export const TYPE_NOTE = 'Note';
......@@ -232,7 +232,9 @@ export function insertMarkdownText({
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
textToInsert = tag.replace(textPlaceholder, () => selected.replace(/\\n/g, '\n'));
textToInsert = tag.replace(textPlaceholder, () =>
selected.replace(/\\n/g, '\n').replace(/\/(n|t|r)/g, '\\$1'),
);
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
......
......@@ -15,6 +15,14 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
function cleanUpLine(content) {
return unescape(
stripHtml(content)
.replace(/\\(n|t|r)/g, '/$1')
.replace(/\n/g, ''),
);
}
export default {
components: {
GfmAutocomplete,
......@@ -129,7 +137,7 @@ export default {
return text;
}
return unescape(stripHtml(richText).replace(/\n/g, ''));
return cleanUpLine(richText);
})
.join('\\n');
}
......@@ -141,7 +149,7 @@ export default {
return text;
}
return unescape(stripHtml(richText).replace(/\n/g, ''));
return cleanUpLine(richText);
}
return '';
......@@ -272,6 +280,7 @@ export default {
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
data-testid="markdownHeader"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
......
......@@ -83,6 +83,10 @@ module Featurable
end
end
included do
validate :allowed_access_levels
end
def access_level(feature)
public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
......@@ -94,4 +98,21 @@ module Featurable
def string_access_level(feature)
self.class.str_from_access_level(access_level(feature))
end
private
def allowed_access_levels
validator = lambda do |field|
level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > ENABLED
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
(self.class.available_features - feature_validation_exclusion).each {|f| validator.call("#{f}_access_level")}
end
# Features that we should exclude from the validation
def feature_validation_exclusion
[]
end
end
......@@ -54,7 +54,6 @@ class ProjectFeature < ApplicationRecord
validates :project, presence: true
validate :repository_children_level
validate :allowed_access_levels
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
default_value_for :issues_access_level, value: ENABLED, allows_nil: false
......@@ -110,17 +109,6 @@ class ProjectFeature < ApplicationRecord
%i(merge_requests_access_level builds_access_level).each(&validator)
end
# Validates access level for other than pages cannot be PUBLIC
def allowed_access_levels
validator = lambda do |field|
level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > ENABLED
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
(FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")}
end
def get_permission(user, feature)
case access_level(feature)
when DISABLED
......@@ -142,6 +130,10 @@ class ProjectFeature < ApplicationRecord
project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
end
def feature_validation_exclusion
%i(pages)
end
end
ProjectFeature.prepend_mod_with('ProjectFeature')
......@@ -10,16 +10,9 @@ module Ci
private
def process_subsequent_jobs(processable)
if Feature.enabled?(:ci_same_stage_job_needs, processable.project, default_enabled: :yaml)
(stage_dependent_jobs(processable) | needs_dependent_jobs(processable))
.each do |processable|
process(processable)
end
else
skipped_jobs(processable).after_stage(processable.stage_idx)
.find_each do |job|
process(job)
end
(stage_dependent_jobs(processable) | needs_dependent_jobs(processable))
.each do |processable|
process(processable)
end
end
......
---
name: ci_same_stage_job_needs
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59668
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328253
milestone: '14.1'
type: development
group: group::pipeline authoring
default_enabled: true
# frozen_string_literal: true
class RemoveAllowEditingCommitMessagesFromProjectSettings < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
remove_column :project_settings, :allow_editing_commit_messages
end
end
def down
with_lock_retries do
add_column :project_settings, :allow_editing_commit_messages, :boolean, default: false, null: false
end
end
end
b85ef326056bb152d527e34b49caa3c40ee8685c3b14654992246c6adf082f8c
\ No newline at end of file
......@@ -17343,7 +17343,6 @@ CREATE TABLE project_settings (
squash_option smallint DEFAULT 3,
has_confluence boolean DEFAULT false NOT NULL,
has_vulnerabilities boolean DEFAULT false NOT NULL,
allow_editing_commit_messages boolean DEFAULT false NOT NULL,
prevent_merge_without_jira_issue boolean DEFAULT false NOT NULL,
cve_id_request_enabled boolean DEFAULT true NOT NULL,
mr_default_target_self boolean DEFAULT false NOT NULL,
......@@ -1594,8 +1594,7 @@ production:
- In [GitLab 14.1 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/30632) you
can refer to jobs in the same stage as the job you are configuring. This feature is
enabled on GitLab.com and ready for production use. On self-managed [GitLab 14.2 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/30632)
this feature is available by default. To hide the feature, ask an administrator to
[disable the `ci_same_stage_job_needs` flag](../../administration/feature_flags.md).
this feature is available by default.
- In GitLab 14.0 and older, you can only refer to jobs in earlier stages.
- In GitLab 13.9 and older, if `needs:` refers to a job that might not be added to
a pipeline because of `only`, `except`, or `rules`, the pipeline might fail to create.
......
mutation($id: ID!) {
destroyNote(input: { id: $id }) {
errors
note {
id
}
}
}
<script>
import { GlButton, GlSafeHtmlDirective as SafeHtml, GlLoadingIcon } from '@gitlab/ui';
import deleteNoteMutation from 'ee/security_dashboard/graphql/mutations/note_delete.mutation.graphql';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import createFlash from '~/flash';
import { TYPE_NOTE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import HistoryCommentEditor from './history_comment_editor.vue';
......@@ -115,25 +118,26 @@ export default {
});
});
},
deleteComment() {
async deleteComment() {
this.isDeletingComment = true;
const deleteUrl = this.comment.path;
axios
.delete(deleteUrl)
.then(() => {
this.$emit('onCommentDeleted', this.comment);
})
.catch(() =>
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.',
),
}),
)
.finally(() => {
this.isDeletingComment = false;
try {
await this.$apollo.mutate({
mutation: deleteNoteMutation,
variables: {
id: convertToGraphQLId(TYPE_NOTE, this.comment.id),
},
});
this.$emit('onCommentDeleted', this.comment);
} catch (e) {
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.',
),
});
}
this.isDeletingComment = false;
},
cancelEditingComment() {
this.isEditingComment = false;
......
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import deleteNoteMutation from 'ee/security_dashboard/graphql/mutations/note_delete.mutation.graphql';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import HistoryComment from 'ee/vulnerabilities/components/history_comment.vue';
import HistoryCommentEditor from 'ee/vulnerabilities/components/history_comment_editor.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
Vue.use(VueApollo);
describe('History Comment', () => {
let wrapper;
const createWrapper = (comment) => {
const createApolloProvider = (...queries) => {
return createMockApollo([...queries]);
};
const createWrapper = ({ comment, apolloProvider } = {}) => {
wrapper = mount(HistoryComment, {
apolloProvider,
propsData: {
comment,
notesUrl: '/notes',
......@@ -138,7 +149,7 @@ describe('History Comment', () => {
});
describe(`when there's an existing comment`, () => {
beforeEach(() => createWrapper(comment));
beforeEach(() => createWrapper({ comment }));
it('shows the comment with the correct user author and timestamp and the edit/delete buttons', () => {
expectExistingCommentView();
......@@ -187,44 +198,6 @@ describe('History Comment', () => {
});
});
it('deletes the comment when the confirm delete button is clicked', () => {
mockAxios.onDelete().replyOnce(200);
deleteButton().trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
confirmDeleteButton().trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(confirmDeleteButton().props('loading')).toBe(true);
expect(cancelDeleteButton().props('disabled')).toBe(true);
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.delete).toHaveLength(1);
expect(wrapper.emitted().onCommentDeleted).toBeTruthy();
expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(comment);
});
});
it('shows an error message when the comment cannot be deleted', () => {
mockAxios.onDelete().replyOnce(500);
deleteButton().trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
confirmDeleteButton().trigger('click');
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.delete).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
it('saves the comment when the save button is clicked on the comment editor', () => {
const responseData = { ...comment, note: 'new comment' };
mockAxios.onPut().replyOnce(200, responseData);
......@@ -261,9 +234,66 @@ describe('History Comment', () => {
});
});
describe('deleting a note', () => {
it('deletes the comment when the confirm delete button is clicked', async () => {
createWrapper({
comment,
apolloProvider: createApolloProvider([
deleteNoteMutation,
jest.fn().mockResolvedValue({
data: {
destroyNote: {
errors: [],
note: null,
},
},
}),
]),
});
deleteButton().trigger('click');
await wrapper.vm.$nextTick();
confirmDeleteButton().trigger('click');
await wrapper.vm.$nextTick();
expect(confirmDeleteButton().props('loading')).toBe(true);
expect(cancelDeleteButton().props('disabled')).toBe(true);
await axios.waitForAll();
expect(wrapper.emitted().onCommentDeleted).toBeTruthy();
expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(comment);
});
it('shows an error message when the comment cannot be deleted', async () => {
createWrapper({
comment,
apolloProvider: createApolloProvider([
deleteNoteMutation,
jest.fn().mockRejectedValue({
data: {
destroyNote: {
errors: [{ message: 'Something went wrong' }],
note: null,
},
},
}),
]),
});
deleteButton().trigger('click');
await wrapper.vm.$nextTick();
confirmDeleteButton().trigger('click');
await axios.waitForAll();
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('no permission to edit existing comment', () => {
it('does not show the edit/delete buttons if the current user has no edit permissions', () => {
createWrapper({ ...comment, currentUser: { canEdit: false } });
createWrapper({ comment: { ...comment, currentUser: { canEdit: false } } });
expect(editButton().exists()).toBe(false);
expect(deleteButton().exists()).toBe(false);
......
......@@ -54,4 +54,8 @@ RSpec.describe ProjectFeature do
end
end
end
it_behaves_like 'access level validation', ProjectFeature::EE_FEATURES do
let(:container_features) { project.project_feature }
end
end
......@@ -15,12 +15,7 @@ module Gitlab
@context = context
@pipeline = context.pipeline
@seed_attributes = attributes
@stages_for_needs_lookup = if Feature.enabled?(:ci_same_stage_job_needs, @pipeline.project, default_enabled: :yaml)
(previous_stages + [current_stage]).compact
else
previous_stages
end
@stages_for_needs_lookup = (previous_stages + [current_stage]).compact
@needs_attributes = dig(:needs_attributes)
@resource_group_key = attributes.delete(:resource_group_key)
@job_variables = @seed_attributes.delete(:job_variables)
......
......@@ -47,9 +47,7 @@ module Gitlab
validate_job!(name, job)
end
if ::Feature.enabled?(:ci_same_stage_job_needs, @opts[:project], default_enabled: :yaml)
YamlProcessor::Dag.check_circular_dependencies!(@jobs)
end
YamlProcessor::Dag.check_circular_dependencies!(@jobs)
end
def validate_job!(name, job)
......@@ -103,16 +101,8 @@ module Gitlab
job_stage_index = stage_index(name)
dependency_stage_index = stage_index(dependency)
if ::Feature.enabled?(:ci_same_stage_job_needs, @opts[:project], default_enabled: :yaml)
unless dependency_stage_index.present? && dependency_stage_index <= job_stage_index
error!("#{name} job: #{dependency_type} #{dependency} is not defined in current or prior stages")
end
else
# A dependency might be defined later in the configuration
# with a stage that does not exist
unless dependency_stage_index.present? && dependency_stage_index < job_stage_index
error!("#{name} job: #{dependency_type} #{dependency} is not defined in prior stages")
end
unless dependency_stage_index.present? && dependency_stage_index <= job_stage_index
error!("#{name} job: #{dependency_type} #{dependency} is not defined in current or prior stages")
end
end
......
......@@ -88,6 +88,25 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(`${initialValue}\n- `);
});
it('unescapes new line characters', () => {
const initialValue = '';
textArea.value = initialValue;
textArea.selectionStart = 0;
textArea.selectionEnd = 0;
insertMarkdownText({
textArea,
text: textArea.value,
tag: '```suggestion:-0+0\n{text}\n```',
blockTag: true,
selected: '# Does not parse the /n currently.',
wrap: false,
});
expect(textArea.value).toContain('# Does not parse the \\n currently.');
});
it('inserts the tag on the same line if the current line only contains spaces', () => {
const initialValue = ' ';
......
......@@ -28,6 +28,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
data-uploads-path=""
>
<markdown-header-stub
data-testid="markdownHeader"
linecontent=""
suggestionstartindex="0"
/>
......
......@@ -32,7 +32,7 @@ describe('Markdown field component', () => {
axiosMock.restore();
});
function createSubject() {
function createSubject(lines = []) {
// We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression
// caused by mixing Vanilla JS and Vue.
subject = mount(
......@@ -60,6 +60,7 @@ describe('Markdown field component', () => {
markdownPreviewPath,
isSubmitting: false,
textareaValue,
lines,
},
},
);
......@@ -243,4 +244,14 @@ describe('Markdown field component', () => {
});
});
});
describe('suggestions', () => {
it('escapes new line characters', () => {
createSubject([{ rich_text: 'hello world\\n' }]);
expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe(
'hello world/n',
);
});
});
});
......@@ -1140,16 +1140,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
it 'does not have errors' do
expect(subject.errors).to be_empty
end
context 'when ci_same_stage_job_needs FF is disabled' do
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
it 'has errors' do
expect(subject.errors).to contain_exactly("'rspec' job needs 'build' job, but 'build' is not in any previous stage")
end
end
end
context 'when using 101 needs' do
......
......@@ -34,10 +34,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Pipeline do
described_class.new(seed_context, stages_attributes)
end
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
describe '#stages' do
it 'returns the stage resources' do
stages = seed.stages
......
......@@ -590,14 +590,6 @@ module Gitlab
end
it_behaves_like 'has warnings and expected error', /build job: need test is not defined in current or prior stages/
context 'with ci_same_stage_job_needs FF disabled' do
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
it_behaves_like 'has warnings and expected error', /build job: need test is not defined in prior stages/
end
end
end
end
......@@ -1809,14 +1801,6 @@ module Gitlab
let(:dependencies) { ['deploy'] }
it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in current or prior stages'
context 'with ci_same_stage_job_needs FF disabled' do
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in prior stages'
end
end
context 'when a job depends on another job that references a not-yet defined stage' do
......@@ -2053,14 +2037,6 @@ module Gitlab
let(:needs) { ['deploy'] }
it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in current or prior stages'
context 'with ci_same_stage_job_needs FF disabled' do
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in prior stages'
end
end
context 'needs and dependencies that are mismatching' do
......
......@@ -30,8 +30,11 @@ RSpec.describe Featurable do
describe '.set_available_features' do
let!(:klass) do
Class.new do
Class.new(ApplicationRecord) do
include Featurable
self.table_name = 'project_features'
set_available_features %i(feature1 feature2)
def feature1_access_level
......
......@@ -41,18 +41,15 @@ RSpec.describe ProjectFeature do
end
end
context 'public features' do
features = ProjectFeature::FEATURES - %i(pages)
it_behaves_like 'access level validation', ProjectFeature::FEATURES - %i(pages) do
let(:container_features) { project.project_feature }
end
features.each do |feature|
it "does not allow public access level for #{feature}" do
project_feature = project.project_feature
field = "#{feature}_access_level".to_sym
project_feature.update_attribute(field, ProjectFeature::PUBLIC)
it 'allows public access level for :pages feature' do
project_feature = project.project_feature
project_feature.pages_access_level = ProjectFeature::PUBLIC
expect(project_feature.valid?).to be_falsy, "#{field} failed"
end
end
expect(project_feature.valid?).to be_truthy
end
describe 'default pages access level' do
......
......@@ -44,16 +44,6 @@ RSpec.describe Ci::AfterRequeueJobService do
it 'marks subsequent skipped jobs as processable' do
expect { execute_service }.to change { test4.reload.status }.from('skipped').to('created')
end
context 'with ci_same_stage_job_needs FF disabled' do
before do
stub_feature_flags(ci_same_stage_job_needs: false)
end
it 'does nothing with the build' do
expect { execute_service }.not_to change { test4.reload.status }
end
end
end
context 'when the pipeline is a downstream pipeline and the bridge is depended' do
......
# frozen_string_literal: true
RSpec.shared_examples 'access level validation' do |features|
features.each do |feature|
it "does not allow public access level for #{feature}" do
field = "#{feature}_access_level".to_sym
container_features.update_attribute(field, ProjectFeature::PUBLIC)
expect(container_features.valid?).to be_falsy, "#{field} failed"
end
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