Commit fbd8cf89 authored by Paul Slaughter's avatar Paul Slaughter

Handle different commit errors in IDE

Currently parses for:
- CODEOWNERS violation
- Branch changed
parent 49d7ee1e
...@@ -8,6 +8,7 @@ import Actions from './actions.vue'; ...@@ -8,6 +8,7 @@ import Actions from './actions.vue';
import SuccessMessage from './success_message.vue'; import SuccessMessage from './success_message.vue';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
import consts from '../../stores/modules/commit/constants'; import consts from '../../stores/modules/commit/constants';
import { createUnexpectedCommitError } from '../../lib/errors';
export default { export default {
components: { components: {
...@@ -21,11 +22,13 @@ export default { ...@@ -21,11 +22,13 @@ export default {
return { return {
isCompact: true, isCompact: true,
componentHeight: null, componentHeight: null,
// Keep track of "lastCommitError" so we hold onto the value even when "commitError" is cleared.
lastCommitError: createUnexpectedCommitError(),
}; };
}, },
computed: { computed: {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapState('commit', ['commitMessage', 'submitCommitLoading', 'commitError']),
...mapGetters(['someUncommittedChanges']), ...mapGetters(['someUncommittedChanges']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() { overviewText() {
...@@ -38,11 +41,28 @@ export default { ...@@ -38,11 +41,28 @@ export default {
currentViewIsCommitView() { currentViewIsCommitView() {
return this.currentActivityView === leftSidebarViews.commit.name; return this.currentActivityView === leftSidebarViews.commit.name;
}, },
commitErrorPrimaryAction() {
if (!this.lastCommitError?.canCreateBranch) {
return undefined;
}
return {
text: __('Create new branch'),
};
},
}, },
watch: { watch: {
currentActivityView: 'handleCompactState', currentActivityView: 'handleCompactState',
someUncommittedChanges: 'handleCompactState', someUncommittedChanges: 'handleCompactState',
lastCommitMsg: 'handleCompactState', lastCommitMsg: 'handleCompactState',
commitError(val) {
if (!val) {
return;
}
this.lastCommitError = val;
this.$refs.commitErrorModal.show();
},
}, },
methods: { methods: {
...mapActions(['updateActivityBarView']), ...mapActions(['updateActivityBarView']),
...@@ -53,9 +73,7 @@ export default { ...@@ -53,9 +73,7 @@ export default {
'updateCommitAction', 'updateCommitAction',
]), ]),
commit() { commit() {
return this.commitChanges().catch(() => { return this.commitChanges();
this.$refs.createBranchModal.show();
});
}, },
forceCreateNewBranch() { forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit()); return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
...@@ -164,17 +182,14 @@ export default { ...@@ -164,17 +182,14 @@ export default {
</button> </button>
</div> </div>
<gl-modal <gl-modal
ref="createBranchModal" ref="commitErrorModal"
modal-id="ide-create-branch-modal" modal-id="ide-commit-error-modal"
:ok-title="__('Create new branch')" :title="lastCommitError.title"
:title="__('Branch has changed')" :action-primary="commitErrorPrimaryAction"
ok-variant="success" :action-cancel="{ text: __('Cancel') }"
@ok="forceCreateNewBranch" @ok="forceCreateNewBranch"
> >
{{ {{ lastCommitError.message }}
__(`This branch has changed since you started editing.
Would you like to create a new branch?`)
}}
</gl-modal> </gl-modal>
</form> </form>
</transition> </transition>
......
import { __ } from '~/locale';
import { joinSentences } from '~/lib/utils/text_utility';
const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/;
const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/;
export const createUnexpectedCommitError = () => ({
title: __('Unexpected error'),
message: __('Could not commit. An unexpected error occurred.'),
canCreateBranch: false,
});
export const createCodeownersCommitError = message => ({
title: __('CODEOWNERS rule violation'),
message,
canCreateBranch: true,
});
export const createBranchChangedCommitError = message => ({
title: __('Branch changed'),
message: joinSentences(message, __('Would you like to create a new branch?')),
canCreateBranch: true,
});
export const parseCommitError = e => {
const { message } = e?.response?.data || {};
if (!message) {
return createUnexpectedCommitError();
}
if (CODEOWNERS_REGEX.test(message)) {
return createCodeownersCommitError(message);
} else if (BRANCH_CHANGED_REGEX.test(message)) {
return createBranchChangedCommitError(message);
}
return createUnexpectedCommitError();
};
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { deprecatedCreateFlash as flash } from '~/flash'; import { deprecatedCreateFlash as flash } from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import * as rootTypes from '../../mutation_types'; import * as rootTypes from '../../mutation_types';
import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import service from '../../../services'; import service from '../../../services';
...@@ -8,6 +7,7 @@ import * as types from './mutation_types'; ...@@ -8,6 +7,7 @@ import * as types from './mutation_types';
import consts from './constants'; import consts from './constants';
import { leftSidebarViews } from '../../../constants'; import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub'; import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
export const updateCommitMessage = ({ commit }, message) => { export const updateCommitMessage = ({ commit }, message) => {
commit(types.UPDATE_COMMIT_MESSAGE, message); commit(types.UPDATE_COMMIT_MESSAGE, message);
...@@ -113,6 +113,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ...@@ -113,6 +113,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
? Promise.resolve() ? Promise.resolve()
: dispatch('stageAllChanges', null, { root: true }); : dispatch('stageAllChanges', null, { root: true });
commit(types.CLEAR_ERROR);
commit(types.UPDATE_LOADING, true); commit(types.UPDATE_LOADING, true);
return stageFilesPromise return stageFilesPromise
...@@ -128,6 +129,12 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ...@@ -128,6 +129,12 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
return service.commit(rootState.currentProjectId, payload); return service.commit(rootState.currentProjectId, payload);
}) })
.catch(e => {
commit(types.UPDATE_LOADING, false);
commit(types.SET_ERROR, parseCommitError(e));
throw e;
})
.then(({ data }) => { .then(({ data }) => {
commit(types.UPDATE_LOADING, false); commit(types.UPDATE_LOADING, false);
...@@ -214,24 +221,5 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ...@@ -214,24 +221,5 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
{ root: true }, { root: true },
), ),
); );
})
.catch(err => {
commit(types.UPDATE_LOADING, false);
// don't catch bad request errors, let the view handle them
if (err.response.status === httpStatusCodes.BAD_REQUEST) throw err;
dispatch(
'setErrorMessage',
{
text: __('An error occurred while committing your changes.'),
action: () =>
dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true })),
actionText: __('Please try again'),
},
{ root: true },
);
window.dispatchEvent(new Event('resize'));
}); });
}; };
...@@ -3,3 +3,6 @@ export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; ...@@ -3,3 +3,6 @@ export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
export const UPDATE_LOADING = 'UPDATE_LOADING'; export const UPDATE_LOADING = 'UPDATE_LOADING';
export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR'; export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR';
export const CLEAR_ERROR = 'CLEAR_ERROR';
export const SET_ERROR = 'SET_ERROR';
...@@ -24,4 +24,10 @@ export default { ...@@ -24,4 +24,10 @@ export default {
shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR, shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR,
}); });
}, },
[types.CLEAR_ERROR](state) {
state.commitError = null;
},
[types.SET_ERROR](state, error) {
state.commitError = error;
},
}; };
...@@ -4,4 +4,5 @@ export default () => ({ ...@@ -4,4 +4,5 @@ export default () => ({
newBranchName: '', newBranchName: '',
submitCommitLoading: false, submitCommitLoading: false,
shouldCreateMR: true, shouldCreateMR: true,
commitError: null,
}); });
...@@ -399,3 +399,24 @@ export const truncateNamespace = (string = '') => { ...@@ -399,3 +399,24 @@ export const truncateNamespace = (string = '') => {
* @returns {Boolean} * @returns {Boolean}
*/ */
export const hasContent = obj => isString(obj) && obj.trim() !== ''; export const hasContent = obj => isString(obj) && obj.trim() !== '';
/**
* Joins the given sentences by adding periods if necessary.
*
* @param {...string} sentences
*/
export const joinSentences = (...sentences) =>
sentences.reduce((acc, sentence) => {
if (!sentence?.trim()) {
return acc;
} else if (!acc) {
return sentence;
} else if (/[.!?]\s*$/.test(acc)) {
const endsWithSpace = /\s$/.test(acc);
const sep = endsWithSpace ? '' : ' ';
return `${acc}${sep}${sentence}`;
}
return `${acc}. ${sentence}`;
}, '');
---
title: Fix error reporting for Web IDE commits
merge_request: 42383
author:
type: fixed
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'EE IDE user commits changes', :js do
include WebIdeSpecHelpers
let(:project) { create(:project, :custom_repo, files: { 'docs/CODEOWNERS' => "[Backend]\n*.rb @ruby-owner" }) }
let(:ruby_owner) { create(:user, username: 'ruby-owner') }
let(:user) { project.owner }
before do
stub_licensed_features(code_owners: true, code_owner_approval_required: true)
project.add_developer(ruby_owner)
create(:protected_branch,
name: 'master',
code_owner_approval_required: true,
project: project)
sign_in(user)
ide_visit(project)
end
it 'shows error message' do
ide_create_new_file('test.rb', content: '# A ruby file')
ide_commit
expect(page).to have_content('CODEOWNERS rule violation')
end
end
...@@ -2673,9 +2673,6 @@ msgstr "" ...@@ -2673,9 +2673,6 @@ msgstr ""
msgid "An error occurred while checking group path. Please refresh and try again." msgid "An error occurred while checking group path. Please refresh and try again."
msgstr "" msgstr ""
msgid "An error occurred while committing your changes."
msgstr ""
msgid "An error occurred while creating the issue. Please try again." msgid "An error occurred while creating the issue. Please try again."
msgstr "" msgstr ""
...@@ -4106,7 +4103,7 @@ msgstr "" ...@@ -4106,7 +4103,7 @@ msgstr ""
msgid "Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" msgid "Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
msgstr "" msgstr ""
msgid "Branch has changed" msgid "Branch changed"
msgstr "" msgstr ""
msgid "Branch is already taken" msgid "Branch is already taken"
...@@ -4412,6 +4409,9 @@ msgstr "" ...@@ -4412,6 +4409,9 @@ msgstr ""
msgid "CLOSED (MOVED)" msgid "CLOSED (MOVED)"
msgstr "" msgstr ""
msgid "CODEOWNERS rule violation"
msgstr ""
msgid "CONTRIBUTING" msgid "CONTRIBUTING"
msgstr "" msgstr ""
...@@ -7126,6 +7126,9 @@ msgstr "" ...@@ -7126,6 +7126,9 @@ msgstr ""
msgid "Could not change HEAD: branch '%{branch}' does not exist" msgid "Could not change HEAD: branch '%{branch}' does not exist"
msgstr "" msgstr ""
msgid "Could not commit. An unexpected error occurred."
msgstr ""
msgid "Could not connect to FogBugz, check your URL" msgid "Could not connect to FogBugz, check your URL"
msgstr "" msgstr ""
...@@ -25643,9 +25646,6 @@ msgstr "" ...@@ -25643,9 +25646,6 @@ msgstr ""
msgid "This board's scope is reduced" msgid "This board's scope is reduced"
msgstr "" msgstr ""
msgid "This branch has changed since you started editing. Would you like to create a new branch?"
msgstr ""
msgid "This chart could not be displayed" msgid "This chart could not be displayed"
msgstr "" msgstr ""
...@@ -26994,6 +26994,9 @@ msgstr "" ...@@ -26994,6 +26994,9 @@ msgstr ""
msgid "Undo ignore" msgid "Undo ignore"
msgstr "" msgstr ""
msgid "Unexpected error"
msgstr ""
msgid "Unfortunately, your email message to GitLab could not be processed." msgid "Unfortunately, your email message to GitLab could not be processed."
msgstr "" msgstr ""
...@@ -28653,6 +28656,9 @@ msgstr "" ...@@ -28653,6 +28656,9 @@ msgstr ""
msgid "Workflow Help" msgid "Workflow Help"
msgstr "" msgstr ""
msgid "Would you like to create a new branch?"
msgstr ""
msgid "Write" msgid "Write"
msgstr "" msgstr ""
......
import Vue from 'vue'; import Vue from 'vue';
import { getByText } from '@testing-library/dom';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { projectData } from 'jest/ide/mock_data'; import { projectData } from 'jest/ide/mock_data';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { createStore } from '~/ide/stores'; import { createStore } from '~/ide/stores';
import consts from '~/ide/stores/modules/commit/constants';
import CommitForm from '~/ide/components/commit_sidebar/form.vue'; import CommitForm from '~/ide/components/commit_sidebar/form.vue';
import { leftSidebarViews } from '~/ide/constants'; import { leftSidebarViews } from '~/ide/constants';
import { createCodeownersCommitError, createUnexpectedCommitError } from '~/ide/lib/errors';
describe('IDE commit form', () => { describe('IDE commit form', () => {
const Component = Vue.extend(CommitForm); const Component = Vue.extend(CommitForm);
...@@ -259,21 +262,45 @@ describe('IDE commit form', () => { ...@@ -259,21 +262,45 @@ describe('IDE commit form', () => {
}); });
}); });
it('opens new branch modal if commitChanges throws an error', () => { it.each`
vm.commitChanges.mockRejectedValue({ success: false }); createError | props
${() => createCodeownersCommitError('test message')} | ${{ actionPrimary: { text: 'Create new branch' } }}
${createUnexpectedCommitError} | ${{ actionPrimary: null }}
`('opens error modal if commitError with $error', async ({ createError, props }) => {
jest.spyOn(vm.$refs.commitErrorModal, 'show');
jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation(); const error = createError();
vm.$store.state.commit.commitError = error;
return vm await vm.$nextTick();
.$nextTick()
.then(() => {
vm.$el.querySelector('.btn-success').click();
return vm.$nextTick(); expect(vm.$refs.commitErrorModal.show).toHaveBeenCalled();
}) expect(vm.$refs.commitErrorModal).toMatchObject({
.then(() => { actionCancel: { text: 'Cancel' },
expect(vm.$refs.createBranchModal.show).toHaveBeenCalled(); ...props,
});
expect(document.body).toHaveText(error.message);
});
}); });
describe('with error modal with primary', () => {
beforeEach(() => {
jest.spyOn(vm.$store, 'dispatch').mockReturnValue(Promise.resolve());
});
it('updates commit action and commits', async () => {
vm.$store.state.commit.commitError = createCodeownersCommitError('test message');
await vm.$nextTick();
getByText(document.body, 'Create new branch').click();
await waitForPromises();
expect(vm.$store.dispatch.mock.calls).toEqual([
['commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH],
['commit/commitChanges', undefined],
]);
}); });
}); });
}); });
......
import {
createUnexpectedCommitError,
createCodeownersCommitError,
createBranchChangedCommitError,
parseCommitError,
} from '~/ide/lib/errors';
const TEST_MESSAGE = 'Test message';
const TEST_MESSAGE_WITH_SENTENCE = 'Test message.';
const TEST_MESSAGE_WITH_SENTENCE_AND_SPACE = 'Test message. ';
const CODEOWNERS_MESSAGE =
'Push to protected branches that contain changes to files matching CODEOWNERS is not allowed';
const CHANGED_MESSAGE = 'Things changed since you started editing';
describe('~/ide/lib/errors', () => {
const createResponseError = message => ({
response: {
data: {
message,
},
},
});
describe('createCodeownersCommitError', () => {
it('uses given message', () => {
expect(createCodeownersCommitError(TEST_MESSAGE)).toEqual({
title: 'CODEOWNERS rule violation',
message: TEST_MESSAGE,
canCreateBranch: true,
});
});
});
describe('createBranchChangedCommitError', () => {
it.each`
message | expectedMessage
${TEST_MESSAGE} | ${`${TEST_MESSAGE}. Would you like to create a new branch?`}
${TEST_MESSAGE_WITH_SENTENCE} | ${`${TEST_MESSAGE}. Would you like to create a new branch?`}
${TEST_MESSAGE_WITH_SENTENCE_AND_SPACE} | ${`${TEST_MESSAGE}. Would you like to create a new branch?`}
`('uses given message="$message"', ({ message, expectedMessage }) => {
expect(createBranchChangedCommitError(message)).toEqual({
title: 'Branch changed',
message: expectedMessage,
canCreateBranch: true,
});
});
});
describe('parseCommitError', () => {
it.each`
message | expectation
${null} | ${createUnexpectedCommitError()}
${{}} | ${createUnexpectedCommitError()}
${{ response: {} }} | ${createUnexpectedCommitError()}
${{ response: { data: {} } }} | ${createUnexpectedCommitError()}
${createResponseError('test')} | ${createUnexpectedCommitError()}
${createResponseError(CODEOWNERS_MESSAGE)} | ${createCodeownersCommitError(CODEOWNERS_MESSAGE)}
${createResponseError(CHANGED_MESSAGE)} | ${createBranchChangedCommitError(CHANGED_MESSAGE)}
`('parses message into error object with "$message"', ({ message, expectation }) => {
expect(parseCommitError(message)).toEqual(expectation);
});
});
});
...@@ -9,6 +9,7 @@ import eventHub from '~/ide/eventhub'; ...@@ -9,6 +9,7 @@ import eventHub from '~/ide/eventhub';
import consts from '~/ide/stores/modules/commit/constants'; import consts from '~/ide/stores/modules/commit/constants';
import * as mutationTypes from '~/ide/stores/modules/commit/mutation_types'; import * as mutationTypes from '~/ide/stores/modules/commit/mutation_types';
import * as actions from '~/ide/stores/modules/commit/actions'; import * as actions from '~/ide/stores/modules/commit/actions';
import { createUnexpectedCommitError } from '~/ide/lib/errors';
import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants'; import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants';
import testAction from '../../../../helpers/vuex_action_helper'; import testAction from '../../../../helpers/vuex_action_helper';
...@@ -510,7 +511,7 @@ describe('IDE commit module actions', () => { ...@@ -510,7 +511,7 @@ describe('IDE commit module actions', () => {
}); });
}); });
describe('failed', () => { describe('success response with failed message', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(service, 'commit').mockResolvedValue({ jest.spyOn(service, 'commit').mockResolvedValue({
data: { data: {
...@@ -533,6 +534,25 @@ describe('IDE commit module actions', () => { ...@@ -533,6 +534,25 @@ describe('IDE commit module actions', () => {
}); });
}); });
describe('failed response', () => {
beforeEach(() => {
jest.spyOn(service, 'commit').mockRejectedValue({});
});
it('commits error updates', async () => {
jest.spyOn(store, 'commit');
await store.dispatch('commit/commitChanges').catch(() => {});
expect(store.commit.mock.calls).toEqual([
expect.arrayContaining(['commit/CLEAR_ERROR']),
expect.arrayContaining(['commit/UPDATE_LOADING', true]),
expect.arrayContaining(['commit/UPDATE_LOADING', false]),
expect.arrayContaining(['commit/SET_ERROR', createUnexpectedCommitError()]),
]);
});
});
describe('first commit of a branch', () => { describe('first commit of a branch', () => {
const COMMIT_RESPONSE = { const COMMIT_RESPONSE = {
id: '123456', id: '123456',
......
import commitState from '~/ide/stores/modules/commit/state'; import commitState from '~/ide/stores/modules/commit/state';
import mutations from '~/ide/stores/modules/commit/mutations'; import mutations from '~/ide/stores/modules/commit/mutations';
import * as types from '~/ide/stores/modules/commit/mutation_types';
describe('IDE commit module mutations', () => { describe('IDE commit module mutations', () => {
let state; let state;
...@@ -62,4 +63,24 @@ describe('IDE commit module mutations', () => { ...@@ -62,4 +63,24 @@ describe('IDE commit module mutations', () => {
expect(state.shouldCreateMR).toBe(false); expect(state.shouldCreateMR).toBe(false);
}); });
}); });
describe(types.CLEAR_ERROR, () => {
it('should clear commitError', () => {
state.commitError = {};
mutations[types.CLEAR_ERROR](state);
expect(state.commitError).toBeNull();
});
});
describe(types.SET_ERROR, () => {
it('should set commitError', () => {
const error = { title: 'foo' };
mutations[types.SET_ERROR](state, error);
expect(state.commitError).toBe(error);
});
});
}); });
...@@ -325,4 +325,17 @@ describe('text_utility', () => { ...@@ -325,4 +325,17 @@ describe('text_utility', () => {
expect(textUtils.hasContent(txt)).toEqual(result); expect(textUtils.hasContent(txt)).toEqual(result);
}); });
}); });
describe('joinSentences', () => {
it.each`
input | output
${[]} | ${''}
${['Lorem ipsum']} | ${'Lorem ipsum'}
${['Lorem ipsum', null, 'Dolar sit']} | ${'Lorem ipsum. Dolar sit'}
${['Lorem ipsum!', 'Dolar sit']} | ${'Lorem ipsum! Dolar sit'}
${['Lorem ipsum? ', 'Dolar sit']} | ${'Lorem ipsum? Dolar sit'}
`('joins the sentences with periods ($input)', ({ input, output }) => {
expect(textUtils.joinSentences(...input)).toBe(output);
});
});
}); });
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