Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-19 21:12:42 +00:00
parent e4fc62c0af
commit c1ea2a9164
82 changed files with 2384 additions and 678 deletions

View file

@ -5,8 +5,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
import {
IssuableStatus,
IssuableStatusText,
IssuableType,
IssueTypePath,
IncidentTypePath,
IncidentType,
} from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
import Store from '../stores';
import descriptionComponent from './description.vue';
@ -195,8 +203,14 @@ export default {
showForm: false,
templatesRequested: false,
isStickyHeaderShowing: false,
issueState: {},
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
},
},
computed: {
issuableTemplates() {
return this.store.formState.issuableTemplates;
@ -288,7 +302,7 @@ export default {
methods: {
handleBeforeUnloadEvent(e) {
const event = e;
if (this.showForm && this.issueChanged) {
if (this.showForm && this.issueChanged && !this.issueState.isDirty) {
event.returnValue = __('Are you sure you want to lose your issue information?');
}
return undefined;
@ -346,14 +360,32 @@ export default {
},
updateIssuable() {
const {
store: { formState },
issueState,
} = this;
const issuablePayload = issueState.isDirty
? { ...formState, issue_type: issueState.issueType }
: formState;
this.clearFlash();
return this.service
.updateIssuable(this.store.formState)
.updateIssuable(issuablePayload)
.then((res) => res.data)
.then((data) => {
if (!window.location.pathname.includes(data.web_url)) {
if (
!window.location.pathname.includes(data.web_url) &&
issueState.issueType !== IncidentType
) {
visitUrl(data.web_url);
}
if (issueState.isDirty) {
const URI =
issueState.issueType === IncidentType
? data.web_url.replace(IssueTypePath, IncidentTypePath)
: data.web_url;
visitUrl(URI);
}
})
.then(this.updateStoreState)
.then(() => {

View file

@ -1,17 +1,24 @@
<script>
import { GlButton } from '@gitlab/ui';
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
const issuableTypes = {
issue: __('Issue'),
epic: __('Epic'),
incident: __('Incident'),
};
export default {
components: {
GlButton,
GlModal,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [updateMixin],
props: {
@ -36,19 +43,56 @@ export default {
data() {
return {
deleteLoading: false,
skipApollo: false,
issueState: {},
modalId: uniqueId('delete-issuable-modal-'),
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
skip() {
return this.skipApollo;
},
result() {
this.skipApollo = true;
},
},
},
computed: {
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
issuableType: this.typeToShow.toLowerCase(),
});
},
deleteIssuableModalText() {
return this.issuableType === 'epic'
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: this.typeToShow,
});
},
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
modalActionProps() {
return {
primary: {
text: this.deleteIssuableButtonText,
attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
},
cancel: {
text: __('Cancel'),
},
};
},
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
issuableType: issuableTypes[this.issuableType].toLowerCase(),
});
typeToShow() {
const { issueState, issuableType } = this;
const type = issueState.issueType ?? issuableType;
return issuableTypes[type];
},
},
methods: {
@ -56,49 +100,57 @@ export default {
eventHub.$emit('close.form');
},
deleteIssuable() {
const confirmMessage =
this.issuableType === 'epic'
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: issuableTypes[this.issuableType],
});
// eslint-disable-next-line no-alert
if (window.confirm(confirmMessage)) {
this.deleteLoading = true;
eventHub.$emit('delete.issuable', { destroy_confirm: true });
}
this.deleteLoading = true;
eventHub.$emit('delete.issuable', { destroy_confirm: true });
},
},
};
</script>
<template>
<div class="gl-mt-3 gl-mb-3 clearfix">
<gl-button
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
class="float-left qa-save-button gl-mr-3"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
<gl-button @click="closeForm">
{{ __('Cancel') }}
</gl-button>
<gl-button
v-if="shouldShowDeleteButton"
:loading="deleteLoading"
:disabled="deleteLoading"
category="secondary"
variant="danger"
class="float-right qa-delete-button"
@click="deleteIssuable"
>
{{ deleteIssuableButtonText }}
</gl-button>
<div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
<div>
<gl-button
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
class="qa-save-button gl-mr-3"
data-testid="issuable-save-button"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
<gl-button data-testid="issuable-cancel-button" @click="closeForm">
{{ __('Cancel') }}
</gl-button>
</div>
<div v-if="shouldShowDeleteButton">
<gl-button
v-gl-modal="modalId"
:loading="deleteLoading"
:disabled="deleteLoading"
category="secondary"
variant="danger"
class="qa-delete-button"
data-testid="issuable-delete-button"
>
{{ deleteIssuableButtonText }}
</gl-button>
<gl-modal
ref="removeModal"
:modal-id="modalId"
size="sm"
:action-primary="modalActionProps.primary"
:action-cancel="modalActionProps.cancel"
@primary="deleteIssuable"
>
<template #modal-title>{{ deleteIssuableButtonText }}</template>
<div>
<p class="gl-mb-1">{{ deleteIssuableModalText }}</p>
</div>
</gl-modal>
</div>
</div>
</template>

View file

@ -54,14 +54,14 @@ export default {
<template>
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues">
<div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues">
<button
ref="toggle"
:data-namespace-path="projectNamespace"
:data-project-path="projectPath"
:data-project-id="projectId"
:data-data="issuableTemplatesJson"
class="dropdown-menu-toggle js-issuable-selector"
class="dropdown-menu-toggle js-issuable-selector gl-button"
type="button"
data-field-name="issuable_template"
data-selected="null"

View file

@ -20,7 +20,7 @@ export default {
id="issuable-title"
ref="input"
v-model="formState.title"
class="form-control qa-title-input"
class="form-control qa-title-input gl-border-gray-200"
dir="auto"
type="text"
:placeholder="__('Title')"

View file

@ -0,0 +1,79 @@
<script>
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { capitalize } from 'lodash';
import { __ } from '~/locale';
import { IssuableTypes } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
export const i18n = {
label: __('Issue Type'),
};
export default {
i18n,
IssuableTypes,
components: {
GlFormGroup,
GlDropdown,
GlDropdownItem,
},
data() {
return {
issueState: {},
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
},
},
computed: {
dropdownText() {
const {
issueState: { issueType },
} = this;
return capitalize(issueType);
},
},
methods: {
updateIssueType(issueType) {
this.$apollo.mutate({
mutation: updateIssueStateMutation,
variables: {
issueType,
isDirty: true,
},
});
},
},
};
</script>
<template>
<gl-form-group
:label="$options.i18n.label"
label-class="sr-only"
label-for="issuable-type"
class="mb-2 mb-md-0"
>
<gl-dropdown
id="issuable-type"
:aria-labelledby="$options.i18n.label"
:text="dropdownText"
:header-text="$options.i18n.label"
class="gl-w-full"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="type in $options.IssuableTypes"
:key="type.value"
:is-checked="issueState.issueType === type.value"
is-check-item
@click="updateIssueType(type.value)"
>
{{ type.text }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</template>

View file

@ -2,21 +2,24 @@
import { GlAlert } from '@gitlab/ui';
import $ from 'jquery';
import Autosave from '~/autosave';
import { IssuableType } from '~/issue_show/constants';
import eventHub from '../event_hub';
import editActions from './edit_actions.vue';
import descriptionField from './fields/description.vue';
import descriptionTemplate from './fields/description_template.vue';
import titleField from './fields/title.vue';
import lockedWarning from './locked_warning.vue';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
import DescriptionTemplateField from './fields/description_template.vue';
import IssuableTitleField from './fields/title.vue';
import IssuableTypeField from './fields/type.vue';
import LockedWarning from './locked_warning.vue';
export default {
components: {
lockedWarning,
titleField,
descriptionField,
descriptionTemplate,
editActions,
DescriptionField,
DescriptionTemplateField,
EditActions,
GlAlert,
IssuableTitleField,
IssuableTypeField,
LockedWarning,
},
props: {
canDestroy: {
@ -89,6 +92,9 @@ export default {
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
},
isIssueType() {
return this.issuableType === IssuableType.Issue;
},
},
created() {
eventHub.$on('delete.issuable', this.resetAutosave);
@ -162,7 +168,7 @@ export default {
</script>
<template>
<form>
<form data-testid="issuable-form">
<locked-warning v-if="showLockedWarning" />
<gl-alert
v-if="showOutdatedDescriptionWarning"
@ -179,9 +185,17 @@ export default {
)
}}</gl-alert
>
<div class="row gl-mb-3">
<div class="col-12">
<issuable-title-field ref="title" :form-state="formState" />
</div>
</div>
<div class="row">
<div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3">
<description-template
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<issuable-type-field ref="issue-type" />
</div>
<div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
<description-template-field
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
@ -189,14 +203,6 @@ export default {
:project-namespace="projectNamespace"
/>
</div>
<div
:class="{
'col-sm-8 col-lg-9': hasIssuableTemplates,
'col-12': !hasIssuableTemplates,
}"
>
<title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" />
</div>
</div>
<description-field
ref="description"

View file

@ -25,3 +25,14 @@ export const IssueStateEvent = {
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
export const IssuableTypes = [
{ value: 'issue', text: __('Issue') },
{ value: 'incident', text: __('Incident') },
];
export const IssueTypePath = 'issues';
export const IncidentTypePath = 'issues/incident';
export const IncidentType = 'incident';
export const issueState = { issueType: undefined, isDirty: false };

View file

@ -0,0 +1,9 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { defaultClient } from '~/sidebar/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient,
});

View file

@ -1,15 +1,23 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incidents/incident_tabs.vue';
Vue.use(VueApollo);
import { issueState } from './constants';
import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
export default function initIssuableApp(issuableData = {}) {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getIssueStateQuery,
data: {
issueState: { ...issueState, issueType: el.dataset.issueType },
},
});
const {
@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) {
const fullPath = `${projectNamespace}/${projectPath}`;
return new Vue({
el: document.getElementById('js-issuable-app'),
el,
apolloProvider,
components: {
issuableApp,

View file

@ -1,14 +1,33 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
import { issueState } from './constants';
import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
const bootstrapApollo = (state = {}) => {
return apolloProvider.clients.defaultClient.cache.writeQuery({
query: getIssueStateQuery,
data: {
issueState: state,
},
});
};
export function initIssuableApp(issuableData, store) {
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
return new Vue({
el: document.getElementById('js-issuable-app'),
el,
apolloProvider,
store,
computed: {
...mapGetters(['getNoteableData']),
@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) {
return undefined;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
return new Vue({
el,

View file

@ -0,0 +1,3 @@
query issueState {
issueState @client
}

View file

@ -0,0 +1,3 @@
mutation updateIssueState($issueType: String, $isDirty: Boolean) {
updateIssueState(issueType: $issueType, isDirty: $isDirty) @client
}

View file

@ -2,6 +2,7 @@ import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import { initRunnerList } from '~/runner/runner_list';
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
@ -10,3 +11,7 @@ initFilteredSearch({
});
initInstallRunner();
if (gon.features?.runnerListViewVueUi) {
initRunnerList();
}

View file

@ -0,0 +1,44 @@
<script>
import { GlLink } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
components: {
GlLink,
TooltipOnTruncate,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerNumericalId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerUrl() {
// TODO implement using webUrl from the API
return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
},
description() {
return this.runner.description;
},
shortSha() {
return this.runner.shortSha;
},
},
};
</script>
<template>
<div>
<gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
<div class="gl-text-truncate">
{{ description }}
</div>
</tooltip-on-truncate>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script>
import { GlBadge } from '@gitlab/ui';
import RunnerTypeBadge from '../runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerType() {
return this.runner.runnerType;
},
locked() {
return this.runner.locked;
},
paused() {
return !this.runner.active;
},
},
};
</script>
<template>
<div>
<runner-type-badge :type="runnerType" size="sm" />
<gl-badge v-if="locked" variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
<gl-badge v-if="paused" variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
</div>
</template>

View file

@ -0,0 +1,142 @@
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { formatNumber, sprintf, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerNameCell from './cells/runner_name_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
import RunnerTags from './runner_tags.vue';
const tableField = ({ key, label = '', width = 10 }) => {
return {
key,
label,
thClass: [
`gl-w-${width}p`,
'gl-bg-transparent!',
'gl-border-b-solid!',
'gl-border-b-gray-100!',
'gl-py-5!',
'gl-px-0!',
'gl-border-b-1!',
],
tdClass: ['gl-py-5!', 'gl-px-1!'],
tdAttr: {
'data-testid': `td-${key}`,
},
};
};
export default {
components: {
GlTable,
GlSkeletonLoader,
TimeAgo,
RunnerNameCell,
RunnerTags,
RunnerTypeCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
runners: {
type: Array,
required: true,
},
activeRunnersCount: {
type: Number,
required: true,
},
},
computed: {
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
},
methods: {
runnerTrAttr(runner) {
if (runner) {
return {
'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`,
};
}
return {};
},
},
fields: [
tableField({ key: 'type', label: __('Type/State') }),
tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }),
tableField({ key: 'tagList', label: __('Tags') }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
};
</script>
<template>
<div>
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
<gl-table
:busy="loading"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
stacked="md"
fixed
>
<template #table-busy>
<gl-skeleton-loader />
</template>
<template #cell(type)="{ item }">
<runner-type-cell :runner="item" />
</template>
<template #cell(name)="{ item }">
<runner-name-cell :runner="item" />
</template>
<template #cell(version)="{ item: { version } }">
{{ version }}
</template>
<template #cell(ipAddress)="{ item: { ipAddress } }">
{{ ipAddress }}
</template>
<template #cell(projectCount)>
<!-- TODO add projects count -->
</template>
<template #cell(jobCount)>
<!-- TODO add jobs count -->
</template>
<template #cell(tagList)="{ item: { tagList } }">
<runner-tags :tag-list="tagList" size="sm" />
</template>
<template #cell(contactedAt)="{ item: { contactedAt } }">
<time-ago v-if="contactedAt" :time="contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>
<template #cell(actions)>
<!-- TODO add actions to update runners -->
</template>
</gl-table>
<!-- TODO implement pagination -->
</div>
</template>

View file

@ -0,0 +1,76 @@
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
export default {
components: {
GlLink,
GlSprintf,
ClipboardButton,
RunnerInstructions,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
runnerInstallHelpPage: {
default: null,
},
},
props: {
registrationToken: {
type: String,
required: true,
},
typeName: {
type: String,
required: false,
default: __('shared'),
},
},
computed: {
rootUrl() {
return gon.gitlab_url || '';
},
},
};
</script>
<template>
<div class="bs-callout">
<h5 data-testid="runner-help-title">
<gl-sprintf :message="__('Set up a %{type} runner manually')">
<template #type>
{{ typeName }}
</template>
</gl-sprintf>
</h5>
<ol>
<li>
<gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
{{ __("Install GitLab Runner and ensure it's running.") }}
</gl-link>
</li>
<li>
{{ __('Register the runner with this URL:') }}
<br />
<code data-testid="coordinator-url">{{ rootUrl }}</code>
<clipboard-button :title="__('Copy URL')" :text="rootUrl" />
</li>
<li>
{{ __('And this registration token:') }}
<br />
<code data-testid="registration-token">{{ registrationToken }}</code>
<clipboard-button :title="__('Copy token')" :text="registrationToken" />
</li>
</ol>
<!-- TODO Implement reset token functionality -->
<runner-instructions />
</div>
</template>

View file

@ -0,0 +1,33 @@
<script>
import { GlBadge } from '@gitlab/ui';
export default {
components: {
GlBadge,
},
props: {
tagList: {
type: Array,
required: false,
default: () => [],
},
size: {
type: String,
required: false,
default: 'md',
},
variant: {
type: String,
required: false,
default: 'info',
},
},
};
</script>
<template>
<div>
<gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
{{ tag }}
</gl-badge>
</div>
</template>

View file

@ -0,0 +1,60 @@
<script>
import { GlBadge } from '@gitlab/ui';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
runnerTypes: {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
},
};
</script>
<template>
<div class="bs-callout">
<p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
<p>
{{
__(
'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
)
}}
</p>
<div>
<span> {{ __('Runners can be:') }}</span>
<ul>
<li>
<runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects in its group.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
- {{ __('Runs jobs from assigned projects.') }}
</li>
<li>
<gl-badge variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
- {{ __('Cannot be assigned to other projects.') }}
</li>
<li>
<gl-badge variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
- {{ __('Not available to run jobs.') }}
</li>
</ul>
</div>
</div>
</template>

View file

@ -0,0 +1,17 @@
query getRunners {
runners {
nodes {
id
description
runnerType
shortSha
version
revision
ipAddress
active
locked
tagList
contactedAt
}
}
}

View file

@ -0,0 +1,42 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import RunnerDetailsApp from './runner_list_app.vue';
Vue.use(VueApollo);
export const initRunnerList = (selector = '#js-runner-list') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
// TODO `activeRunnersCount` should be implemented using a GraphQL API.
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
return new Vue({
el,
apolloProvider,
provide: {
runnerInstallHelpPage,
},
render(h) {
return h(RunnerDetailsApp, {
props: {
activeRunnersCount: parseInt(activeRunnersCount, 10),
registrationToken,
},
});
},
});
};

View file

@ -0,0 +1,82 @@
<script>
import * as Sentry from '@sentry/browser';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
export default {
components: {
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
},
props: {
activeRunnersCount: {
type: Number,
required: true,
},
registrationToken: {
type: String,
required: true,
},
},
data() {
return {
runners: [],
};
},
apollo: {
runners: {
query: getRunnersQuery,
update({ runners }) {
return runners?.nodes || [];
},
error(err) {
this.captureException(err);
},
},
},
computed: {
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.length;
},
},
errorCaptured(err) {
this.captureException(err);
},
methods: {
captureException(err) {
Sentry.withScope((scope) => {
scope.setTag('component', 'runner_list_app');
Sentry.captureException(err);
});
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col-sm-6">
<runner-type-help />
</div>
<div class="col-sm-6">
<runner-manual-setup-help :registration-token="registrationToken" />
</div>
</div>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list
v-else
:runners="runners"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
</div>
</template>

View file

@ -1,5 +1,7 @@
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
export const defaultClient = createDefaultClient(
{},
{
cacheConfig: {
fragmentMatcher,
const resolvers = {
Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
const data = produce(sourceData, (draftData) => {
draftData.issueState = { issueType, isDirty };
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
assumeImmutableResults: true,
},
);
};
export const defaultClient = createDefaultClient(resolvers, {
cacheConfig: {
fragmentMatcher,
},
assumeImmutableResults: true,
});
export const apolloProvider = new VueApollo({
defaultClient,

View file

@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
end
feature_category :continuous_integration

View file

@ -160,8 +160,8 @@ module CommitsHelper
commit.author,
ref,
{
merge_request: merge_request,
pipeline_status: commit.status_for(ref),
merge_request: merge_request&.cache_key,
pipeline_status: commit.status_for(ref)&.cache_key,
xhr: request.xhr?,
controller: controller.controller_path,
path: @path # referred to in #link_to_browse_code

View file

@ -22,32 +22,26 @@ module Nav
def build_anonymous_view_model(builder:)
# These come from `app/views/layouts/nav/_explore.html.ham`
if explore_nav_link?(:projects)
builder.add_primary_menu_item(
**projects_menu_item_attrs.merge(
{
active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]),
href: explore_root_path
})
builder.add_primary_menu_item_with_shortcut(
href: explore_root_path,
active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]),
**projects_menu_item_attrs
)
end
if explore_nav_link?(:groups)
builder.add_primary_menu_item(
**groups_menu_item_attrs.merge(
{
active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']),
href: explore_groups_path
})
builder.add_primary_menu_item_with_shortcut(
href: explore_groups_path,
active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']),
**groups_menu_item_attrs
)
end
if explore_nav_link?(:snippets)
builder.add_primary_menu_item(
**snippets_menu_item_attrs.merge(
{
active: active_nav_link?(controller: :snippets),
href: explore_snippets_path
})
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(controller: :snippets),
href: explore_snippets_path,
**snippets_menu_item_attrs
)
end
end
@ -57,13 +51,13 @@ module Nav
if dashboard_nav_link?(:projects)
current_item = project ? current_project(project: project) : {}
builder.add_primary_menu_item(
**projects_menu_item_attrs.merge({
active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
css_class: 'qa-projects-dropdown',
data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
view: PROJECTS_VIEW
})
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
css_class: 'qa-projects-dropdown',
data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
view: PROJECTS_VIEW,
shortcut_href: dashboard_projects_path,
**projects_menu_item_attrs
)
builder.add_view(PROJECTS_VIEW, container_view_props(namespace: 'projects', current_item: current_item, submenu: projects_submenu))
end
@ -71,46 +65,47 @@ module Nav
if dashboard_nav_link?(:groups)
current_item = group ? current_group(group: group) : {}
builder.add_primary_menu_item(
**groups_menu_item_attrs.merge({
active: active_nav_link?(path: %w[dashboard/groups explore/groups]),
css_class: 'qa-groups-dropdown',
data: { track_label: "groups_dropdown", track_event: "click_dropdown" },
view: GROUPS_VIEW
})
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(path: %w[dashboard/groups explore/groups]),
css_class: 'qa-groups-dropdown',
data: { track_label: "groups_dropdown", track_event: "click_dropdown" },
view: GROUPS_VIEW,
shortcut_href: dashboard_groups_path,
**groups_menu_item_attrs
)
builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu))
end
if dashboard_nav_link?(:milestones)
builder.add_primary_menu_item(
builder.add_primary_menu_item_with_shortcut(
id: 'milestones',
title: 'Milestones',
href: dashboard_milestones_path,
active: active_nav_link?(controller: 'dashboard/milestones'),
icon: 'clock',
data: { qa_selector: 'milestones_link' },
href: dashboard_milestones_path
shortcut_class: 'dashboard-shortcuts-milestones'
)
end
if dashboard_nav_link?(:snippets)
builder.add_primary_menu_item(
**snippets_menu_item_attrs.merge({
active: active_nav_link?(controller: 'dashboard/snippets'),
data: { qa_selector: 'snippets_link' },
href: dashboard_snippets_path
})
builder.add_primary_menu_item_with_shortcut(
active: active_nav_link?(controller: 'dashboard/snippets'),
data: { qa_selector: 'snippets_link' },
href: dashboard_snippets_path,
**snippets_menu_item_attrs
)
end
if dashboard_nav_link?(:activity)
builder.add_primary_menu_item(
builder.add_primary_menu_item_with_shortcut(
id: 'activity',
title: 'Activity',
href: activity_dashboard_path,
active: active_nav_link?(path: 'dashboard#activity'),
icon: 'history',
data: { qa_selector: 'activity_link' },
href: activity_dashboard_path
shortcut_class: 'dashboard-shortcuts-activity'
)
end
@ -165,7 +160,8 @@ module Nav
{
id: 'project',
title: _('Projects'),
icon: 'project'
icon: 'project',
shortcut_class: 'dashboard-shortcuts-projects'
}
end
@ -173,7 +169,8 @@ module Nav
{
id: 'groups',
title: 'Groups',
icon: 'group'
icon: 'group',
shortcut_class: 'dashboard-shortcuts-groups'
}
end
@ -181,7 +178,8 @@ module Nav
{
id: 'snippets',
title: _('Snippets'),
icon: 'snippet'
icon: 'snippet',
shortcut_class: 'dashboard-shortcuts-snippets'
}
end

View file

@ -753,22 +753,14 @@ module Ci
end
def any_runners_online?
if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
cache_for_online_runners do
project.any_online_runners? { |runner| runner.match_build_if_online?(self) }
end
else
project.any_active_runners? { |runner| runner.match_build_if_online?(self) }
cache_for_online_runners do
project.any_online_runners? { |runner| runner.match_build_if_online?(self) }
end
end
def any_runners_available?
if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
cache_for_available_runners do
project.active_runners.exists?
end
else
project.any_active_runners?
cache_for_available_runners do
project.active_runners.exists?
end
end

View file

@ -1751,11 +1751,6 @@ class Project < ApplicationRecord
end
end
# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326989
def any_active_runners?(&block)
active_runners_with_tags.any?(&block)
end
def any_online_runners?(&block)
online_runners_with_tags.any?(&block)
end

View file

@ -38,6 +38,7 @@ module Issues
super
params.delete(:issue_type) unless issue_type_allowed?(issue)
filter_incident_label(issue) if params[:issue_type]
moved_issue = params.delete(:moved_issue)
@ -82,6 +83,37 @@ module Issues
def issue_type_allowed?(object)
can?(current_user, :"create_#{params[:issue_type]}", object)
end
# @param issue [Issue]
def filter_incident_label(issue)
return unless add_incident_label?(issue) || remove_incident_label?(issue)
label = ::IncidentManagement::CreateIncidentLabelService
.new(project, current_user)
.execute
.payload[:label]
# These(add_label_ids, remove_label_ids) are being added ahead of time
# to be consumed by #process_label_ids, this allows system notes
# to be applied correctly alongside the label updates.
if add_incident_label?(issue)
params[:add_label_ids] ||= []
params[:add_label_ids] << label.id
else
params[:remove_label_ids] ||= []
params[:remove_label_ids] << label.id
end
end
# @param issue [Issue]
def add_incident_label?(issue)
issue.incident?
end
# @param _issue [Issue, nil]
def remove_incident_label?(_issue)
false
end
end
end

View file

@ -34,7 +34,6 @@ module Issues
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def after_create(issue)
add_incident_label(issue)
user_agent_detail_service.create
resolve_discussions_with_issue(issue)
@ -56,22 +55,6 @@ module Issues
def user_agent_detail_service
UserAgentDetailService.new(@issue, request)
end
# Applies label "incident" (creates it if missing) to incident issues.
# For use in "after" hooks only to ensure we are not appyling
# labels prematurely.
def add_incident_label(issue)
return unless issue.incident?
label = ::IncidentManagement::CreateIncidentLabelService
.new(project, current_user)
.execute
.payload[:label]
return if issue.label_ids.include?(label.id)
issue.labels << label
end
end
end

View file

@ -204,6 +204,16 @@ module Issues
def create_confidentiality_note(issue)
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
end
override :add_incident_label?
def add_incident_label?(issue)
issue.issue_type != params[:issue_type] && !issue.incident?
end
override :remove_incident_label?
def remove_incident_label?(issue)
issue.issue_type != params[:issue_type] && issue.incident?
end
end
end

View file

@ -1,7 +1,7 @@
-# Note: This file should stay aligned with:
-# `app/views/groups/runners/_runner.html.haml`
.gl-responsive-table-row{ id: dom_id(runner) }
.gl-responsive-table-row{ data: { testid: "runner-row-#{runner.id}" } }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content

View file

@ -1,87 +1,97 @@
- breadcrumb_title _('Runners')
- page_title _('Runners')
.row
.col-sm-6
.bs-callout
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
%br
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
%br
- if Feature.enabled?(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
#js-runner-list{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } }
- else
.row
.col-sm-6
.bs-callout
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
%br
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
%br
%div
%span= _('Runners can be:')
%ul
%li
%span.badge.badge-pill.gl-badge.sm.badge-success shared
\-
= _('Runs jobs from all unassigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-success group
\-
= _('Runs jobs from all unassigned projects in its group.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-info specific
\-
= _('Runs jobs from assigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
\-
= _('Cannot be assigned to other projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
\-
= _('Not available to run jobs.')
%div
%span= _('Runners can be:')
%ul
%li
%span.badge.badge-pill.gl-badge.sm.badge-success shared
\-
= _('Runs jobs from all unassigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-success group
\-
= _('Runs jobs from all unassigned projects in its group.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-info specific
\-
= _('Runs jobs from assigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
\-
= _('Cannot be assigned to other projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
\-
= _('Not available to run jobs.')
.col-sm-6
.bs-callout
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
type: 'shared',
reset_token_url: reset_registration_token_admin_application_settings_path,
project_path: '',
group_path: '' }
.col-sm-6
.bs-callout
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
type: 'shared',
reset_token_url: reset_registration_token_admin_application_settings_path,
project_path: '',
group_path: '' }
.row
.col-sm-9
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[gl-button btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
.row
.col-sm-9
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[gl-button btn btn-link] do
= status.titleize
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
= button_tag class: %w[gl-button btn btn-link] do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
@ -90,49 +100,41 @@
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.gl-button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.gl-button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.gl-button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.gl-button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
= button_tag class: %w[clear-search hidden] do
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container
= render 'sort_dropdown'
= button_tag class: %w[clear-search hidden] do
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container
= render 'sort_dropdown'
.col-sm-3.text-right-lg
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
- if @runners.any?
.content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
.table-section.section-10{ role: 'rowheader' }= _('Tags')
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
.table-section.section-10{ role: 'rowheader' }
.col-sm-3.text-right-lg
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
- if @runners.any?
.content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
.table-section.section-10{ role: 'rowheader' }= _('Tags')
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
.table-section.section-10{ role: 'rowheader' }
- @runners.each do |runner|
= render 'admin/runners/runner', runner: runner
= paginate @runners, theme: 'gitlab'
- else
.nothing-here-block= _('No runners found')
- @runners.each do |runner|
= render 'admin/runners/runner', runner: runner
= paginate @runners, theme: 'gitlab'
- else
.nothing-here-block= _('No runners found')

View file

@ -14,7 +14,7 @@
%br
= _("And this registration token:")
%br
%code#registration_token= registration_token
%code#registration_token{ data: {testid: 'registration_token' } }= registration_token
= clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
.gl-mt-3.gl-mb-3

View file

@ -5,3 +5,8 @@
= sprite_icon('dot-grid', css_class: "dropdown-icon")
= view_model[:activeTitle]
= sprite_icon('chevron-down')
.hidden
- view_model[:shortcuts].each do |shortcut|
= link_to shortcut[:href], class: shortcut[:css_class] do
= shortcut[:title]

View file

@ -13,6 +13,7 @@ module ApplicationWorker
include Gitlab::SidekiqVersioning::Worker
LOGGING_EXTRA_KEY = 'extra'
DEFAULT_DELAY_INTERVAL = 1
included do
set_queue
@ -51,6 +52,16 @@ module ApplicationWorker
subclass.after_set_class_attribute { subclass.set_queue }
end
def perform_async(*args)
# Worker execution for workers with data_consistency set to :delayed or :sticky
# will be delayed to give replication enough time to complete
if utilizes_load_balancing_capabilities? && data_consistency_delayed_execution_feature_flag_enabled?
perform_in(delay_interval, *args)
else
super
end
end
def set_queue
queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self)
sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue
@ -111,5 +122,15 @@ module ApplicationWorker
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
end
end
protected
def data_consistency_delayed_execution_feature_flag_enabled?
Feature.enabled?(:data_consistency_delayed_execution, default_enabled: :yaml)
end
def delay_interval
DEFAULT_DELAY_INTERVAL.seconds
end
end
end

View file

@ -71,6 +71,20 @@ module WorkerAttributes
class_attributes[:urgency] || :low
end
# Allows configuring worker's data_consistency.
#
# Worker can utilize Sidekiq readonly database replicas capabilities by setting data_consistency attribute.
# Workers with data_consistency set to :delayed or :sticky, calling #perform_async
# will be delayed in order to give replication process enough time to complete.
#
# - *data_consistency* values:
# - 'always' - The job is required to use the primary database (default).
# - 'sticky' - The uses a replica as long as possible. It switches to primary either on write or long replication lag.
# - 'delayed' - The job would switch to primary only on write. It would use replica always.
# If there's a long replication lag the job will be delayed, and only if the replica is not up to date on the next retry,
# it will switch to the primary.
# - *feature_flag* - allows you to toggle a job's `data_consistency, which permits you to safely toggle load balancing capabilities for a specific job.
# If disabled, job will default to `:always`, which means that the job will always use the primary.
def data_consistency(data_consistency, feature_flag: nil)
raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency)
raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency]
@ -85,11 +99,16 @@ module WorkerAttributes
# Since the deduplication should always take into account the latest binary replication pointer into account,
# not the first one, the deduplication will not work with sticky or delayed.
# Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291
if idempotent? && get_data_consistency != :always
if idempotent? && utilizes_load_balancing_capabilities?
raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always"
end
end
# If data_consistency is not set to :always, worker will try to utilize load balancing capabilities and use the replica
def utilizes_load_balancing_capabilities?
get_data_consistency != :always
end
def get_data_consistency
class_attributes[:data_consistency] || :always
end

View file

@ -0,0 +1,5 @@
---
title: Allow issue type change for incidents
merge_request: 61363
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Use cache for CI::Build runners check
merge_request: 61998
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Remove some deprecated global routes
merge_request: 34295
author:
type: removed

View file

@ -0,0 +1,8 @@
---
name: data_consistency_delayed_execution
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61501
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331110
milestone: '14.0'
type: development
group: group::memory
default_enabled: false

View file

@ -1,8 +1,8 @@
---
name: runners_cached_states
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57367
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326989
milestone: '13.11'
name: runner_list_view_vue_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61241
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330969
milestone: '13.12'
type: development
group: group::source code
group: group::runner
default_enabled: false

View file

@ -252,38 +252,6 @@ Rails.application.routes.draw do
end
end
# Deprecated routes.
# Will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/210024
scope as: :deprecated do
# Autocomplete
get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
get '/autocomplete/merge_request_target_branches' => 'autocomplete#merge_request_target_branches'
Gitlab.ee do
get '/autocomplete/project_groups' => 'autocomplete#project_groups'
get '/autocomplete/project_routes' => 'autocomplete#project_routes'
get '/autocomplete/namespace_routes' => 'autocomplete#namespace_routes'
end
resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do
member do
post :accept
match :decline, via: [:get, :post]
end
end
resources :sent_notifications, only: [], constraints: { id: /\h{32}/ } do
member do
get :unsubscribe
end
end
resources :abuse_reports, only: [:new, :create]
end
resources :groups, only: [:index, :new, :create] do
post :preview_markdown
end

View file

@ -417,8 +417,27 @@ To configure the `s3` storage driver in Omnibus:
}
```
- `regionendpoint` is only required when configuring an S3 compatible service such as MinIO. It takes a URL such as `http://127.0.0.1:9000`.
If using with an [AWS S3 VPC endpoint](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html),
then set `regionendpoint` to your VPC endpoint address and set `path_style` to false:
```ruby
registry['storage'] = {
's3' => {
'accesskey' => 's3-access-key',
'secretkey' => 's3-secret-key-for-access-key',
'bucket' => 'your-s3-bucket',
'region' => 'your-s3-region',
'regionendpoint' => 'your-s3-vpc-endpoint',
'path_style' => false
}
}
```
- `regionendpoint` is only required when configuring an S3 compatible service such as MinIO, or
when using an AWS S3 VPC Endpoint.
- `your-s3-bucket` should be the name of a bucket that exists, and can't include subdirectories.
- `path_style` should be set to true to use `host/bucket_name/object` style paths instead of
`bucket_name.host/object`. [Set to false for AWS S3](https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/).
1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -326,6 +326,17 @@ In order to change the default issue closing pattern, GitLab administrators must
[`gitlab.rb` or `gitlab.yml` file](../../../administration/issue_closing_pattern.md)
of your installation.
## Change the issue type
Users with [developer permission](../../permissions.md)
can change an issue's type. To do this, edit the issue and select an issue type from the
**Issue type** selector menu:
- [Issue](index.md)
- [Incident](../../../operations/incident_management/index.md)
![Change the issue type](img/issue_type_change_v13_12.png)
## Deleting issues
Users with [project owner permission](../../permissions.md) can delete an issue by

View file

@ -54,13 +54,11 @@ Currently the following names are reserved as top level groups:
- `500.html`
- `502.html`
- `503.html`
- `abuse_reports`
- `admin`
- `api`
- `apple-touch-icon-precomposed.png`
- `apple-touch-icon.png`
- `assets`
- `autocomplete`
- `dashboard`
- `deploy.html`
- `explore`
@ -71,7 +69,6 @@ Currently the following names are reserved as top level groups:
- `health_check`
- `help`
- `import`
- `invites`
- `jwt`
- `login`
- `oauth`
@ -81,7 +78,6 @@ Currently the following names are reserved as top level groups:
- `robots.txt`
- `s`
- `search`
- `sent_notifications`
- `sitemap`
- `sitemap.xml`
- `sitemap.xml.gz`

View file

@ -6,9 +6,34 @@ module Gitlab
def initialize
@menu_builder = ::Gitlab::Nav::TopNavMenuBuilder.new
@views = {}
@shortcuts = []
end
delegate :add_primary_menu_item, :add_secondary_menu_item, to: :@menu_builder
# Using delegate hides the stacktrace for some errors, so we choose to be explicit.
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62047#note_579031091
def add_primary_menu_item(**args)
@menu_builder.add_primary_menu_item(**args)
end
def add_secondary_menu_item(**args)
@menu_builder.add_secondary_menu_item(**args)
end
def add_shortcut(**args)
item = ::Gitlab::Nav::TopNavMenuItem.build(**args)
@shortcuts.push(item)
end
def add_primary_menu_item_with_shortcut(shortcut_class:, shortcut_href: nil, **args)
add_primary_menu_item(**args)
add_shortcut(
id: "#{args.fetch(:id)}-shortcut",
title: args.fetch(:title),
href: shortcut_href || args.fetch(:href),
css_class: shortcut_class
)
end
def add_view(name, props)
@views[name] = props
@ -19,6 +44,7 @@ module Gitlab
menu.merge({
views: @views,
shortcuts: @shortcuts,
activeTitle: _('Menu')
})
end

View file

@ -21,13 +21,11 @@ module Gitlab
500.html
502.html
503.html
abuse_reports
admin
api
apple-touch-icon-precomposed.png
apple-touch-icon.png
assets
autocomplete
dashboard
deploy.html
explore
@ -38,7 +36,6 @@ module Gitlab
health_check
help
import
invites
jwt
login
oauth
@ -48,7 +45,6 @@ module Gitlab
robots.txt
s
search
sent_notifications
sitemap
sitemap.xml
sitemap.xml.gz

View file

@ -18230,6 +18230,9 @@ msgstr ""
msgid "Issue Boards"
msgstr ""
msgid "Issue Type"
msgstr ""
msgid "Issue already promoted to epic."
msgstr ""
@ -32145,9 +32148,6 @@ msgstr ""
msgid "The \"%{group_path}\" group allows you to sign in with your Single Sign-On Account"
msgstr ""
msgid "The \"Require approval from CODEOWNERS\" setting was moved to %{banner_link_start}Protected Branches%{banner_link_end}"
msgstr ""
msgid "The %{featureName} feature is part of your GitLab Ultimate trial."
msgstr ""

View file

@ -12,6 +12,10 @@ RSpec.describe Admin::RunnersController do
describe '#index' do
render_views
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
it 'lists all runners' do
get :index

View file

@ -17,6 +17,10 @@ RSpec.describe "Admin Runners" do
describe "Runners page" do
let(:pipeline) { create(:ci_pipeline) }
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
context "when there are runners" do
it 'has all necessary texts' do
runner = create(:ci_runner, contacted_at: Time.now)
@ -240,7 +244,7 @@ RSpec.describe "Admin Runners" do
it 'shows the label and does not show the project count' do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'group'
expect(page).to have_text 'n/a'
end
@ -253,7 +257,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'shared'
expect(page).to have_text 'n/a'
end
@ -267,12 +271,36 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'specific'
expect(page).to have_text '1'
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('[data-testid="registration_token"]')).to have_content(token)
end
describe 'reset registration token' do
let(:page_token) { find('[data-testid="registration_token"]').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end
describe "Runner show page" do
@ -381,28 +409,4 @@ RSpec.describe "Admin Runners" do
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('#registration_token')).to have_content(token)
end
describe 'reload registration token' do
let(:page_token) { find('#registration_token').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end

View file

@ -15,8 +15,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
end
it 'navigate to tabs' do
pending_on_combined_menu_flag
find('body').send_keys([:shift, 'I'])
check_page_title('Issues')
@ -49,8 +47,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
end
it 'navigate to tabs' do
pending_on_combined_menu_flag
find('body').send_keys([:shift, 'G'])
find('.nothing-here-block')
@ -74,8 +70,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
end
context 'with combined_menu: feature flag on' do
let(:needs_rewrite_for_combined_menu_flag_on) { true }
before do
stub_feature_flags(combined_menu: true)
end
@ -84,16 +78,10 @@ RSpec.describe 'Dashboard shortcuts', :js do
end
context 'with combined_menu feature flag off' do
let(:needs_rewrite_for_combined_menu_flag_on) { false }
before do
stub_feature_flags(combined_menu: false)
end
it_behaves_like 'combined_menu: feature flag examples'
end
def pending_on_combined_menu_flag
pending 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587' if needs_rewrite_for_combined_menu_flag_on
end
end

View file

@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do
end
end
end
context 'when an incident `issue_type` is edited by a signed in user' do
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
wait_for_requests
project_path = "/#{project.full_path}"
click_button 'Edit title and description'
wait_for_requests
page.within('[data-testid="issuable-form"]') do
click_button 'Incident'
click_button 'Issue'
click_button 'Save changes'
wait_for_requests
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
end
end
end
context 'when incident details are edited by a signed in user' do
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
wait_for_requests
project_path = "/#{project.full_path}"
click_button 'Edit title and description'
wait_for_requests
page.within('[data-testid="issuable-form"]') do
click_button 'Incident'
click_button 'Issue'
click_button 'Save changes'
wait_for_requests
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
end
end
end
end

View file

@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) }
let(:incident) { create(:incident, project: project, author: user) }
context 'when user displays the issue' do
before do
@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do
end
context 'when user displays the issue as an incident' do
let(:issue) { create(:incident, project: project, author: user) }
before do
visit project_issue_path(project, issue)
visit project_issue_path(project, incident)
wait_for_requests
end
@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do
visit project_issue_path(project, issue)
wait_for_requests
page.find('.js-issuable-edit').click
click_button 'Edit title and description'
fill_in 'issuable-title', with: 'issue title'
click_button 'Save'
click_button 'Save changes'
wait_for_requests
Users::DestroyService.new(user).execute(user)
@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do
end
end
end
describe 'user updates `issue_type` via the issue type dropdown' do
context 'when an issue `issue_type` is edited by a signed in user' do
before do
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
open_issue_edit_form
page.within('[data-testid="issuable-form"]') do
update_type_select('Issue', 'Incident')
expect(page).to have_current_path(project_issues_incident_path(project, issue))
end
end
end
context 'when an incident `issue_type` is edited by a signed in user' do
before do
sign_in(user)
visit project_issue_path(project, incident)
wait_for_requests
end
it 'routes the user to the issue details page when the `issue_type` is set to issue' do
open_issue_edit_form
page.within('[data-testid="issuable-form"]') do
update_type_select('Incident', 'Issue')
expect(page).to have_current_path(project_issue_path(project, incident))
end
end
end
end
def update_type_select(from, to)
click_button from
click_button to
click_button 'Save changes'
wait_for_requests
end
def open_issue_edit_form
wait_for_requests
click_button 'Edit title and description'
wait_for_requests
end
end

View file

@ -1,6 +1,7 @@
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
@ -17,7 +18,7 @@ import {
publishedIncidentUrl,
secondRequest,
zoomMeetingUrl,
} from '../mock_data';
} from '../mock_data/mock_data';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
@ -36,12 +37,11 @@ describe('Issuable output', () => {
let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}) => {
const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...appProps, ...props },
provide: {
@ -53,6 +53,11 @@ describe('Issuable output', () => {
HighlightBar: true,
IncidentTabs: true,
},
data() {
return {
...data,
};
},
...options,
});
};
@ -91,10 +96,8 @@ describe('Issuable output', () => {
afterEach(() => {
mock.restore();
realtimeRequestCount = 0;
wrapper.vm.poll.stop();
wrapper.destroy();
wrapper = null;
});
it('should render a title/description/edited and update title/description/edited on update', () => {
@ -115,7 +118,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/);
expect(editedText.find('time').text()).toBeTruthy();
expect(wrapper.vm.state.lock_version).toEqual(1);
expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
})
.then(() => {
wrapper.vm.poll.makeRequest();
@ -133,7 +136,9 @@ describe('Issuable output', () => {
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/);
expect(editedText.find('time').text()).toBeTruthy();
expect(wrapper.vm.state.lock_version).toEqual(2);
// As the lock_version value does not differ from the server,
// we should not see an alert
expect(findAlert().exists()).toBe(false);
});
});
@ -172,7 +177,7 @@ describe('Issuable output', () => {
${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
expect(wrapper.vm[prop]).toEqual(value);
expect(wrapper.vm[prop]).toBe(value);
expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value);
});
});
@ -374,9 +379,9 @@ describe('Issuable output', () => {
});
})
.then(() => {
expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true);
expect(wrapper.vm.formState.lock_version).toEqual(1);
expect(wrapper.find('.alert').exists()).toBe(true);
expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
expect(wrapper.vm.formState.lock_version).toBe(1);
expect(findAlert().exists()).toBe(true);
});
});
});
@ -530,7 +535,7 @@ describe('Issuable output', () => {
`('$title', async ({ state }) => {
wrapper.setProps({ issuableStatus: state });
await wrapper.vm.$nextTick();
await nextTick();
expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
});
@ -542,7 +547,7 @@ describe('Issuable output', () => {
`('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential });
await wrapper.vm.$nextTick();
await nextTick();
expect(findConfidentialBadge().exists()).toBe(isConfidential);
});
@ -554,7 +559,7 @@ describe('Issuable output', () => {
`('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked });
await wrapper.vm.$nextTick();
await nextTick();
expect(findLockedBadge().exists()).toBe(isLocked);
});
@ -562,9 +567,9 @@ describe('Issuable output', () => {
});
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {

View file

@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data';
import { descriptionProps as props } from '../mock_data/mock_data';
jest.mock('~/task_list');

View file

@ -1,113 +1,163 @@
import Vue from 'vue';
import editActions from '~/issue_show/components/edit_actions.vue';
import { GlButton, GlModal } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableEditActions from '~/issue_show/components/edit_actions.vue';
import eventHub from '~/issue_show/event_hub';
import Store from '~/issue_show/stores';
describe('Edit Actions components', () => {
let vm;
import {
getIssueStateQueryResponse,
updateIssueStateQueryResponse,
} from '../mock_data/apollo_mock';
beforeEach((done) => {
const Component = Vue.extend(editActions);
const store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.title = 'test';
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
describe('Edit Actions component', () => {
let wrapper;
let fakeApollo;
let mockIssueStateData;
vm = new Component({
propsData: {
canDestroy: true,
formState: store.formState,
issuableType: 'issue',
const mockResolvers = {
Query: {
issueState() {
return {
__typename: 'IssueState',
rawData: mockIssueStateData(),
};
},
}).$mount();
},
};
Vue.nextTick(done);
const modalId = 'delete-issuable-modal-1';
const createComponent = ({ props, data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
wrapper = shallowMountExtended(IssuableEditActions, {
apolloProvider: fakeApollo,
propsData: {
formState: {
title: 'GitLab Issue',
},
canDestroy: true,
issuableType: 'issue',
...props,
},
data() {
return {
issueState: {},
modalId,
...data,
};
},
});
};
async function deleteIssuable(localWrapper) {
localWrapper.findComponent(GlModal).vm.$emit('primary');
}
const findModal = () => wrapper.findComponent(GlModal);
const findEditButtons = () => wrapper.findAllComponents(GlButton);
const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button');
const findSaveButton = () => wrapper.findByTestId('issuable-save-button');
const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button');
beforeEach(() => {
mockIssueStateData = jest.fn();
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders all buttons as enabled', () => {
expect(vm.$el.querySelectorAll('.disabled').length).toBe(0);
expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0);
});
it('does not render delete button if canUpdate is false', (done) => {
vm.canDestroy = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
done();
const buttons = findEditButtons().wrappers;
buttons.forEach((button) => {
expect(button.attributes('disabled')).toBeFalsy();
});
});
it('disables submit button when title is blank', (done) => {
vm.formState.title = '';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
done();
});
it('does not render the delete button if canDestroy is false', () => {
createComponent({ props: { canDestroy: false } });
expect(findDeleteButton().exists()).toBe(false);
});
it('should not show delete button if showDeleteButton is false', (done) => {
vm.showDeleteButton = false;
it('disables save button when title is blank', () => {
createComponent({ props: { formState: { title: '', issue_type: '' } } });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
done();
});
expect(findSaveButton().attributes('disabled')).toBe('true');
});
it('does not render the delete button if showDeleteButton is false', () => {
createComponent({ props: { showDeleteButton: false } });
expect(findDeleteButton().exists()).toBe(false);
});
describe('updateIssuable', () => {
it('sends update.issauble event when clicking save button', () => {
vm.$el.querySelector('.btn-confirm').click();
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('disabled button after clicking save button', (done) => {
vm.$el.querySelector('.btn-confirm').click();
it('sends update.issauble event when clicking save button', () => {
findSaveButton().vm.$emit('click', { preventDefault: jest.fn() });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
done();
});
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
});
});
describe('closeForm', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('emits close.form when clicking cancel', () => {
vm.$el.querySelector('.btn-default').click();
findCancelButton().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
});
});
describe('deleteIssuable', () => {
it('sends delete.issuable event when clicking save button', () => {
jest.spyOn(window, 'confirm').mockReturnValue(true);
vm.$el.querySelector('.btn-danger').click();
describe('renders create modal with the correct information', () => {
it('renders correct modal id', () => {
expect(findModal().attributes('modalid')).toBe(modalId);
});
});
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
describe('deleteIssuable', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('does no actions when confirm is false', (done) => {
jest.spyOn(window, 'confirm').mockReturnValue(false);
vm.$el.querySelector('.btn-danger').click();
it('does not send the `delete.issuable` event when clicking delete button', () => {
findDeleteButton().vm.$emit('click');
expect(eventHub.$emit).not.toHaveBeenCalled();
});
Vue.nextTick(() => {
expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable');
it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
expect(eventHub.$emit).toHaveBeenCalledTimes(0);
await deleteIssuable(wrapper);
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
expect(eventHub.$emit).toHaveBeenCalledTimes(1);
});
});
expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull();
describe('with Apollo cache mock', () => {
it('renders the right delete button text per apollo cache type', async () => {
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
await waitForPromises();
expect(findDeleteButton().text()).toBe('Delete issue');
});
done();
});
it('should not change the delete button text per apollo cache mutation', async () => {
mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse);
await waitForPromises();
expect(findDeleteButton().text()).toBe('Delete issue');
});
});
});

View file

@ -0,0 +1,84 @@
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue';
import { IssuableTypes } from '~/issue_show/constants';
import {
getIssueStateQueryResponse,
updateIssueStateQueryResponse,
} from '../../mock_data/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Issue type field component', () => {
let wrapper;
let fakeApollo;
let mockIssueStateData;
const mockResolvers = {
Query: {
issueState() {
return {
__typename: 'IssueState',
rawData: mockIssueStateData(),
};
},
},
Mutation: {
updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse),
},
};
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
const createComponent = ({ data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
wrapper = shallowMount(IssueTypeField, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
issueState: {},
...data,
};
},
});
};
beforeEach(() => {
mockIssueStateData = jest.fn();
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a form group with the correct label', () => {
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
});
it('renders a form select with the `issue_type` value', () => {
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
});
describe('with Apollo cache mock', () => {
it('renders the selected issueType', async () => {
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
await waitForPromises();
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
});
it('updates the `issue_type` in the apollo cache when the value is changed', async () => {
findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident);
await wrapper.vm.$nextTick();
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident);
});
});
});

View file

@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Autosave from '~/autosave';
import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue';
import IssueTypeField from '~/issue_show/components/fields/type.vue';
import formComponent from '~/issue_show/components/form.vue';
import LockedWarning from '~/issue_show/components/locked_warning.vue';
import eventHub from '~/issue_show/event_hub';
@ -39,6 +40,7 @@ describe('Inline edit form component', () => {
};
const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate);
const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField);
const findLockedWarning = () => wrapper.findComponent(LockedWarning);
const findAlert = () => wrapper.findComponent(GlAlert);
@ -68,6 +70,21 @@ describe('Inline edit form component', () => {
expect(findDescriptionTemplate().exists()).toBe(true);
});
it.each`
issuableType | value
${'issue'} | ${true}
${'epic'} | ${false}
`(
'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"',
({ issuableType, value }) => {
createComponent({
issuableType,
});
expect(findIssuableTypeField().exists()).toBe(value);
},
);
it('hides locked warning by default', () => {
createComponent();

View file

@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import INVALID_URL from '~/lib/utils/invalid_url';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import { descriptionProps } from '../../mock_data';
import { descriptionProps } from '../../mock_data/mock_data';
const mockAlert = {
__typename: 'AlertManagementAlert',

View file

@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data';
import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores';
import { appProps } from './mock_data';
import { appProps } from './mock_data/mock_data';
const mock = new MockAdapter(axios);
mock.onGet().reply(200);

View file

@ -0,0 +1,9 @@
export const getIssueStateQueryResponse = {
issueType: 'issue',
isDirty: false,
};
export const updateIssueStateQueryResponse = {
issueType: 'incident',
isDirty: true,
};

View file

@ -48,6 +48,7 @@ export const appProps = {
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
issueType: 'issue',
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',

View file

@ -0,0 +1,42 @@
import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue';
const mockId = '1';
const mockShortSha = '2P6oDVDm';
const mockDescription = 'runner-1';
describe('RunnerTypeCell', () => {
let wrapper;
const findLink = () => wrapper.findComponent(GlLink);
const createComponent = () => {
wrapper = mount(RunnerNameCell, {
propsData: {
runner: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
shortSha: mockShortSha,
description: mockDescription,
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner link with id and short token', () => {
expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`);
expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`);
});
it('Displays the runner description', () => {
expect(wrapper.text()).toContain(mockDescription);
});
});

View file

@ -0,0 +1,48 @@
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue';
import { INSTANCE_TYPE } from '~/runner/constants';
describe('RunnerTypeCell', () => {
let wrapper;
const findBadges = () => wrapper.findAllComponents(GlBadge);
const createComponent = ({ runner = {} } = {}) => {
wrapper = mount(RunnerTypeCell, {
propsData: {
runner: {
runnerType: INSTANCE_TYPE,
active: true,
locked: false,
...runner,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner type', () => {
createComponent();
expect(findBadges()).toHaveLength(1);
expect(findBadges().at(0).text()).toBe('shared');
});
it('Displays locked and paused states', () => {
createComponent({
runner: {
active: false,
locked: true,
},
});
expect(findBadges()).toHaveLength(3);
expect(findBadges().at(0).text()).toBe('shared');
expect(findBadges().at(1).text()).toBe('locked');
expect(findBadges().at(2).text()).toBe('paused');
});
});

View file

@ -0,0 +1,115 @@
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
let wrapper;
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
mount(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays active runner count', () => {
expect(findActiveRunnersMessage().text()).toBe(
`Runners currently online: ${mockActiveRunnersCount}`,
);
});
it('Displays a large active runner count', () => {
createComponent({ props: { activeRunnersCount: 2000 } });
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
});
it('Displays headers', () => {
const headerLabels = findHeaders().wrappers.map((w) => w.text());
expect(headerLabels).toEqual([
'Type/State',
'Runner',
'Version',
'IP Address',
'Projects',
'Jobs',
'Tags',
'Last contact',
'', // actions has no label
]);
});
it('Displays a list of runners', () => {
expect(findRows()).toHaveLength(2);
expect(findSkeletonLoader().exists()).toBe(false);
});
it('Displays details of a runner', () => {
const { id, description, version, ipAddress, shortSha } = mockRunners[0];
// Badges
expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('shared locked');
// Runner identifier
expect(findCell({ fieldKey: 'name' }).text()).toContain(
`#${getIdFromGraphQLId(id)} (${shortSha})`,
);
expect(findCell({ fieldKey: 'name' }).text()).toContain(description);
// Other fields: some cells are empty in the first iteration
// See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
expect(findCell({ fieldKey: 'actions' }).text()).toBe('');
});
it('Links to the runner page', () => {
const { id } = mockRunners[0];
expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe(
`/admin/runners/${getIdFromGraphQLId(id)}`,
);
});
describe('When data is loading', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
});
it('shows an skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
});
});

View file

@ -0,0 +1,84 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/';
describe('RunnerManualSetupHelp', () => {
let wrapper;
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
const findRegistrationToken = () => wrapper.findByTestId('registration-token');
const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link');
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(RunnerManualSetupHelp, {
provide: {
runnerInstallHelpPage: mockRunnerInstallHelpPage,
},
propsData: {
registrationToken: mockRegistrationToken,
...props,
},
stubs: {
GlSprintf,
},
}),
);
};
beforeAll(() => {
originalGon = global.gon;
global.gon = { gitlab_url: TEST_HOST };
});
afterAll(() => {
global.gon = originalGon;
});
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Title contains the default runner type', () => {
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
createComponent({ props: { typeName: 'group' } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
it('Displays the coordinator URL token', () => {
expect(findCoordinatorUrl().text()).toBe(TEST_HOST);
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
it('Displays the registration token', () => {
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
it('Displays the runner instructions', () => {
expect(findRunnerInstructions().exists()).toBe(true);
});
});

View file

@ -0,0 +1,64 @@
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTags from '~/runner/components/runner_tags.vue';
describe('RunnerTags', () => {
let wrapper;
const findBadge = () => wrapper.findComponent(GlBadge);
const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerTags, {
propsData: {
tagList: ['tag1', 'tag2'],
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays tags text', () => {
expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2');
expect(findBadgesAt(0).text()).toBe('tag1');
expect(findBadgesAt(1).text()).toBe('tag2');
});
it('Displays tags with correct style', () => {
expect(findBadge().props('size')).toBe('md');
expect(findBadge().props('variant')).toBe('info');
});
it('Displays tags with small size', () => {
createComponent({
props: { size: 'sm' },
});
expect(findBadge().props('size')).toBe('sm');
});
it('Displays tags with a variant', () => {
createComponent({
props: { variant: 'warning' },
});
expect(findBadge().props('variant')).toBe('warning');
});
it('Is empty when there are no tags', () => {
createComponent({
props: { tagList: null },
});
expect(wrapper.text()).toBe('');
expect(findBadge().exists()).toBe(false);
});
});

View file

@ -0,0 +1,32 @@
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
describe('RunnerTypeHelp', () => {
let wrapper;
const findBadges = () => wrapper.findAllComponents(GlBadge);
const createComponent = () => {
wrapper = mount(RunnerTypeHelp);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays each of the runner types', () => {
expect(findBadges().at(0).text()).toBe('shared');
expect(findBadges().at(1).text()).toBe('group');
expect(findBadges().at(2).text()).toBe('specific');
});
it('Displays runner states', () => {
expect(findBadges().at(3).text()).toBe('locked');
expect(findBadges().at(4).text()).toBe('paused');
});
});

View file

@ -0,0 +1,37 @@
export const runnersData = {
data: {
runners: {
nodes: [
{
id: 'gid://gitlab/Ci::Runner/1',
description: 'runner-1',
runnerType: 'INSTANCE_TYPE',
shortSha: '2P6oDVDm',
version: '13.12.0',
revision: '11223344',
ipAddress: '127.0.0.1',
active: true,
locked: true,
tagList: [],
contactedAt: '2021-05-14T11:44:03Z',
__typename: 'CiRunner',
},
{
id: 'gid://gitlab/Ci::Runner/2',
description: 'runner-2',
runnerType: 'GROUP_TYPE',
shortSha: 'dpSCAC31',
version: '13.12.0',
revision: '11223344',
ipAddress: '127.0.0.1',
active: true,
locked: true,
tagList: [],
contactedAt: '2021-05-14T11:44:02Z',
__typename: 'CiRunner',
},
],
__typename: 'CiRunnerConnection',
},
},
};

View file

@ -0,0 +1,106 @@
import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
import { runnersData } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2;
const mocKRunners = runnersData.data.runners.nodes;
jest.mock('@sentry/browser');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('RunnerListApp', () => {
let wrapper;
let mockRunnersQuery;
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
wrapper = mountFn(RunnerListApp, {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
activeRunnersCount: mockActiveRunnersCount,
registrationToken: mockRegistrationToken,
...props,
},
});
};
beforeEach(async () => {
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
});
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo();
await waitForPromises();
});
afterEach(() => {
mockRunnersQuery.mockReset();
wrapper.destroy();
});
it('shows the runners list', () => {
expect(mocKRunners).toMatchObject(findRunnerList().props('runners'));
});
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
createComponentWithApollo();
await waitForPromises();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
});
});
it('when runners have not loaded, shows a loading state', () => {
createComponentWithApollo();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when runners query fails', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error());
createComponentWithApollo();
await waitForPromises();
});
it('error is reported to sentry', async () => {
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalled();
});
});
});

View file

@ -298,7 +298,7 @@ RSpec.describe CommitsHelper do
let(:pipeline) { create(:ci_pipeline, :running) }
let(:user) { create(:user) }
let(:ref) { "master" }
let(:merge_request) { nil }
let(:merge_request) { create(:merge_request) }
let(:request) { double(xhr?: true) }
let(:current_path) { "test" }
@ -315,8 +315,8 @@ RSpec.describe CommitsHelper do
it do
is_expected.to include(
{
merge_request: merge_request,
pipeline_status: commit_status,
merge_request: merge_request.cache_key,
pipeline_status: pipeline.cache_key,
xhr: true,
controller: "commits",
path: current_path

View file

@ -48,30 +48,51 @@ RSpec.describe Nav::TopNavHelper do
context 'when current_user is nil (anonymous)' do
it 'has expected :primary' do
expected_projects_item = ::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore',
icon: 'project',
id: 'project',
title: 'Projects'
)
expected_groups_item = ::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/groups',
icon: 'group',
id: 'groups',
title: 'Groups'
)
expected_snippets_item = ::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/snippets',
icon: 'snippet',
id: 'snippets',
title: 'Snippets'
)
expect(subject[:primary])
.to eq([
expected_projects_item,
expected_groups_item,
expected_snippets_item
])
expected_primary = [
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore',
icon: 'project',
id: 'project',
title: 'Projects'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/groups',
icon: 'group',
id: 'groups',
title: 'Groups'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/snippets',
icon: 'snippet',
id: 'snippets',
title: 'Snippets'
)
]
expect(subject[:primary]).to eq(expected_primary)
end
it 'has expected :shortcuts' do
expected_shortcuts = [
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore',
id: 'project-shortcut',
title: 'Projects',
css_class: 'dashboard-shortcuts-projects'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/groups',
id: 'groups-shortcut',
title: 'Groups',
css_class: 'dashboard-shortcuts-groups'
),
::Gitlab::Nav::TopNavMenuItem.build(
href: '/explore/snippets',
id: 'snippets-shortcut',
title: 'Snippets',
css_class: 'dashboard-shortcuts-snippets'
)
]
expect(subject[:shortcuts]).to eq(expected_shortcuts)
end
end
@ -82,6 +103,7 @@ RSpec.describe Nav::TopNavHelper do
expect(subject).to eq({ activeTitle: active_title,
primary: [],
secondary: [],
shortcuts: [],
views: {} })
end
@ -105,6 +127,16 @@ RSpec.describe Nav::TopNavHelper do
expect(subject[:primary]).to eq([expected_primary])
end
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'project-shortcut',
title: 'Projects',
href: '/dashboard/projects',
css_class: 'dashboard-shortcuts-projects'
)
expect(subject[:shortcuts]).to eq([expected_shortcuts])
end
context 'projects' do
it 'has expected :currentUserName' do
expect(projects_view[:currentUserName]).to eq(current_user.username)
@ -191,6 +223,16 @@ RSpec.describe Nav::TopNavHelper do
expect(subject[:primary]).to eq([expected_primary])
end
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'groups-shortcut',
title: 'Groups',
href: '/dashboard/groups',
css_class: 'dashboard-shortcuts-groups'
)
expect(subject[:shortcuts]).to eq([expected_shortcuts])
end
context 'groups' do
it 'has expected :currentUserName' do
expect(groups_view[:currentUserName]).to eq(current_user.username)
@ -268,6 +310,16 @@ RSpec.describe Nav::TopNavHelper do
)
expect(subject[:primary]).to eq([expected_primary])
end
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'milestones-shortcut',
title: 'Milestones',
href: '/dashboard/milestones',
css_class: 'dashboard-shortcuts-milestones'
)
expect(subject[:shortcuts]).to eq([expected_shortcuts])
end
end
context 'with snippets' do
@ -285,6 +337,16 @@ RSpec.describe Nav::TopNavHelper do
)
expect(subject[:primary]).to eq([expected_primary])
end
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'snippets-shortcut',
title: 'Snippets',
href: '/dashboard/snippets',
css_class: 'dashboard-shortcuts-snippets'
)
expect(subject[:shortcuts]).to eq([expected_shortcuts])
end
end
context 'with activity' do
@ -302,6 +364,16 @@ RSpec.describe Nav::TopNavHelper do
)
expect(subject[:primary]).to eq([expected_primary])
end
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'activity-shortcut',
title: 'Activity',
href: '/dashboard/activity',
css_class: 'dashboard-shortcuts-activity'
)
expect(subject[:shortcuts]).to eq([expected_shortcuts])
end
end
context 'when sherlock is enabled' do

View file

@ -183,7 +183,7 @@ RSpec.describe Gitlab::PathRegex do
# We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
it 'does not allow expansion' do
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(44)
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(40)
end
end

View file

@ -662,28 +662,10 @@ RSpec.describe Ci::Build do
end
end
context 'with runners_cached_states feature flag enabled' do
before do
stub_feature_flags(runners_cached_states: true)
end
it 'caches the result in Redis' do
expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
it 'caches the result in Redis' do
expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
build.any_runners_online?
end
end
context 'with runners_cached_states feature flag disabled' do
before do
stub_feature_flags(runners_cached_states: false)
end
it 'does not cache' do
expect(Rails.cache).not_to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
build.any_runners_online?
end
build.any_runners_online?
end
end
@ -700,28 +682,10 @@ RSpec.describe Ci::Build do
it { is_expected.to be_truthy }
end
context 'with runners_cached_states feature flag enabled' do
before do
stub_feature_flags(runners_cached_states: true)
end
it 'caches the result in Redis' do
expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
it 'caches the result in Redis' do
expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
build.any_runners_available?
end
end
context 'with runners_cached_states feature flag disabled' do
before do
stub_feature_flags(runners_cached_states: false)
end
it 'does not cache' do
expect(Rails.cache).not_to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
build.any_runners_available?
end
build.any_runners_available?
end
end

View file

@ -1632,112 +1632,6 @@ RSpec.describe Project, factory_default: :keep do
end
end
describe '#any_active_runners?' do
subject { project.any_active_runners? }
context 'shared runners' do
let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
let(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
let(:shared_runner) { create(:ci_runner, :instance) }
context 'for shared runners disabled' do
let(:shared_runners_enabled) { false }
it 'has no runners available' do
is_expected.to be_falsey
end
it 'has a specific runner' do
specific_runner
is_expected.to be_truthy
end
it 'has a shared runner, but they are prohibited to use' do
shared_runner
is_expected.to be_falsey
end
it 'checks the presence of specific runner' do
specific_runner
expect(project.any_active_runners? { |runner| runner == specific_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
specific_runner
expect(project.any_active_runners? { false }).to be_falsey
end
end
context 'for shared runners enabled' do
let(:shared_runners_enabled) { true }
it 'has a shared runner' do
shared_runner
is_expected.to be_truthy
end
it 'checks the presence of shared runner' do
shared_runner
expect(project.any_active_runners? { |runner| runner == shared_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
shared_runner
expect(project.any_active_runners? { false }).to be_falsey
end
end
end
context 'group runners' do
let(:project) { create(:project, group_runners_enabled: group_runners_enabled) }
let(:group) { create(:group, projects: [project]) }
let(:group_runner) { create(:ci_runner, :group, groups: [group]) }
context 'for group runners disabled' do
let(:group_runners_enabled) { false }
it 'has no runners available' do
is_expected.to be_falsey
end
it 'has a group runner, but they are prohibited to use' do
group_runner
is_expected.to be_falsey
end
end
context 'for group runners enabled' do
let(:group_runners_enabled) { true }
it 'has a group runner' do
group_runner
is_expected.to be_truthy
end
it 'checks the presence of group runner' do
group_runner
expect(project.any_active_runners? { |runner| runner == group_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
group_runner
expect(project.any_active_runners? { false }).to be_falsey
end
end
end
end
describe '#any_online_runners?' do
subject { project.any_online_runners? }

View file

@ -335,10 +335,6 @@ RSpec.describe InvitesController, 'routing' do
it 'to #show' do
expect(get("/-/invites/#{member.invite_token}")).to route_to('invites#show', id: member.invite_token)
end
it 'to legacy route' do
expect(get("/invites/#{member.invite_token}")).to route_to('invites#show', id: member.invite_token)
end
end
RSpec.describe AbuseReportsController, 'routing' do
@ -347,10 +343,6 @@ RSpec.describe AbuseReportsController, 'routing' do
it 'to #new' do
expect(get("/-/abuse_reports/new?user_id=#{user.id}")).to route_to('abuse_reports#new', user_id: user.id.to_s)
end
it 'to legacy route' do
expect(get("/abuse_reports/new?user_id=#{user.id}")).to route_to('abuse_reports#new', user_id: user.id.to_s)
end
end
RSpec.describe SentNotificationsController, 'routing' do
@ -376,12 +368,6 @@ RSpec.describe AutocompleteController, 'routing' do
it 'to #merge_request_target_branches' do
expect(get("/-/autocomplete/merge_request_target_branches")).to route_to('autocomplete#merge_request_target_branches')
end
it 'to legacy route' do
expect(get("/autocomplete/users")).to route_to('autocomplete#users')
expect(get("/autocomplete/projects")).to route_to('autocomplete#projects')
expect(get("/autocomplete/award_emojis")).to route_to('autocomplete#award_emojis')
end
end
RSpec.describe Snippets::BlobsController, "routing" do

View file

@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do
opts.merge!(title: '')
end
it 'does not create an incident label prematurely' do
expect { subject }.not_to change(Label, :count)
it 'does not apply an incident label prematurely' do
expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count)
end
end
end

View file

@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
context 'changing issue_type' do
let!(:label_1) { create(:label, project: project, title: 'incident') }
let!(:label_2) { create(:label, project: project, title: 'missed-sla') }
before do
stub_licensed_features(quality_management: true)
end
context 'from issue to incident' do
it 'adds a `incident` label if one does not exist' do
expect { update_issue(issue_type: 'incident') }.to change(issue.labels, :count).by(1)
expect(issue.labels.pluck(:title)).to eq(['incident'])
end
context 'for an issue with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1]) }
before do
update_issue(issue_type: 'incident')
end
it 'does not add an `incident` label if one already exist' do
expect(issue.labels).to eq([label_1])
end
end
context 'filtering the incident label' do
let(:params) { { add_label_ids: [] } }
before do
update_issue(issue_type: 'incident')
end
it 'creates and add a incident label id to add_label_ids' do
expect(issue.label_ids).to contain_exactly(label_1.id)
end
end
end
context 'from incident to issue' do
let(:issue) { create(:incident, project: project) }
context 'for an incident with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
before do
update_issue(issue_type: 'issue')
end
it 'removes an `incident` label if one exists on the incident' do
expect(issue.labels).to eq([label_2])
end
end
context 'filtering the incident label' do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } }
before do
update_issue(issue_type: 'issue')
end
it 'adds an incident label id to remove_label_ids for it to be removed' do
expect(issue.label_ids).to contain_exactly(label_2.id)
end
end
end
context 'from issue to restricted issue types' do
context 'without sufficient permissions' do
let(:user) { create(:user) }
before do
project.add_guest(user)
end
it 'does nothing to the labels' do
expect { update_issue(issue_type: 'issue') }.not_to change(issue.labels, :count)
expect(issue.reload.labels).to eq([])
end
end
end
end
it 'updates open issue counter for assignees when issue is reassigned' do
update_issue(assignee_ids: [user2.id])

View file

@ -29,8 +29,10 @@ RSpec.describe BuildHooksWorker do
stub_feature_flags(delayed_perform_for_build_hooks_worker: false)
end
it 'does not call perform_in' do
expect(described_class).not_to receive(:perform_in)
it 'delays scheduling a job by calling perform_in with default delay' do
expect(described_class).to receive(:perform_in).with(ApplicationWorker::DEFAULT_DELAY_INTERVAL.second, 123)
described_class.perform_async(123)
end
end

View file

@ -176,6 +176,58 @@ RSpec.describe ApplicationWorker do
end
end
describe '.perform_async' do
shared_examples_for 'worker utilizes load balancing capabilities' do |data_consistency|
before do
worker.data_consistency(data_consistency)
end
context 'when data_consistency_delayed_execution feature flag is disabled' do
before do
stub_feature_flags(data_consistency_delayed_execution: false)
end
it 'data_consistency_delayed_execution_feature_flag_enabled? should return false' do
expect(worker).to receive(:data_consistency_delayed_execution_feature_flag_enabled?).and_return(false)
worker.perform_async
end
it 'does not call perform_in' do
expect(worker).not_to receive(:perform_in)
worker.perform_async
end
end
it 'call perform_in' do
expect(worker).to receive(:perform_in).with(described_class::DEFAULT_DELAY_INTERVAL.seconds, 123)
worker.perform_async(123)
end
end
context 'when workers data consistency is :sticky' do
it_behaves_like 'worker utilizes load balancing capabilities', :sticky
end
context 'when workers data consistency is :delayed' do
it_behaves_like 'worker utilizes load balancing capabilities', :delayed
end
context 'when workers data consistency is :always' do
before do
worker.data_consistency(:always)
end
it 'does not call perform_in' do
expect(worker).not_to receive(:perform_in)
worker.perform_async
end
end
end
describe '.bulk_perform_async' do
it 'enqueues jobs in bulk' do
Sidekiq::Testing.fake! do