Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-07-17 03:09:14 +00:00
parent d5cf5cf4f7
commit 4bc0e06402
83 changed files with 1609 additions and 463 deletions

View File

@ -1,5 +1,5 @@
<script>
import { GlDeprecatedButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import {
RICH_BLOB_VIEWER,
RICH_BLOB_VIEWER_TITLE,
@ -11,7 +11,7 @@ export default {
components: {
GlIcon,
GlButtonGroup,
GlDeprecatedButton,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -46,7 +46,7 @@ export default {
</script>
<template>
<gl-button-group class="js-blob-viewer-switcher mx-2">
<gl-deprecated-button
<gl-button
v-gl-tooltip.hover
:aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
:title="$options.SIMPLE_BLOB_VIEWER_TITLE"
@ -55,8 +55,8 @@ export default {
@click="switchToViewer($options.SIMPLE_BLOB_VIEWER)"
>
<gl-icon name="code" :size="14" />
</gl-deprecated-button>
<gl-deprecated-button
</gl-button>
<gl-button
v-gl-tooltip.hover
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
@ -65,6 +65,6 @@ export default {
@click="switchToViewer($options.RICH_BLOB_VIEWER)"
>
<gl-icon name="document" :size="14" />
</gl-deprecated-button>
</gl-button>
</gl-button-group>
</template>

View File

@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { GlFormGroup, GlToggle } from '@gitlab/ui';
@ -21,6 +22,9 @@ export default {
activated: this.initialActivated,
};
},
computed: {
...mapGetters(['isInheriting']),
},
mounted() {
// Initialize view
this.$nextTick(() => {
@ -42,6 +46,7 @@ export default {
v-model="activated"
name="service[active]"
class="gl-display-block gl-line-height-0"
:disabled="isInheriting"
@change="onToggle"
/>
</gl-form-group>
@ -50,7 +55,12 @@ export default {
<div class="form-group row" role="group">
<label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label>
<div class="col-sm-10 pt-1">
<gl-toggle v-model="activated" name="service[active]" @change="onToggle" />
<gl-toggle
v-model="activated"
name="service[active]"
:disabled="isInheriting"
@change="onToggle"
/>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
@ -59,6 +60,7 @@ export default {
};
},
computed: {
...mapGetters(['isInheriting']),
isCheckbox() {
return this.type === 'checkbox';
},
@ -107,6 +109,7 @@ export default {
id: this.fieldId,
name: this.fieldName,
state: this.valid,
readonly: this.isInheriting,
};
},
valid() {
@ -142,12 +145,15 @@ export default {
</template>
<template v-if="isCheckbox">
<input :name="fieldName" type="hidden" value="false" />
<gl-form-checkbox v-model="model" v-bind="sharedProps">
<input :name="fieldName" type="hidden" :value="model || false" />
<gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting">
{{ humanizedTitle }}
</gl-form-checkbox>
</template>
<gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" />
<template v-else-if="isSelect">
<input type="hidden" :name="fieldName" :value="model" />
<gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" />
</template>
<gl-form-textarea
v-else-if="isTextarea"
v-model="model"

View File

@ -1,5 +1,8 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import OverrideDropdown from './override_dropdown.vue';
import ActiveToggle from './active_toggle.vue';
import JiraTriggerFields from './jira_trigger_fields.vue';
import JiraIssuesFields from './jira_issues_fields.vue';
@ -9,6 +12,7 @@ import DynamicField from './dynamic_field.vue';
export default {
name: 'IntegrationForm',
components: {
OverrideDropdown,
ActiveToggle,
JiraTriggerFields,
JiraIssuesFields,
@ -16,55 +20,55 @@ export default {
DynamicField,
},
mixins: [glFeatureFlagsMixin()],
props: {
activeToggleProps: {
type: Object,
required: true,
},
showActive: {
type: Boolean,
required: true,
},
triggerFieldsProps: {
type: Object,
required: true,
},
jiraIssuesProps: {
type: Object,
required: true,
},
triggerEvents: {
type: Array,
required: false,
default: () => [],
},
fields: {
type: Array,
required: false,
default: () => [],
},
type: {
type: String,
required: true,
},
},
computed: {
...mapGetters(['currentKey', 'propsSource']),
...mapState(['adminState', 'override']),
isJira() {
return this.type === 'jira';
return this.propsSource.type === 'jira';
},
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
},
},
methods: {
...mapActions(['setOverride']),
},
};
</script>
<template>
<div>
<active-toggle v-if="showActive" v-bind="activeToggleProps" />
<jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
<trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
<dynamic-field v-for="field in fields" :key="field.name" v-bind="field" />
<jira-issues-fields v-if="showJiraIssuesFields" v-bind="jiraIssuesProps" />
<override-dropdown
v-if="adminState !== null"
:inherit-from-id="adminState.id"
:override="override"
@change="setOverride"
/>
<active-toggle
v-if="propsSource.showActive"
:key="`${currentKey}-active-toggle`"
v-bind="propsSource.activeToggleProps"
/>
<jira-trigger-fields
v-if="isJira"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
/>
<trigger-fields
v-else-if="propsSource.triggerEvents.length"
:key="`${currentKey}-trigger-fields`"
:events="propsSource.triggerEvents"
:type="propsSource.type"
/>
<dynamic-field
v-for="field in propsSource.fields"
:key="`${currentKey}-${field.name}`"
v-bind="field"
/>
<jira-issues-fields
v-if="showJiraIssuesFields"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
/>
</div>
</template>

View File

@ -89,8 +89,8 @@ export default {
}}
</p>
<template v-if="showJiraIssuesIntegration">
<input name="service[issues_enabled]" type="hidden" value="false" />
<gl-form-checkbox v-model="enableJiraIssues" name="service[issues_enabled]">
<input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" />
<gl-form-checkbox v-model="enableJiraIssues">
{{ s__('JiraService|Enable Jira issues') }}
<template #help>
{{

View File

@ -1,5 +1,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
@ -55,6 +56,7 @@ export default {
};
},
computed: {
...mapGetters(['isInheriting']),
showEnableComments() {
return this.triggerCommit || this.triggerMergeRequest;
},
@ -73,13 +75,17 @@ export default {
)
"
>
<input name="service[commit_events]" type="hidden" value="false" />
<gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
<gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting">
{{ __('Commit') }}
</gl-form-checkbox>
<input name="service[merge_requests_events]" type="hidden" value="false" />
<gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
<input
name="service[merge_requests_events]"
type="hidden"
:value="triggerMergeRequest || false"
/>
<gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting">
{{ __('Merge request') }}
</gl-form-checkbox>
</gl-form-group>
@ -89,8 +95,12 @@ export default {
:label="s__('Integrations|Comment settings:')"
data-testid="comment-settings"
>
<input name="service[comment_on_event_enabled]" type="hidden" value="false" />
<gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
<input
name="service[comment_on_event_enabled]"
type="hidden"
:value="enableComments || false"
/>
<gl-form-checkbox v-model="enableComments" :disabled="isInheriting">
{{ s__('Integrations|Enable comments') }}
</gl-form-checkbox>
</gl-form-group>
@ -100,12 +110,18 @@ export default {
:label="s__('Integrations|Comment detail:')"
data-testid="comment-detail"
>
<input
v-if="isInheriting"
name="service[comment_detail]"
type="hidden"
:value="commentDetail"
/>
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
v-model="commentDetail"
:value="commentDetailOption.value"
name="service[comment_detail]"
:disabled="isInheriting"
>
{{ commentDetailOption.label }}
<template #help>
@ -126,13 +142,17 @@ export default {
}}
</label>
<input name="service[commit_events]" type="hidden" value="false" />
<gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
<gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting">
{{ __('Commit') }}
</gl-form-checkbox>
<input name="service[merge_requests_events]" type="hidden" value="false" />
<gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
<input
name="service[merge_requests_events]"
type="hidden"
:value="triggerMergeRequest || false"
/>
<gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting">
{{ __('Merge request') }}
</gl-form-checkbox>
@ -144,8 +164,12 @@ export default {
<label>
{{ s__('Integrations|Comment settings:') }}
</label>
<input name="service[comment_on_event_enabled]" type="hidden" value="false" />
<gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
<input
name="service[comment_on_event_enabled]"
type="hidden"
:value="enableComments || false"
/>
<gl-form-checkbox v-model="enableComments" :disabled="isInheriting">
{{ s__('Integrations|Enable comments') }}
</gl-form-checkbox>
@ -153,12 +177,18 @@ export default {
<label>
{{ s__('Integrations|Comment detail:') }}
</label>
<input
v-if="isInheriting"
name="service[comment_detail]"
type="hidden"
:value="commentDetail"
/>
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
v-model="commentDetail"
:value="commentDetailOption.value"
name="service[comment_detail]"
:disabled="isInheriting"
>
{{ commentDetailOption.label }}
<template #help>

View File

@ -0,0 +1,63 @@
<script>
import { s__ } from '~/locale';
import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui';
const dropdownOptions = [
{
value: false,
text: s__('Integrations|Use instance level settings'),
},
{
value: true,
text: s__('Integrations|Use custom settings'),
},
];
export default {
dropdownOptions,
name: 'OverrideDropdown',
components: {
GlNewDropdown,
GlNewDropdownItem,
},
props: {
inheritFromId: {
type: Number,
required: true,
},
override: {
type: Boolean,
required: true,
},
},
data() {
return {
selected: dropdownOptions.find(x => x.value === this.override),
};
},
methods: {
onClick(option) {
this.selected = option;
this.$emit('change', option.value);
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<span>{{ s__('Integrations|This integration has multiple settings available.') }}</span>
<input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" />
<gl-new-dropdown :text="selected.text">
<gl-new-dropdown-item
v-for="option in $options.dropdownOptions"
:key="option.value"
@click="onClick(option)"
>
{{ option.text }}
</gl-new-dropdown-item>
</gl-new-dropdown>
</div>
</template>

View File

@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { startCase } from 'lodash';
import { __ } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
@ -32,6 +33,7 @@ export default {
},
},
computed: {
...mapGetters(['isInheriting']),
placeholder() {
return placeholderForType[this.type];
},
@ -57,8 +59,8 @@ export default {
>
<div id="trigger-fields" class="gl-pt-3">
<gl-form-group v-for="event in events" :key="event.title" :description="event.description">
<input :name="checkboxName(event.name)" type="hidden" value="false" />
<gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)">
<input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" />
<gl-form-checkbox v-model="event.value" :disabled="isInheriting">
{{ startCase(event.title) }}
</gl-form-checkbox>
<gl-form-input
@ -66,6 +68,7 @@ export default {
v-model="event.field.value"
:name="fieldName(event.field.name)"
:placeholder="placeholder"
:readonly="isInheriting"
/>
</gl-form-group>
</div>

View File

@ -1,21 +1,19 @@
import Vue from 'vue';
import { createStore } from './store';
import { parseBoolean } from '~/lib/utils/common_utils';
import IntegrationForm from './components/integration_form.vue';
export default el => {
if (!el) {
return null;
}
function parseBooleanInData(data) {
const result = {};
Object.entries(data).forEach(([key, value]) => {
result[key] = parseBoolean(value);
});
return result;
}
function parseBooleanInData(data) {
const result = {};
Object.entries(data).forEach(([key, value]) => {
result[key] = parseBoolean(value);
});
return result;
}
function parseDatasetToProps(data) {
const {
id,
type,
commentDetail,
projectKey,
@ -23,8 +21,9 @@ export default el => {
editProjectPath,
triggerEvents,
fields,
inheritFromId,
...booleanAttributes
} = el.dataset;
} = data;
const {
showActive,
activated,
@ -35,33 +34,53 @@ export default el => {
enableJiraIssues,
} = parseBooleanInData(booleanAttributes);
return {
activeToggleProps: {
initialActivated: activated,
},
showActive,
type,
triggerFieldsProps: {
initialTriggerCommit: commitEvents,
initialTriggerMergeRequest: mergeRequestEvents,
initialEnableComments: enableComments,
initialCommentDetail: commentDetail,
},
jiraIssuesProps: {
showJiraIssuesIntegration,
initialEnableJiraIssues: enableJiraIssues,
initialProjectKey: projectKey,
upgradePlanPath,
editProjectPath,
},
triggerEvents: JSON.parse(triggerEvents),
fields: JSON.parse(fields),
inheritFromId: parseInt(inheritFromId, 10),
id: parseInt(id, 10),
};
}
export default (el, adminEl) => {
if (!el) {
return null;
}
const props = parseDatasetToProps(el.dataset);
const initialState = {
adminState: null,
customState: props,
};
if (adminEl) {
initialState.adminState = Object.freeze(parseDatasetToProps(adminEl.dataset));
}
return new Vue({
el,
store: createStore(initialState),
render(createElement) {
return createElement(IntegrationForm, {
props: {
activeToggleProps: {
initialActivated: activated,
},
showActive,
type,
triggerFieldsProps: {
initialTriggerCommit: commitEvents,
initialTriggerMergeRequest: mergeRequestEvents,
initialEnableComments: enableComments,
initialCommentDetail: commentDetail,
},
jiraIssuesProps: {
showJiraIssuesIntegration,
initialEnableJiraIssues: enableJiraIssues,
initialProjectKey: projectKey,
upgradePlanPath,
editProjectPath,
},
triggerEvents: JSON.parse(triggerEvents),
fields: JSON.parse(fields),
},
});
return createElement(IntegrationForm);
},
});
};

View File

@ -0,0 +1,4 @@
import * as types from './mutation_types';
// eslint-disable-next-line import/prefer-default-export
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);

View File

@ -0,0 +1,6 @@
export const isInheriting = state => (state.adminState === null ? false : !state.override);
export const propsSource = (state, getters) =>
getters.isInheriting ? state.adminState : state.customState;
export const currentKey = (state, getters) => (getters.isInheriting ? 'admin' : 'custom');

View File

@ -0,0 +1,17 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
// eslint-disable-next-line import/prefer-default-export
export const createStore = (initialState = {}) =>
new Vuex.Store({
actions,
getters,
mutations,
state: createState(initialState),
});

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const SET_OVERRIDE = 'SET_OVERRIDE';

View File

@ -0,0 +1,7 @@
import * as types from './mutation_types';
export default {
[types.SET_OVERRIDE](state, override) {
state.override = override;
},
};

View File

@ -0,0 +1,9 @@
export default ({ adminState = null, customState = {} } = {}) => {
const override = adminState !== null ? adminState.id !== customState.inheritFromId : false;
return {
override,
adminState,
customState,
};
};

View File

@ -22,7 +22,10 @@ export default class IntegrationSettingsForm {
init() {
// Init Vue component
initForm(document.querySelector('.js-vue-integration-settings'));
initForm(
document.querySelector('.js-vue-integration-settings'),
document.querySelector('.js-vue-admin-integration-settings'),
);
eventHub.$on('toggle', active => {
this.formActive = active;
this.handleServiceToggle();

View File

@ -303,40 +303,7 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
});
}
/* eslint-disable @gitlab/require-i18n-strings */
export function keypressNoteText(e) {
if (this.selectionStart === this.selectionEnd) {
return;
}
const keys = {
'*': '**{text}**', // wraps with bold character
_: '_{text}_', // wraps with italic character
'`': '`{text}`', // wraps with inline character
"'": "'{text}'", // single quotes
'"': '"{text}"', // double quotes
'[': '[{text}]', // brackets
'{': '{{text}}', // braces
'(': '({text})', // parentheses
'<': '<{text}>', // angle brackets
};
const tag = keys[e.key];
if (tag) {
updateText({
tag,
textArea: this,
blockTag: '',
wrap: true,
select: '',
tagContent: '',
});
e.preventDefault();
}
}
/* eslint-enable @gitlab/require-i18n-strings */
export function addMarkdownListeners(form) {
$('.markdown-area').on('keydown', keypressNoteText);
return $('.js-md', form)
.off('click')
.on('click', function() {
@ -373,6 +340,5 @@ export function addEditorMarkdownListeners(editor) {
}
export function removeMarkdownListeners(form) {
$('.markdown-area').off('keydown');
return $('.js-md', form).off('click');
}

View File

@ -0,0 +1,42 @@
<script>
/**
* Renders Code quality body text
* Fixed: [name] in [link]:[line]
*/
import ReportLink from '~/reports/components/report_link.vue';
import { STATUS_SUCCESS } from '~/reports/constants';
export default {
name: 'CodequalityIssueBody',
components: {
ReportLink,
},
props: {
status: {
type: String,
required: true,
},
issue: {
type: Object,
required: true,
},
},
computed: {
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
},
};
</script>
<template>
<div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
<div class="report-block-list-issue-description-text">
<template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template>
{{ issue.name }}
</div>
<report-link v-if="issue.path" :issue="issue" />
</div>
</template>

View File

@ -0,0 +1,83 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { componentNames } from '~/reports/components/issue_body';
import { s__, sprintf } from '~/locale';
import ReportSection from '~/reports/components/report_section.vue';
import createStore from './store';
export default {
name: 'GroupedCodequalityReportsApp',
store: createStore(),
components: {
ReportSection,
},
props: {
headPath: {
type: String,
required: true,
},
headBlobPath: {
type: String,
required: true,
},
basePath: {
type: String,
required: false,
default: null,
},
baseBlobPath: {
type: String,
required: false,
default: null,
},
codequalityHelpPath: {
type: String,
required: true,
},
},
componentNames,
computed: {
...mapState(['newIssues', 'resolvedIssues']),
...mapGetters([
'hasCodequalityIssues',
'codequalityStatus',
'codequalityText',
'codequalityPopover',
]),
},
created() {
this.setPaths({
basePath: this.basePath,
headPath: this.headPath,
baseBlobPath: this.baseBlobPath,
headBlobPath: this.headBlobPath,
helpPath: this.codequalityHelpPath,
});
this.fetchReports();
},
methods: {
...mapActions(['fetchReports', 'setPaths']),
},
loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), {
reportName: 'codeclimate',
}),
errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
reportName: 'codeclimate',
}),
};
</script>
<template>
<report-section
:status="codequalityStatus"
:loading-text="$options.loadingText"
:error-text="$options.errorText"
:success-text="codequalityText"
:unresolved-issues="newIssues"
:resolved-issues="resolvedIssues"
:has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody"
:popover-options="codequalityPopover"
class="js-codequality-widget mr-widget-border-top mr-report"
/>
</template>

View File

@ -1,12 +1,15 @@
import TestIssueBody from './test_issue_body.vue';
import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
export const components = {
AccessibilityIssueBody,
CodequalityIssueBody,
TestIssueBody,
};
export const componentNames = {
AccessibilityIssueBody: AccessibilityIssueBody.name,
CodequalityIssueBody: CodequalityIssueBody.name,
TestIssueBody: TestIssueBody.name,
};

View File

@ -37,6 +37,7 @@ import eventHub from './event_hub';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue';
import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
@ -75,6 +76,7 @@ export default {
'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus,
GroupedCodequalityReportsApp,
GroupedTestReportsApp,
TerraformPlan,
GroupedAccessibilityReportsApp,
@ -111,6 +113,9 @@ export default {
shouldSuggestPipelines() {
return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
},
shouldRenderCodeQuality() {
return this.mr?.codeclimate?.head_path;
},
shouldRenderRelatedLinks() {
return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState;
},
@ -380,6 +385,15 @@ export default {
:mr="mr"
/>
<div class="mr-section-container mr-widget-workflow">
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:base-path="mr.codeclimate.base_path"
:head-path="mr.codeclimate.head_path"
:head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath"
:codequality-help-path="mr.codequalityHelpPath"
/>
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"

View File

@ -11,12 +11,12 @@ export default function deviseState(data) {
return stateKey.checking;
} else if (data.has_conflicts) {
return stateKey.conflicts;
} else if (data.work_in_progress) {
return stateKey.workInProgress;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
} else if (data.work_in_progress) {
return stateKey.workInProgress;
} else if (this.hasMergeableDiscussionsState) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {

View File

@ -185,6 +185,13 @@ export default class MergeRequestStore {
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access;
this.newPipelinePath = data.new_project_pipeline_path;
// codeclimate
const blobPath = data.blob_path || {};
this.headBlobPath = blobPath.head_path || '';
this.baseBlobPath = blobPath.base_path || '';
this.codequalityHelpPath = data.codequality_help_path;
this.codeclimate = data.codeclimate;
}
get isNothingToMergeState() {

View File

@ -31,6 +31,7 @@ module ServiceParams
:external_wiki_url,
:google_iap_service_account_json,
:google_iap_audience_client_id,
:inherit_from_id,
# We're using `issues_events` and `merge_requests_events`
# in the view so we still need to explicitly state them
# here. `Service#event_names` would only give

View File

@ -21,10 +21,12 @@ class Projects::ServicesController < Projects::ApplicationController
layout "project_settings"
def edit
@admin_integration = Service.instance_for(service.type)
end
def update
@service.attributes = service_params[:service]
@service.inherit_from_id = nil if service_params[:service][:inherit_from_id].blank?
saved = @service.save(context: :manual_change)

View File

@ -101,6 +101,7 @@ module ServicesHelper
def integration_form_data(integration)
{
id: integration.id,
show_active: integration.show_active_box?.to_s,
activated: (integration.active || integration.new_record?).to_s,
type: integration.to_param,
@ -109,7 +110,8 @@ module ServicesHelper
enable_comments: integration.comment_on_event_enabled.to_s,
comment_detail: integration.comment_detail,
trigger_events: trigger_events_for_service(integration),
fields: fields_for_service(integration)
fields: fields_for_service(integration),
inherit_from_id: integration.inherit_from_id
}
end

View File

@ -364,6 +364,10 @@ class Service < ApplicationRecord
exists?(instance: true, type: type)
end
def self.instance_for(type)
find_by(instance: true, type: type)
end
# override if needed
def supports_data_fields?
false

View File

@ -148,9 +148,7 @@
%a.muted{ href: user_url(assignee), style: "color: #333333; text-decoration: none; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; vertical-align: top;" }
= assignee.name
-# EE-specific start
= render 'layouts/mailer/additional_text'
-# EE-specific end
= render_if_exists 'layouts/mailer/additional_text'
%tr.footer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }

View File

@ -13,5 +13,6 @@
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
#js-vue-mr-widget.mr-widget

View File

@ -8,6 +8,8 @@
= markdown integration.help
.service-settings
- if @admin_integration
.js-vue-admin-integration-settings{ data: integration_form_data(@admin_integration) }
.js-vue-integration-settings{ data: integration_form_data(integration) }
- if show_service_trigger_events?(integration)

View File

@ -10,6 +10,7 @@ module ApplicationWorker
include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker
include WorkerAttributes
include WorkerContext
include Gitlab::SidekiqVersioning::Worker
LOGGING_EXTRA_KEY = 'extra'

View File

@ -0,0 +1,5 @@
---
title: Add override selector for project-level integrations
merge_request: 34742
author:
type: added

View File

@ -1,5 +0,0 @@
---
title: Surround selected text in markdown fields on certain key presses
merge_request: 25748
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Use new vuex store for code quality MR widget
merge_request: 36120
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Check WIP status after all other possible statuses
merge_request: 36624
author:
type: changed

View File

@ -239,7 +239,7 @@ The following documentation relates to the DevOps **Verify** stage:
| Verify topics | Description |
|:----------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------|
| [Code Quality reports](user/project/merge_requests/code_quality.md) **(STARTER)** | Analyze source code quality. |
| [Code Quality reports](user/project/merge_requests/code_quality.md) | Analyze source code quality. |
| [GitLab CI/CD](ci/README.md) | Explore the features and capabilities of Continuous Integration with GitLab. |
| [JUnit test reports](ci/junit_test_reports.md) | Display JUnit test reports on merge requests. |
| [Multi-project pipelines](ci/multi_project_pipelines.md) **(PREMIUM)** | Visualize entire pipelines that span multiple projects, including all cross-project inter-dependencies. |

View File

@ -9884,6 +9884,11 @@ type Project {
"""
sastCiConfiguration: SastCiConfiguration
"""
Information about security analyzers used in the project
"""
securityScanners: SecurityScanners
"""
Detailed version of a Sentry error on the project
"""
@ -12053,6 +12058,37 @@ type SecurityReportSummarySection {
vulnerabilitiesCount: Int
}
"""
The type of the security scanner.
"""
enum SecurityScannerType {
CONTAINER_SCANNING
DAST
DEPENDENCY_SCANNING
SAST
SECRET_DETECTION
}
"""
Represents a list of security scanners
"""
type SecurityScanners {
"""
List of analyzers which are available for the project.
"""
available: [SecurityScannerType!]
"""
List of analyzers which are enabled for the project.
"""
enabled: [SecurityScannerType!]
"""
List of analyzers which ran successfully in the latest pipeline.
"""
pipelineRun: [SecurityScannerType!]
}
"""
A Sentry error.
"""

View File

@ -29080,6 +29080,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "securityScanners",
"description": "Information about security analyzers used in the project",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityScanners",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryDetailedError",
"description": "Detailed version of a Sentry error on the project",
@ -35291,6 +35305,126 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SecurityScannerType",
"description": "The type of the security scanner.",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "SAST",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "DAST",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "DEPENDENCY_SCANNING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "CONTAINER_SCANNING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "SECRET_DETECTION",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SecurityScanners",
"description": "Represents a list of security scanners",
"fields": [
{
"name": "available",
"description": "List of analyzers which are available for the project.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SecurityScannerType",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "enabled",
"description": "List of analyzers which are enabled for the project.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SecurityScannerType",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineRun",
"description": "List of analyzers which ran successfully in the latest pipeline.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SecurityScannerType",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryDetailedError",

View File

@ -1434,6 +1434,7 @@ Information about pagination in a connection.
| `requirement` | Requirement | Find a single requirement. Available only when feature flag `requirements_management` is enabled. |
| `requirementStatesCount` | RequirementStatesCount | Number of requirements for the project by their state |
| `sastCiConfiguration` | SastCiConfiguration | SAST CI configuration for the project |
| `securityScanners` | SecurityScanners | Information about security analyzers used in the project |
| `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project |
| `sentryErrors` | SentryErrorCollection | Paginated collection of Sentry errors on the project |
| `serviceDeskAddress` | String | E-mail address of the service desk. |
@ -1746,6 +1747,16 @@ Represents a section of a summary of a security report
| `scannedResourcesCsvPath` | String | Path to download all the scanned resources in CSV format |
| `vulnerabilitiesCount` | Int | Total number of vulnerabilities |
## SecurityScanners
Represents a list of security scanners
| Name | Type | Description |
| --- | ---- | ---------- |
| `available` | SecurityScannerType! => Array | List of analyzers which are available for the project. |
| `enabled` | SecurityScannerType! => Array | List of analyzers which are enabled for the project. |
| `pipelineRun` | SecurityScannerType! => Array | List of analyzers which ran successfully in the latest pipeline. |
## SentryDetailedError
A Sentry error.

View File

@ -129,7 +129,7 @@ Its feature set is listed on the table below according to DevOps stages.
| [Browser Performance Testing](../user/project/merge_requests/browser_performance_testing.md) | Quickly determine the browser performance impact of pending code changes. |
| [Load Performance Testing](../user/project/merge_requests/load_performance_testing.md) | Quickly determine the server performance impact of pending code changes. |
| [CI services](services/README.md) | Link Docker containers with your base image.|
| [Code Quality](../user/project/merge_requests/code_quality.md) **(STARTER)** | Analyze your source code quality. |
| [Code Quality](../user/project/merge_requests/code_quality.md) | Analyze your source code quality. |
| [GitLab CI/CD for external repositories](ci_cd_for_external_repos/index.md) **(PREMIUM)** | Get the benefits of GitLab CI/CD combined with repositories in GitHub and Bitbucket Cloud. |
| [Interactive Web Terminals](interactive_web_terminal/index.md) **(CORE ONLY)** | Open an interactive web terminal to debug the running jobs. |
| [JUnit tests](junit_test_reports.md) | Identify script failures directly on merge requests. |

View File

@ -188,7 +188,7 @@ according to each stage (Verify, Package, Release).
1. **Verify**:
- Automatically build and test your application with Continuous Integration.
- Analyze your source code quality with [GitLab Code Quality](../../user/project/merge_requests/code_quality.md). **(STARTER)**
- Analyze your source code quality with [GitLab Code Quality](../../user/project/merge_requests/code_quality.md).
- Determine the browser performance impact of code changes with [Browser Performance Testing](../../user/project/merge_requests/browser_performance_testing.md). **(PREMIUM)**
- Determine the server performance impact of code changes with [Load Performance Testing](../../user/project/merge_requests/load_performance_testing.md). **(PREMIUM)**
- Perform a series of tests, such as [Container Scanning](../../user/application_security/container_scanning/index.md) **(ULTIMATE)**, [Dependency Scanning](../../user/application_security/dependency_scanning/index.md) **(ULTIMATE)**, and [JUnit tests](../junit_test_reports.md).

View File

@ -146,9 +146,10 @@ plan report will be uploaded to GitLab as an artifact and will be automatically
in merge requests. For more information, see
[Output `terraform plan` information into a merge request](../../user/infrastructure/index.md#output-terraform-plan-information-into-a-merge-request).
#### `artifacts:reports:codequality` **(STARTER)**
#### `artifacts:reports:codequality`
> - Introduced in GitLab 11.5.
> - Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 11.5.
> - Made [available in all tiers](https://gitlab.com/gitlab-org/gitlab/-/issues/212499) in GitLab 13.2.
> - Requires GitLab Runner 11.5 and above.
The `codequality` report collects [CodeQuality issues](../../user/project/merge_requests/code_quality.md)

View File

@ -117,7 +117,7 @@ The following table lists available parameters for jobs:
| [`when`](#when) | When to run job. Also available: `when:manual` and `when:delayed`. |
| [`environment`](#environment) | Name of an environment to which the job deploys. Also available: `environment:name`, `environment:url`, `environment:on_stop`, `environment:auto_stop_in` and `environment:action`. |
| [`cache`](#cache) | List of files that should be cached between subsequent runs. Also available: `cache:paths`, `cache:key`, `cache:untracked`, and `cache:policy`. |
| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:exclude`, `artifacts:expose_as`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, `artifacts:reports:junit`, `artifacts:reports:cobertura`, and `artifacts:reports:terraform`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:codequality`, `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_scanning`, `artifacts:reports:license_management` (removed in GitLab 13.0), `artifacts:reports:performance`, `artifacts:reports:load_performance`, and `artifacts:reports:metrics`. |
| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:exclude`, `artifacts:expose_as`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, `artifacts:reports:codequality`, `artifacts:reports:junit`, `artifacts:reports:cobertura`, and `artifacts:reports:terraform`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_scanning`, `artifacts:reports:license_management` (removed in GitLab 13.0), `artifacts:reports:performance`, `artifacts:reports:load_performance`, and `artifacts:reports:metrics`. |
| [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. |
| [`coverage`](#coverage) | Code coverage settings for a given job. |
| [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. |
@ -3147,7 +3147,7 @@ These are the available report types:
| [`artifacts:reports:dotenv`](../pipelines/job_artifacts.md#artifactsreportsdotenv) | The `dotenv` report collects a set of environment variables. |
| [`artifacts:reports:cobertura`](../pipelines/job_artifacts.md#artifactsreportscobertura) | The `cobertura` report collects Cobertura coverage XML files. |
| [`artifacts:reports:terraform`](../pipelines/job_artifacts.md#artifactsreportsterraform) | The `terraform` report collects Terraform `tfplan.json` files. |
| [`artifacts:reports:codequality`](../pipelines/job_artifacts.md#artifactsreportscodequality-starter) **(STARTER)** | The `codequality` report collects CodeQuality issues. |
| [`artifacts:reports:codequality`](../pipelines/job_artifacts.md#artifactsreportscodequality) | The `codequality` report collects CodeQuality issues. |
| [`artifacts:reports:sast`](../pipelines/job_artifacts.md#artifactsreportssast-ultimate) **(ULTIMATE)** | The `sast` report collects Static Application Security Testing vulnerabilities. |
| [`artifacts:reports:dependency_scanning`](../pipelines/job_artifacts.md#artifactsreportsdependency_scanning-ultimate) **(ULTIMATE)** | The `dependency_scanning` report collects Dependency Scanning vulnerabilities. |
| [`artifacts:reports:container_scanning`](../pipelines/job_artifacts.md#artifactsreportscontainer_scanning-ultimate) **(ULTIMATE)** | The `container_scanning` report collects Container Scanning vulnerabilities. |

View File

@ -16,6 +16,22 @@ Global navigation (the left-most pane in our three pane documentation) provides:
- The ability to refine landing pages, so they don't have to do all the work of surfacing
every page contained within the documentation.
## Quick start
To add a topic to the global nav, go to the directory that contains
[navigation files](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/content/_data/)
and edit the `yaml` file for your product area. You can copy an existing nav entry and
edit it to point to your topic.
The files are:
| File | Document | Location |
|-----------------------|--------------------------------------------------------------------|-------------------------------------------------------|
| `charts-nav.yaml` | GitLab cloud native Helm Chart | `https://docs.gitlab.com/charts/` |
| `default-nav.yaml` | GitLab Docs | `https://docs.gitlab.com/ee/` |
| `omnibus-nav.yaml` | Omnibus GitLab Docs | `https://docs.gitlab.com/omnibus/` |
| `runner-nav.yaml` | GitLab Runner Docs | `https://docs.gitlab.com/runner/` |
## Adding new items
All new pages need a new navigation item. Without a navigation, the page becomes "orphaned". That

View File

@ -1346,6 +1346,11 @@ Tagged and released versions of GitLab documentation are available:
The version introducing a new feature is added to the top of the topic in the documentation to provide
a helpful link back to how the feature was developed.
TIP: **Tip:**
Whenever you have documentation related to the `gitlab.rb` file, you're working with a self-managed installation.
The section or page is therefore likely to apply only to self-managed instances.
If so, the relevant "`TIER` ONLY" [Product badge](#product-badges) should be included at the highest applicable heading level.
### Text for documentation requiring version text
- For features that need to declare the GitLab version that the feature was introduced. Text similar

View File

@ -64,6 +64,36 @@ the extra jobs will take resources away from jobs from workers that were already
there, if the resources available to the Sidekiq process handling the namespace
are not adjusted appropriately.
## Versioning
Version can be specified on each Sidekiq worker class.
This is then sent along when the job is created.
```ruby
class FooWorker
include ApplicationWorker
version 2
def perform(*args)
if job_version == 2
foo = args.first['foo']
else
foo = args.first
end
end
end
```
Under this schema, any worker is expected to be able to handle any job that was
enqueued by an older version of that worker. This means that when changing the
arguments a worker takes, you must increment the `version` (or set `version 1`
if this is the first time a worker's arguments are changing), but also make sure
that the worker is still able to handle jobs that were queued with any earlier
version of the arguments. From the worker's `perform` method, you can read
`self.job_version` if you want to specifically branch on job version, or you
can read the number or type of provided arguments.
## Idempotent Jobs
It's known that a job can fail for multiple reasons. For example, network outages or bugs.

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference, howto
---
# Code Quality **(STARTER)**
# Code Quality
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1984) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.3.
@ -25,6 +25,11 @@ Code Quality:
DevOps](../../../topics/autodevops/stages.md#auto-code-quality-starter).
- Can be extended through [Analysis Plugins](https://docs.codeclimate.com/docs/list-of-engines) or a [custom tool](#implementing-a-custom-tool).
## Code Quality Widget
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1984) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.3.
> - Made [available in all tiers](https://gitlab.com/gitlab-org/gitlab/-/issues/212499) in 13.2.
Going a step further, GitLab can show the Code Quality report right
in the merge request widget area if a report from the target branch is available to compare to:
@ -82,7 +87,7 @@ include:
The above example will create a `code_quality` job in your CI/CD pipeline which
will scan your source code for code quality issues. The report will be saved as a
[Code Quality report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportscodequality-starter)
[Code Quality report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportscodequality)
that you can later download and analyze.
It's also possible to override the URL to the Code Quality image by
@ -240,7 +245,7 @@ do this:
1. Define a job in your `.gitlab-ci.yml` file that generates the
[Code Quality report
artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportscodequality-starter).
artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportscodequality).
1. Configure your tool to generate the Code Quality report artifact as a JSON
file that implements a subset of the [Code Climate
spec](https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-types).
@ -276,11 +281,11 @@ NOTE: **Note:**
Although the Code Climate spec supports more properties, those are ignored by
GitLab.
## Code Quality reports
## Code Quality reports **(STARTER)**
Once the Code Quality job has completed:
- The full list of code quality violations generated by a pipeline is available in the
- The full list of code quality violations generated by a pipeline is shown in the
Code Quality tab of the Pipeline Details page.
- Potential changes to code quality are shown directly in the merge request.
The Code Quality widget in the merge request compares the reports from the base and head of the branch,
@ -293,7 +298,7 @@ Once the Code Quality job has completed:
### Using Analysis Plugins
Should there be a need to extend the default functionality provided by Code Quality, as stated in [Code Quality](#code-quality-starter), [Analysis Plugins](https://docs.codeclimate.com/docs/list-of-engines) are available.
Should there be a need to extend the default functionality provided by Code Quality, as stated in [Code Quality](#code-quality), [Analysis Plugins](https://docs.codeclimate.com/docs/list-of-engines) are available.
For example, to use the [SonarJava analyzer](https://docs.codeclimate.com/docs/sonar-java),
add a file named `.codeclimate.yml` containing the [enablement code](https://docs.codeclimate.com/docs/sonar-java#enable-the-plugin)

View File

@ -19,7 +19,7 @@ A. Consider you're a software developer working in a team:
1. You checkout a new branch, and submit your changes through a merge request
1. You gather feedback from your team
1. You work on the implementation optimizing code with [Code Quality reports](code_quality.md) **(STARTER)**
1. You work on the implementation optimizing code with [Code Quality reports](code_quality.md)
1. You verify your changes with [JUnit test reports](../../../ci/junit_test_reports.md) in GitLab CI/CD
1. You avoid using dependencies whose license is not compatible with your project with [License Compliance reports](../../compliance/license_compliance/index.md) **(ULTIMATE)**
1. You request the [approval](merge_request_approvals.md) from your manager **(STARTER)**

View File

@ -16,7 +16,7 @@ or link to useful information directly from merge requests:
| [Accessibility Testing](accessibility_testing.md) | Automatically report A11y violations for changed pages in merge requests. |
| [Browser Performance Testing](browser_performance_testing.md) **(PREMIUM)** | Quickly determine the browser performance impact of pending code changes. |
| [Load Performance Testing](load_performance_testing.md) **(PREMIUM)** | Quickly determine the server performance impact of pending code changes. |
| [Code Quality](code_quality.md) **(STARTER)** | Analyze your source code quality using the [Code Climate](https://codeclimate.com/) analyzer and show the Code Climate report right in the merge request widget area. |
| [Code Quality](code_quality.md) | Analyze your source code quality using the [Code Climate](https://codeclimate.com/) analyzer and show the Code Climate report right in the merge request widget area. |
| [Display arbitrary job artifacts](../../../ci/yaml/README.md#artifactsexpose_as) | Configure CI pipelines with the `artifacts:expose_as` parameter to directly link to selected [artifacts](../../../ci/pipelines/job_artifacts.md) in merge requests. |
| [GitLab CI/CD](../../../ci/README.md) | Build, test, and deploy your code in a per-branch basis with built-in CI/CD. |
| [JUnit test reports](../../../ci/junit_test_reports.md) | Configure your CI jobs to use JUnit test reports, and let GitLab display a report on the merge request so that its easier and faster to identify the failure without having to check the entire job log. |

View File

@ -52,7 +52,9 @@ module Gitlab
end
def map_user_id(jira_user)
Gitlab::JiraImport::UserMapper.new(project, jira_user).execute&.id
return unless jira_user&.dig('accountId')
Gitlab::JiraImport.get_user_mapping(project.id, jira_user['accountId'])
end
def reporter

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
module Gitlab
module JiraImport
class UserMapper
include ::Gitlab::Utils::StrongMemoize
def initialize(project, jira_user)
@project = project
@jira_user = jira_user
end
def execute
return unless jira_user
email = jira_user['emailAddress']
# We also include emails that are not yet confirmed
users = User.by_any_email(email).to_a
user = users.first
# this event should never happen but we should log it in case we have invalid data
log_user_mapping_message('Multiple users found for an email address', email) if users.count > 1
unless project.project_member(user) || project.group&.group_member(user)
log_user_mapping_message('Jira user not found', email)
return
end
user
end
private
attr_reader :project, :jira_user, :params
def log_user_mapping_message(message, email)
logger.info(
project_id: project.id,
project_path: project.full_path,
user_email: email,
message: message
)
end
def logger
@logger ||= Gitlab::Import::Logger.build
end
end
end
end

View File

@ -5,6 +5,10 @@ module Gitlab
def self.install!
Sidekiq::Manager.prepend SidekiqVersioning::Manager
Sidekiq.server_middleware do |chain|
chain.add SidekiqVersioning::Middleware
end
# The Sidekiq client API always adds the queue to the Sidekiq queue
# list, but mail_room and gitlab-shell do not. This is only necessary
# for monitoring.

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Gitlab
module SidekiqVersioning
class Middleware
def call(worker, job, queue)
worker.job_version = job['version']
yield
end
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Gitlab
module SidekiqVersioning
module Worker
extend ActiveSupport::Concern
included do
version 0
attr_writer :job_version
end
class_methods do
def version(new_version = nil)
if new_version
sidekiq_options version: new_version.to_i
else
get_sidekiq_options['version']
end
end
end
# Version is not set if `new.perform` is called directly,
# and in that case we fallback to latest version
def job_version
@job_version ||= self.class.version
end
end
end
end

View File

@ -12748,6 +12748,15 @@ msgstr ""
msgid "Integrations|Standard"
msgstr ""
msgid "Integrations|This integration has multiple settings available."
msgstr ""
msgid "Integrations|Use custom settings"
msgstr ""
msgid "Integrations|Use instance level settings"
msgstr ""
msgid "Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created."
msgstr ""

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Verify' do
RSpec.describe 'Verify', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/229724', type: :investigating } do
describe 'Add or Remove CI variable via UI', :smoke do
let!(:project) do
Resource::Project.fabricate_via_api! do |project|

View File

@ -179,6 +179,23 @@ RSpec.describe Projects::ServicesController do
it_behaves_like 'service update'
end
context 'wehn param `inherit_from_id` is set to empty string' do
let(:service_params) { { inherit_from_id: '' } }
it 'sets inherit_from_id to nil' do
expect(service.reload.inherit_from_id).to eq(nil)
end
end
context 'wehn param `inherit_from_id` is set to some value' do
let(:instance_service) { create(:jira_service, :instance) }
let(:service_params) { { inherit_from_id: instance_service.id } }
it 'sets inherit_from_id to value' do
expect(service.reload.inherit_from_id).to eq(instance_service.id)
end
end
end
describe 'as JSON' do

View File

@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'Disable individual triggers', :js do
include_context 'project service activation'
let(:checkbox_selector) { 'input[type=checkbox][name$="_events]"]' }
let(:checkbox_selector) { 'input[name$="_events]"]' }
before do
visit_project_integration(service_name)
@ -18,7 +18,7 @@ RSpec.describe 'Disable individual triggers', :js do
event_count = HipchatService.supported_events.count
expect(page).to have_content "Trigger"
expect(page).to have_css(checkbox_selector, count: event_count)
expect(page).to have_css(checkbox_selector, visible: :all, count: event_count)
end
end
@ -27,7 +27,7 @@ RSpec.describe 'Disable individual triggers', :js do
it "doesn't show unnecessary Trigger checkboxes" do
expect(page).not_to have_content "Trigger"
expect(page).not_to have_css(checkbox_selector)
expect(page).not_to have_css(checkbox_selector, visible: :all)
end
end
end

View File

@ -6,7 +6,7 @@ import {
SIMPLE_BLOB_VIEWER,
SIMPLE_BLOB_VIEWER_TITLE,
} from '~/blob/components/constants';
import { GlButtonGroup, GlDeprecatedButton } from '@gitlab/ui';
import { GlButtonGroup, GlButton } from '@gitlab/ui';
describe('Blob Header Viewer Switcher', () => {
let wrapper;
@ -35,7 +35,7 @@ describe('Blob Header Viewer Switcher', () => {
beforeEach(() => {
createComponent();
btnGroup = wrapper.find(GlButtonGroup);
buttons = wrapper.findAll(GlDeprecatedButton);
buttons = wrapper.findAll(GlButton);
});
it('renders gl-button-group component', () => {
@ -57,7 +57,7 @@ describe('Blob Header Viewer Switcher', () => {
function factory(propsData = {}) {
createComponent(propsData);
buttons = wrapper.findAll(GlDeprecatedButton);
buttons = wrapper.findAll(GlButton);
simpleBtn = buttons.at(0);
richBtn = buttons.at(1);

View File

@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils';
import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
import { GlToggle } from '@gitlab/ui';
import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
const GL_TOGGLE_ACTIVE_CLASS = 'is-checked';
const GL_TOGGLE_DISABLED_CLASS = 'is-disabled';
describe('ActiveToggle', () => {
let wrapper;
@ -11,9 +13,12 @@ describe('ActiveToggle', () => {
initialActivated: true,
};
const createComponent = props => {
const createComponent = (props = {}, isInheriting = false) => {
wrapper = mount(ActiveToggle, {
propsData: { ...defaultProps, ...props },
computed: {
isInheriting: () => isInheriting,
},
});
};
@ -29,6 +34,15 @@ describe('ActiveToggle', () => {
const findInputInToggle = () => findGlToggle().find('input');
describe('template', () => {
describe('is inheriting adminSettings', () => {
it('renders GlToggle as disabled', () => {
createComponent({}, true);
expect(findGlToggle().exists()).toBe(true);
expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_DISABLED_CLASS);
});
});
describe('initialActivated is false', () => {
it('renders GlToggle as inactive', () => {
createComponent({

View File

@ -14,9 +14,12 @@ describe('DynamicField', () => {
value: '1',
};
const createComponent = props => {
const createComponent = (props, isInheriting = false) => {
wrapper = mount(DynamicField, {
propsData: { ...defaultProps, ...props },
computed: {
isInheriting: () => isInheriting,
},
});
};
@ -34,108 +37,143 @@ describe('DynamicField', () => {
const findGlFormTextarea = () => wrapper.find(GlFormTextarea);
describe('template', () => {
describe('dynamic field', () => {
describe('type is checkbox', () => {
beforeEach(() => {
createComponent({
type: 'checkbox',
describe.each([[true, 'disabled', 'readonly'], [false, undefined, undefined]])(
'dynamic field, when isInheriting = `%p`',
(isInheriting, disabled, readonly) => {
describe('type is checkbox', () => {
beforeEach(() => {
createComponent(
{
type: 'checkbox',
},
isInheriting,
);
});
it(`renders GlFormCheckbox, which ${isInheriting ? 'is' : 'is not'} disabled`, () => {
expect(findGlFormCheckbox().exists()).toBe(true);
expect(
findGlFormCheckbox()
.find('[type=checkbox]')
.attributes('disabled'),
).toBe(disabled);
});
it('does not render other types of input', () => {
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it('renders GlFormCheckbox', () => {
expect(findGlFormCheckbox().exists()).toBe(true);
});
describe('type is select', () => {
beforeEach(() => {
createComponent(
{
type: 'select',
choices: [['all', 'All details'], ['standard', 'Standard']],
},
isInheriting,
);
});
it('does not render other types of input', () => {
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it(`renders GlFormSelect, which ${isInheriting ? 'is' : 'is not'} disabled`, () => {
expect(findGlFormSelect().exists()).toBe(true);
expect(findGlFormSelect().findAll('option')).toHaveLength(2);
expect(
findGlFormSelect()
.find('select')
.attributes('disabled'),
).toBe(disabled);
});
describe('type is select', () => {
beforeEach(() => {
createComponent({
type: 'select',
choices: [['all', 'All details'], ['standard', 'Standard']],
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it('renders findGlFormSelect', () => {
expect(findGlFormSelect().exists()).toBe(true);
expect(findGlFormSelect().findAll('option')).toHaveLength(2);
});
describe('type is textarea', () => {
beforeEach(() => {
createComponent(
{
type: 'textarea',
},
isInheriting,
);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it(`renders GlFormTextarea, which ${isInheriting ? 'is' : 'is not'} readonly`, () => {
expect(findGlFormTextarea().exists()).toBe(true);
expect(
findGlFormTextarea()
.find('textarea')
.attributes('readonly'),
).toBe(readonly);
});
describe('type is textarea', () => {
beforeEach(() => {
createComponent({
type: 'textarea',
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it('renders findGlFormTextarea', () => {
expect(findGlFormTextarea().exists()).toBe(true);
});
describe('type is password', () => {
beforeEach(() => {
createComponent(
{
type: 'password',
},
isInheriting,
);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes('type')).toBe('password');
expect(findGlFormInput().attributes('readonly')).toBe(readonly);
});
describe('type is password', () => {
beforeEach(() => {
createComponent({
type: 'password',
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes('type')).toBe('password');
});
describe('type is text', () => {
beforeEach(() => {
createComponent(
{
type: 'text',
required: true,
},
isInheriting,
);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes()).toMatchObject({
type: 'text',
id: 'service_project_url',
name: 'service[project_url]',
placeholder: defaultProps.placeholder,
required: 'required',
});
expect(findGlFormInput().attributes('readonly')).toBe(readonly);
});
describe('type is text', () => {
beforeEach(() => {
createComponent({
type: 'text',
required: true,
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes()).toMatchObject({
type: 'text',
id: 'service_project_url',
name: 'service[project_url]',
placeholder: defaultProps.placeholder,
required: 'required',
});
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
});
},
);
describe('help text', () => {
it('renders description with help text', () => {

View File

@ -1,34 +1,29 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from '~/integrations/edit/store';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { mockIntegrationProps } from 'jest/integrations/edit/mock_data';
describe('IntegrationForm', () => {
let wrapper;
const defaultProps = {
activeToggleProps: {
initialActivated: true,
},
showActive: true,
triggerFieldsProps: {
initialTriggerCommit: false,
initialTriggerMergeRequest: false,
initialEnableComments: false,
},
jiraIssuesProps: {},
type: '',
};
const createComponent = (props, featureFlags = {}) => {
const createComponent = (customStateProps = {}, featureFlags = {}, initialState = {}) => {
wrapper = shallowMount(IntegrationForm, {
propsData: { ...defaultProps, ...props },
propsData: {},
store: createStore({
customState: { ...mockIntegrationProps, ...customStateProps },
...initialState,
}),
stubs: {
OverrideDropdown,
ActiveToggle,
JiraTriggerFields,
TriggerFields,
},
provide: {
glFeatures: featureFlags,
@ -43,6 +38,7 @@ describe('IntegrationForm', () => {
}
});
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
const findActiveToggle = () => wrapper.find(ActiveToggle);
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
@ -140,5 +136,35 @@ describe('IntegrationForm', () => {
});
});
});
describe('adminState state is null', () => {
it('does not render OverrideDropdown', () => {
createComponent(
{},
{},
{
adminState: null,
},
);
expect(findOverrideDropdown().exists()).toBe(false);
});
});
describe('adminState state is an object', () => {
it('renders OverrideDropdown', () => {
createComponent(
{},
{},
{
adminState: {
...mockIntegrationProps,
},
},
);
expect(findOverrideDropdown().exists()).toBe(true);
});
});
});
});

View File

@ -1,7 +1,9 @@
import { mount } from '@vue/test-utils';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import { GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
describe('JiraIssuesFields', () => {
let wrapper;

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import { GlFormCheckbox } from '@gitlab/ui';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
describe('JiraTriggerFields', () => {
let wrapper;
@ -11,9 +11,12 @@ describe('JiraTriggerFields', () => {
initialEnableComments: false,
};
const createComponent = props => {
const createComponent = (props, isInheriting = false) => {
wrapper = mount(JiraTriggerFields, {
propsData: { ...defaultProps, ...props },
computed: {
isInheriting: () => isInheriting,
},
});
};
@ -93,5 +96,23 @@ describe('JiraTriggerFields', () => {
expect(findCommentDetail().isVisible()).toBe(true);
});
});
it('disables checkboxes and radios if inheriting', () => {
createComponent(
{
initialTriggerCommit: true,
initialEnableComments: true,
},
true,
);
wrapper.findAll('[type=checkbox]').wrappers.forEach(checkbox => {
expect(checkbox.attributes('disabled')).toBe('disabled');
});
wrapper.findAll('[type=radio]').wrappers.forEach(radio => {
expect(radio.attributes('disabled')).toBe('disabled');
});
});
});
});

View File

@ -9,9 +9,12 @@ describe('TriggerFields', () => {
type: 'slack',
};
const createComponent = props => {
const createComponent = (props, isInheriting = false) => {
wrapper = mount(TriggerFields, {
propsData: { ...defaultProps, ...props },
computed: {
isInheriting: () => isInheriting,
},
});
};
@ -22,10 +25,11 @@ describe('TriggerFields', () => {
}
});
const findAllGlFormGroups = () => wrapper.find('#trigger-fields').findAll(GlFormGroup);
const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox);
const findAllGlFormInputs = () => wrapper.findAll(GlFormInput);
describe('template', () => {
describe.each([true, false])('template, isInheriting = `%p`', isInheriting => {
it('renders a label with text "Trigger"', () => {
createComponent();
@ -51,9 +55,12 @@ describe('TriggerFields', () => {
];
beforeEach(() => {
createComponent({
events,
});
createComponent(
{
events,
},
isInheriting,
);
});
it('does not render GlFormInput for each event', () => {
@ -69,8 +76,10 @@ describe('TriggerFields', () => {
});
});
it('renders GlFormCheckbox for each event', () => {
const checkboxes = findAllGlFormCheckboxes();
it(`renders GlFormCheckbox and corresponding hidden input for each event, which ${
isInheriting ? 'is' : 'is not'
} disabled`, () => {
const checkboxes = findAllGlFormGroups();
const expectedResults = [
{ labelText: 'Push', inputName: 'service[push_event]' },
{ labelText: 'Merge Request', inputName: 'service[merge_requests_event]' },
@ -78,14 +87,22 @@ describe('TriggerFields', () => {
expect(checkboxes).toHaveLength(2);
checkboxes.wrappers.forEach((checkbox, index) => {
const checkBox = checkbox.find(GlFormCheckbox);
expect(checkbox.find('label').text()).toBe(expectedResults[index].labelText);
expect(checkbox.find('input').attributes('name')).toBe(expectedResults[index].inputName);
expect(checkbox.vm.$attrs.checked).toBe(events[index].value);
expect(checkbox.find('[type=hidden]').attributes('name')).toBe(
expectedResults[index].inputName,
);
expect(checkbox.find('[type=hidden]').attributes('value')).toBe(
events[index].value.toString(),
);
expect(checkBox.vm.$attrs.disabled).toBe(isInheriting);
expect(checkBox.vm.$attrs.checked).toBe(events[index].value);
});
});
});
describe('events with field property', () => {
describe('events with field property, isInheriting = `%p`', () => {
const events = [
{
field: {
@ -102,16 +119,21 @@ describe('TriggerFields', () => {
];
beforeEach(() => {
createComponent({
events,
});
createComponent(
{
events,
},
isInheriting,
);
});
it('renders GlFormCheckbox for each event', () => {
expect(findAllGlFormCheckboxes()).toHaveLength(2);
});
it('renders GlFormInput for each event', () => {
it(`renders GlFormInput for each event, which ${
isInheriting ? 'is' : 'is not'
} readonly`, () => {
const fields = findAllGlFormInputs();
const expectedResults = [
{
@ -128,6 +150,7 @@ describe('TriggerFields', () => {
fields.wrappers.forEach((field, index) => {
expect(field.attributes()).toMatchObject(expectedResults[index]);
expect(field.vm.$attrs.readonly).toBe(isInheriting);
expect(field.vm.$attrs.value).toBe(events[index].field.value);
});
});

View File

@ -0,0 +1,18 @@
// eslint-disable-next-line import/prefer-default-export
export const mockIntegrationProps = {
id: 25,
activeToggleProps: {
initialActivated: true,
},
showActive: true,
triggerFieldsProps: {
initialTriggerCommit: false,
initialTriggerMergeRequest: false,
initialEnableComments: false,
},
jiraIssuesProps: {},
triggerEvents: [],
fields: [],
type: '',
inheritFromId: 25,
};

View File

@ -0,0 +1,19 @@
import createState from '~/integrations/edit/store/state';
import { setOverride } from '~/integrations/edit/store/actions';
import * as types from '~/integrations/edit/store/mutation_types';
import testAction from 'helpers/vuex_action_helper';
describe('Integration form store actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setOverride', () => {
it('should commit override mutation', () => {
return testAction(setOverride, true, state, [{ type: types.SET_OVERRIDE, payload: true }]);
});
});
});

View File

@ -0,0 +1,71 @@
import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters';
import createState from '~/integrations/edit/store/state';
import { mockIntegrationProps } from '../mock_data';
describe('Integration form store getters', () => {
let state;
const customState = { ...mockIntegrationProps, type: 'CustomState' };
const adminState = { ...mockIntegrationProps, type: 'AdminState' };
beforeEach(() => {
state = createState({ customState });
});
describe('isInheriting', () => {
describe('when adminState is null', () => {
it('returns false', () => {
expect(isInheriting(state)).toBe(false);
});
});
describe('when adminState is an object', () => {
beforeEach(() => {
state.adminState = adminState;
});
describe('when override is false', () => {
beforeEach(() => {
state.override = false;
});
it('returns false', () => {
expect(isInheriting(state)).toBe(true);
});
});
describe('when override is true', () => {
beforeEach(() => {
state.override = true;
});
it('returns true', () => {
expect(isInheriting(state)).toBe(false);
});
});
});
});
describe('propsSource', () => {
beforeEach(() => {
state.adminState = adminState;
});
it('equals adminState if inheriting', () => {
expect(propsSource(state, { isInheriting: true })).toEqual(adminState);
});
it('equals customState if not inheriting', () => {
expect(propsSource(state, { isInheriting: false })).toEqual(customState);
});
});
describe('currentKey', () => {
it('equals `admin` if inheriting', () => {
expect(currentKey(state, { isInheriting: true })).toEqual('admin');
});
it('equals `custom` if not inheriting', () => {
expect(currentKey(state, { isInheriting: false })).toEqual('custom');
});
});
});

View File

@ -0,0 +1,19 @@
import mutations from '~/integrations/edit/store/mutations';
import createState from '~/integrations/edit/store/state';
import * as types from '~/integrations/edit/store/mutation_types';
describe('Integration form store mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(`${types.SET_OVERRIDE}`, () => {
it('sets override', () => {
mutations[types.SET_OVERRIDE](state, true);
expect(state.override).toBe(true);
});
});
});

View File

@ -0,0 +1,26 @@
import createState from '~/integrations/edit/store/state';
describe('Integration form state factory', () => {
it('states default to null', () => {
expect(createState()).toEqual({
adminState: null,
customState: {},
override: false,
});
});
describe('override is initialized correctly', () => {
it.each([
[{ id: 25 }, { inheritFromId: null }, true],
[{ id: 25 }, { inheritFromId: 27 }, true],
[{ id: 25 }, { inheritFromId: 25 }, false],
[null, { inheritFromId: null }, false],
[null, { inheritFromId: 25 }, false],
])(
'for adminState: %p, customState: %p: override = `%p`',
(adminState, customState, expected) => {
expect(createState({ adminState, customState }).override).toEqual(expected);
},
);
});
});

View File

@ -1,4 +1,4 @@
import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown';
import { insertMarkdownText } from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
@ -115,15 +115,14 @@ describe('init markdown', () => {
describe('with selection', () => {
const text = 'initial selected value';
const selected = 'selected';
let selectedIndex;
beforeEach(() => {
textArea.value = text;
selectedIndex = text.indexOf(selected);
const selectedIndex = text.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
});
it('applies the tag to the selected value', () => {
const selectedIndex = text.indexOf(selected);
const tag = '*';
insertMarkdownText({
@ -154,29 +153,6 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`));
});
it.each`
key | expected
${'['} | ${`[${selected}]`}
${'*'} | ${`**${selected}**`}
${"'"} | ${`'${selected}'`}
${'_'} | ${`_${selected}_`}
${'`'} | ${`\`${selected}\``}
${'"'} | ${`"${selected}"`}
${'{'} | ${`{${selected}}`}
${'('} | ${`(${selected})`}
${'<'} | ${`<${selected}>`}
`('generates $expected when $key is pressed', ({ key, expected }) => {
const event = new KeyboardEvent('keydown', { key });
textArea.addEventListener('keydown', keypressNoteText);
textArea.dispatchEvent(event);
expect(textArea.value).toEqual(text.replace(selected, expected));
// cursor placement should be after selection + 2 tag lengths
expect(textArea.selectionStart).toBe(selectedIndex + expected.length);
});
describe('and text to be selected', () => {
const tag = '[{text}](url)';
const select = 'url';
@ -202,7 +178,7 @@ describe('init markdown', () => {
it('selects the right text when multiple tags are present', () => {
const initialValue = `${tag} ${tag} ${selected}`;
textArea.value = initialValue;
selectedIndex = initialValue.indexOf(selected);
const selectedIndex = initialValue.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
insertMarkdownText({
textArea,
@ -228,7 +204,7 @@ describe('init markdown', () => {
const initialValue = `text ${expectedUrl} text`;
textArea.value = initialValue;
selectedIndex = initialValue.indexOf(expectedUrl);
const selectedIndex = initialValue.indexOf(expectedUrl);
textArea.setSelectionRange(selectedIndex, selectedIndex + expectedUrl.length);
insertMarkdownText({

View File

@ -0,0 +1,62 @@
import { shallowMount } from '@vue/test-utils';
import component from '~/reports/codequality_report/components/codequality_issue_body.vue';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
describe('code quality issue body issue body', () => {
let wrapper;
const codequalityIssue = {
name:
'rubygem-rest-client: session fixation vulnerability via Set-Cookie headers in 30x redirection responses',
path: 'Gemfile.lock',
severity: 'normal',
type: 'Issue',
urlPath: '/Gemfile.lock#L22',
};
const mountWithStatus = initialStatus => {
wrapper = shallowMount(component, {
propsData: {
issue: codequalityIssue,
status: initialStatus,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with success', () => {
it('renders fixed label', () => {
mountWithStatus(STATUS_SUCCESS);
expect(wrapper.text()).toContain('Fixed');
});
});
describe('without success', () => {
it('renders fixed label', () => {
mountWithStatus(STATUS_FAILED);
expect(wrapper.text()).not.toContain('Fixed');
});
});
describe('name', () => {
it('renders name', () => {
mountWithStatus(STATUS_NEUTRAL);
expect(wrapper.text()).toContain(codequalityIssue.name);
});
});
describe('path', () => {
it('renders the report-link path using the correct code quality issue', () => {
mountWithStatus(STATUS_NEUTRAL);
expect(wrapper.find('report-link-stub').props('issue')).toBe(codequalityIssue);
});
});
});

View File

@ -0,0 +1,146 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue';
import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue';
import store from '~/reports/codequality_report/store';
import { mockParsedHeadIssues, mockParsedBaseIssues } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Grouped code quality reports app', () => {
const Component = localVue.extend(GroupedCodequalityReportsApp);
let wrapper;
let mockStore;
const mountComponent = (props = {}) => {
wrapper = mount(Component, {
store: mockStore,
localVue,
propsData: {
basePath: 'base.json',
headPath: 'head.json',
baseBlobPath: 'base/blob/path/',
headBlobPath: 'head/blob/path/',
codequalityHelpPath: 'codequality_help.html',
...props,
},
methods: {
fetchReports: () => {},
},
});
};
const findWidget = () => wrapper.find('.js-codequality-widget');
const findIssueBody = () => wrapper.find(CodequalityIssueBody);
beforeEach(() => {
mockStore = store();
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('when it is loading reports', () => {
beforeEach(() => {
mockStore.state.isLoading = true;
});
it('should render loading text', () => {
expect(findWidget().text()).toEqual('Loading codeclimate report');
});
});
describe('when base and head reports are loaded and compared', () => {
describe('with no issues', () => {
beforeEach(() => {
mockStore.state.newIssues = [];
mockStore.state.resolvedIssues = [];
});
it('renders no changes text', () => {
expect(findWidget().text()).toEqual('No changes to code quality');
});
});
describe('with issues', () => {
describe('with new issues', () => {
beforeEach(() => {
mockStore.state.newIssues = [mockParsedHeadIssues[0]];
mockStore.state.resolvedIssues = [];
});
it('renders summary text', () => {
expect(findWidget().text()).toContain('Code quality degraded on 1 point');
});
it('renders custom codequality issue body', () => {
expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]);
});
});
describe('with resolved issues', () => {
beforeEach(() => {
mockStore.state.newIssues = [];
mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]];
});
it('renders summary text', () => {
expect(findWidget().text()).toContain('Code quality improved on 1 point');
});
it('renders custom codequality issue body', () => {
expect(findIssueBody().props('issue')).toEqual(mockParsedBaseIssues[0]);
});
});
describe('with new and resolved issues', () => {
beforeEach(() => {
mockStore.state.newIssues = [mockParsedHeadIssues[0]];
mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]];
});
it('renders summary text', () => {
expect(findWidget().text()).toContain(
'Code quality improved on 1 point and degraded on 1 point',
);
});
it('renders custom codequality issue body', () => {
expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]);
});
});
});
});
describe('when there is a head report but no base report', () => {
beforeEach(() => {
mockStore.state.basePath = null;
mockStore.state.hasError = true;
});
it('renders error text', () => {
expect(findWidget().text()).toEqual('Failed to load codeclimate report');
});
it('renders a help icon with more information', () => {
expect(findWidget().html()).toContain('ic-question');
});
});
describe('on error', () => {
beforeEach(() => {
mockStore.state.hasError = true;
});
it('renders error text', () => {
expect(findWidget().text()).toContain('Failed to load codeclimate report');
});
it('does not render a help icon', () => {
expect(findWidget().html()).not.toContain('ic-question');
});
});
});

View File

@ -211,6 +211,15 @@ export default {
can_revert_on_current_merge_request: true,
can_cherry_pick_on_current_merge_request: true,
},
codeclimate: {
head_path: 'head.json',
base_path: 'base.json',
},
blob_path: {
base_path: 'blob_path',
head_path: 'blob_path',
},
codequality_help_path: 'code_quality.html',
target_branch_path: '/root/acets-app/branches/master',
source_branch_path: '/root/acets-app/branches/daaaa',
conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts',

View File

@ -609,6 +609,12 @@ describe('mrWidgetOptions', () => {
});
});
describe('code quality widget', () => {
it('renders the component', () => {
expect(vm.$el.querySelector('.js-codequality-widget')).toExist();
});
});
describe('pipeline for target branch after merge', () => {
describe('with information for target branch pipeline', () => {
beforeEach(done => {

View File

@ -49,14 +49,18 @@ describe('getStateKey', () => {
expect(bound()).toEqual('unresolvedDiscussions');
data.work_in_progress = true;
expect(bound()).toEqual('workInProgress');
context.onlyAllowMergeIfPipelineSucceeds = true;
context.isPipelineFailed = true;
expect(bound()).toEqual('pipelineFailed');
data.work_in_progress = true;
context.shouldBeRebased = true;
expect(bound()).toEqual('workInProgress');
expect(bound()).toEqual('rebase');
data.has_conflicts = true;

View File

@ -7,4 +7,28 @@ RSpec.describe ServicesHelper do
it { expect(event_action_title('comment')).to eq 'Comment' }
it { expect(event_action_title('something')).to eq 'Something' }
end
describe '#integration_form_data' do
subject { helper.integration_form_data(integration) }
context 'Jira service' do
let(:integration) { build(:jira_service) }
it 'includes Jira specific fields' do
is_expected.to include(
:id,
:show_active,
:activated,
:type,
:merge_request_events,
:commit_events,
:enable_comments,
:comment_detail,
:trigger_events,
:fields,
:inherit_from_id
)
end
end
end
end

View File

@ -10,6 +10,7 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
let_it_be(:other_project_label) { create(:label, project: project, title: 'feature') }
let_it_be(:group_label) { create(:group_label, group: group, title: 'dev') }
let_it_be(:current_user) { create(:user) }
let_it_be(:user) { create(:user) }
let(:iid) { 5 }
let(:key) { 'PROJECT-5' }
@ -17,8 +18,8 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
let(:description) { 'basic description' }
let(:created_at) { '2020-01-01 20:00:00' }
let(:updated_at) { '2020-01-10 20:00:00' }
let(:assignee) { double(attrs: { 'displayName' => 'Solver', 'emailAddress' => 'assignee@example.com' }) }
let(:reporter) { double(attrs: { 'displayName' => 'Reporter', 'emailAddress' => 'reporter@example.com' }) }
let(:assignee) { nil }
let(:reporter) { nil }
let(:jira_status) { 'new' }
let(:parent_field) do
@ -109,11 +110,12 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
end
context 'author' do
context 'when reporter maps to a valid GitLab user' do
let!(:user) { create(:user, email: 'reporter@example.com') }
let(:reporter) { double(attrs: { 'displayName' => 'Solver', 'accountId' => 'abcd' }) }
context 'when reporter maps to a valid GitLab user' do
it 'sets the issue author to the mapped user' do
project.add_developer(user)
expect(Gitlab::JiraImport).to receive(:get_user_mapping).with(project.id, 'abcd')
.and_return(user.id)
expect(subject[:author_id]).to eq(user.id)
end
@ -121,6 +123,9 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
context 'when reporter does not map to a valid Gitlab user' do
it 'defaults the issue author to project creator' do
expect(Gitlab::JiraImport).to receive(:get_user_mapping).with(project.id, 'abcd')
.and_return(nil)
expect(subject[:author_id]).to eq(current_user.id)
end
end
@ -129,25 +134,30 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
let(:reporter) { nil }
it 'defaults the issue author to project creator' do
expect(Gitlab::JiraImport).not_to receive(:get_user_mapping)
expect(subject[:author_id]).to eq(current_user.id)
end
end
context 'when reporter field is missing email address' do
context 'when reporter field is missing accountId' do
let(:reporter) { double(attrs: { 'displayName' => 'Reporter' }) }
it 'defaults the issue author to project creator' do
expect(Gitlab::JiraImport).not_to receive(:get_user_mapping)
expect(subject[:author_id]).to eq(current_user.id)
end
end
end
context 'assignee' do
context 'when assignee maps to a valid GitLab user' do
let!(:user) { create(:user, email: 'assignee@example.com') }
let(:assignee) { double(attrs: { 'displayName' => 'Solver', 'accountId' => '1234' }) }
context 'when assignee maps to a valid GitLab user' do
it 'sets the issue assignees to the mapped user' do
project.add_developer(user)
expect(Gitlab::JiraImport).to receive(:get_user_mapping).with(project.id, '1234')
.and_return(user.id)
expect(subject[:assignee_ids]).to eq([user.id])
end
@ -155,6 +165,9 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
context 'when assignee does not map to a valid GitLab user' do
it 'leaves the assignee empty' do
expect(Gitlab::JiraImport).to receive(:get_user_mapping).with(project.id, '1234')
.and_return(nil)
expect(subject[:assignee_ids]).to be_nil
end
end
@ -163,14 +176,18 @@ RSpec.describe Gitlab::JiraImport::IssueSerializer do
let(:assignee) { nil }
it 'leaves the assignee empty' do
expect(Gitlab::JiraImport).not_to receive(:get_user_mapping)
expect(subject[:assignee_ids]).to be_nil
end
end
context 'when assginee field is missing email address' do
let(:assignee) { double(attrs: { 'displayName' => 'Reporter' }) }
context 'when assginee field is missing accountId' do
let(:assignee) { double(attrs: { 'displayName' => 'Solver' }) }
it 'leaves the assignee empty' do
expect(Gitlab::JiraImport).not_to receive(:get_user_mapping)
expect(subject[:assignee_ids]).to be_nil
end
end

View File

@ -1,80 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::JiraImport::UserMapper do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user, email: 'user@example.com') }
let_it_be(:email) { create(:email, user: user, email: 'second_email@example.com', confirmed_at: nil) }
let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'user@example.com' } }
describe '#execute' do
subject { described_class.new(project, jira_user).execute }
context 'when jira_user is nil' do
let(:jira_user) { nil }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'when Gitlab user is not found by email' do
let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'other@example.com' } }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'when jira_user emailAddress is nil' do
let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => nil } }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'when jira_user emailAddress key is missing' do
let(:jira_user) { { 'acountId' => '1a2b' } }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'when found user is not a project member' do
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'when found user is a project member' do
it 'returns the found user' do
project.add_developer(user)
expect(subject).to eq(user)
end
end
context 'when user found by unconfirmd secondary address is a project member' do
let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'second_email@example.com' } }
it 'returns the found user' do
project.add_developer(user)
expect(subject).to eq(user)
end
end
context 'when user is a group member' do
it 'returns the found user' do
group.add_developer(user)
expect(subject).to eq(user)
end
end
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqVersioning::Middleware do
let(:worker_class) do
Class.new do
def self.name
'DummyWorker'
end
include ApplicationWorker
version 2
end
end
describe '#call' do
let(:worker) { worker_class.new }
let(:job) { { 'version' => 3, 'queue' => queue } }
let(:queue) { worker_class.queue }
def call!(&block)
block ||= -> {}
subject.call(worker, job, queue, &block)
end
it 'sets worker.job_version' do
call!
expect(worker.job_version).to eq(job['version'])
end
it 'yields' do
expect { |b| call!(&b) }.to yield_control
end
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqVersioning::Worker do
let(:worker) do
Class.new do
def self.name
'DummyWorker'
end
# ApplicationWorker includes Gitlab::SidekiqVersioning::Worker
include ApplicationWorker
version 2
end
end
describe '.version' do
context 'when called with an argument' do
it 'sets the version option' do
worker.version 3
expect(worker.get_sidekiq_options['version']).to eq(3)
end
end
context 'when called without an argument' do
it 'returns the version option' do
worker.sidekiq_options version: 3
expect(worker.version).to eq(3)
end
end
end
describe '#job_version' do
let(:job) { worker.new }
context 'when job_version is not set' do
it 'returns latest version' do
expect(job.job_version).to eq(2)
end
end
context 'when job_version is set' do
it 'returns the set version' do
job.job_version = 0
expect(job.job_version).to eq(0)
end
end
end
end

View File

@ -35,6 +35,12 @@ RSpec.describe Gitlab::SidekiqVersioning, :redis do
expect(Sidekiq::Manager).to include(Gitlab::SidekiqVersioning::Manager)
end
it 'adds the SidekiqVersioning::Middleware Sidekiq server middleware' do
described_class.install!
expect(Sidekiq.server_middleware.entries.map(&:klass)).to include(Gitlab::SidekiqVersioning::Middleware)
end
it 'registers all versionless and versioned queues with Redis' do
described_class.install!

View File

@ -386,6 +386,33 @@ RSpec.describe Service do
end
end
describe 'instance' do
describe '.instance_for' do
let_it_be(:jira_service) { create(:jira_service, :instance) }
let_it_be(:slack_service) { create(:slack_service, :instance) }
subject { described_class.instance_for(type) }
context 'Hipchat serivce' do
let(:type) { 'HipchatService' }
it { is_expected.to eq(nil) }
end
context 'Jira serivce' do
let(:type) { 'JiraService' }
it { is_expected.to eq(jira_service) }
end
context 'Slack serivce' do
let(:type) { 'SlackService' }
it { is_expected.to eq(slack_service) }
end
end
end
describe "{property}_changed?" do
let(:service) do
BambooService.create(