Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-26 09:09:53 +00:00
parent 6819cb95c9
commit 0ccabeb3f6
46 changed files with 928 additions and 422 deletions

View File

@ -575,6 +575,9 @@ Rails/SaveBang:
- 'ee/spec/**/*.rb'
- 'qa/spec/**/*.rb'
- 'qa/qa/specs/**/*.rb'
Exclude:
- spec/models/wiki_page/**/*
- spec/models/wiki_page_spec.rb
Cop/PutProjectRoutesUnderScope:
Include:

View File

@ -245,8 +245,6 @@ Rails/SaveBang:
- 'spec/models/user_preference_spec.rb'
- 'spec/models/user_spec.rb'
- 'spec/models/user_status_spec.rb'
- 'spec/models/wiki_page/meta_spec.rb'
- 'spec/models/wiki_page_spec.rb'
Rails/TimeZone:
Enabled: true

View File

@ -1 +1 @@
b19cafa222fd7a999167d3f9f8562c2d74b62bfd
5658d720f02d2c84b51feaae484ea68aeeb59773

View File

@ -0,0 +1,40 @@
import { ApolloLink, Observable } from 'apollo-link';
import { print } from 'graphql';
import cable from '~/actioncable_consumer';
import { uuids } from '~/diffs/utils/uuids';
export default class ActionCableLink extends ApolloLink {
// eslint-disable-next-line class-methods-use-this
request(operation) {
return new Observable((observer) => {
const subscription = cable.subscriptions.create(
{
channel: 'GraphqlChannel',
query: operation.query ? print(operation.query) : null,
variables: operation.variables,
operationName: operation.operationName,
nonce: uuids()[0],
},
{
received(data) {
if (data.errors) {
observer.error(data.errors);
} else if (data.result) {
observer.next(data.result);
}
if (!data.more) {
observer.complete();
}
},
},
);
return {
unsubscribe() {
subscription.unsubscribe();
},
};
});
}
}

View File

@ -238,10 +238,13 @@ class GfmAutoComplete {
const MEMBER_COMMAND = {
ASSIGN: '/assign',
UNASSIGN: '/unassign',
ASSIGN_REVIEWER: '/assign_reviewer',
UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
};
let assignees = [];
let reviewers = [];
let command = '';
// Team Members
@ -286,9 +289,11 @@ class GfmAutoComplete {
return null;
});
// Cache assignees list for easier filtering later
// Cache assignees & reviewers list for easier filtering later
assignees =
SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || [];
reviewers =
SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || [];
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
@ -309,6 +314,12 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN) {
// Only include members which are assigned to Issuable currently
return data.filter((member) => assignees.includes(member.search));
} else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => !reviewers.includes(member.search));
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
}
return data;

View File

@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
@ -83,15 +84,27 @@ export default (resolvers = {}, config = {}) => {
});
});
return new ApolloClient({
typeDefs,
link: ApolloLink.from([
const hasSubscriptionOperation = ({ query: { definitions } }) => {
return definitions.some(
({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription',
);
};
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
ApolloLink.from([
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
apolloCaptchaLink,
uploadsLink,
]),
);
return new ApolloClient({
typeDefs,
link: appLink,
cache: new InMemoryCache({
...cacheConfig,
freezeResults: assumeImmutableResults,

View File

@ -0,0 +1,155 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import {
COMMIT_FAILURE,
COMMIT_SUCCESS,
DEFAULT_FAILURE,
DEFAULT_SUCCESS,
LOAD_FAILURE_UNKNOWN,
} from '../../constants';
import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue';
import {
CODE_SNIPPET_SOURCE_URL_PARAM,
CODE_SNIPPET_SOURCES,
} from '../code_snippet_alert/constants';
export default {
components: {
GlAlert,
CodeSnippetAlert,
},
errorTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
},
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
[DEFAULT_SUCCESS]: __('Your action succeeded.'),
},
props: {
failureType: {
type: String,
required: false,
default: null,
},
failureReasons: {
type: Array,
required: false,
default: () => [],
},
showFailure: {
type: Boolean,
required: false,
default: false,
},
showSuccess: {
type: Boolean,
required: false,
default: false,
},
successType: {
type: String,
required: false,
default: null,
},
},
data() {
return {
codeSnippetCopiedFrom: '',
};
},
computed: {
failure() {
switch (this.failureType) {
case LOAD_FAILURE_UNKNOWN:
return {
text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
variant: 'danger',
};
case COMMIT_FAILURE:
return {
text: this.$options.errorTexts[COMMIT_FAILURE],
variant: 'danger',
};
default:
return {
text: this.$options.errorTexts[DEFAULT_FAILURE],
variant: 'danger',
};
}
},
success() {
switch (this.successType) {
case COMMIT_SUCCESS:
return {
text: this.$options.successTexts[COMMIT_SUCCESS],
variant: 'info',
};
default:
return {
text: this.$options.successTexts[DEFAULT_SUCCESS],
variant: 'info',
};
}
},
},
created() {
this.parseCodeSnippetSourceParam();
},
methods: {
dismissCodeSnippetAlert() {
this.codeSnippetCopiedFrom = '';
},
dismissFailure() {
this.$emit('hide-failure');
},
dismissSuccess() {
this.$emit('hide-success');
},
parseCodeSnippetSourceParam() {
const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
window.history.replaceState(
{},
document.title,
removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
);
}
},
},
};
</script>
<template>
<div>
<code-snippet-alert
v-if="codeSnippetCopiedFrom"
:source="codeSnippetCopiedFrom"
class="gl-mb-5"
@dismiss="dismissCodeSnippetAlert"
/>
<gl-alert
v-if="showSuccess"
:variant="success.variant"
class="gl-mb-5"
@dismiss="dismissSuccess"
>
{{ success.text }}
</gl-alert>
<gl-alert
v-if="showFailure"
:variant="failure.variant"
class="gl-mb-5"
@dismiss="dismissFailure"
>
{{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
</ul>
</gl-alert>
</div>
</template>

View File

@ -14,6 +14,7 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const CREATE_TAB = 'CREATE_TAB';

View File

@ -1,22 +1,15 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import httpStatusCodes from '~/lib/utils/http_status';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue';
import {
CODE_SNIPPET_SOURCE_URL_PARAM,
CODE_SNIPPET_SOURCES,
} from './components/code_snippet_alert/constants';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
import {
COMMIT_FAILURE,
COMMIT_SUCCESS,
DEFAULT_FAILURE,
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_ERROR,
EDITOR_APP_STATUS_LOADING,
@ -32,11 +25,10 @@ import PipelineEditorHome from './pipeline_editor_home.vue';
export default {
components: {
ConfirmUnsavedChangesDialog,
GlAlert,
GlLoadingIcon,
PipelineEditorEmptyState,
PipelineEditorHome,
CodeSnippetAlert,
PipelineEditorMessages,
},
inject: {
ciConfigPath: {
@ -51,15 +43,14 @@ export default {
ciConfigData: {},
failureType: null,
failureReasons: [],
showStartScreen: false,
initialCiFileContent: '',
isNewCiConfigFile: false,
lastCommittedContent: '',
currentCiFileContent: '',
showFailureAlert: false,
showSuccessAlert: false,
successType: null,
codeSnippetCopiedFrom: '',
showStartScreen: false,
showSuccess: false,
showFailure: false,
};
},
@ -152,50 +143,12 @@ export default {
isEmpty() {
return this.currentCiFileContent === '';
},
failure() {
switch (this.failureType) {
case LOAD_FAILURE_UNKNOWN:
return {
text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
variant: 'danger',
};
case COMMIT_FAILURE:
return {
text: this.$options.errorTexts[COMMIT_FAILURE],
variant: 'danger',
};
default:
return {
text: this.$options.errorTexts[DEFAULT_FAILURE],
variant: 'danger',
};
}
},
success() {
switch (this.successType) {
case COMMIT_SUCCESS:
return {
text: this.$options.successTexts[COMMIT_SUCCESS],
variant: 'info',
};
default:
return null;
}
},
},
i18n: {
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
},
errorTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
},
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
},
watch: {
isEmpty(flag) {
if (flag) {
@ -203,9 +156,6 @@ export default {
}
},
},
created() {
this.parseCodeSnippetSourceParam();
},
methods: {
handleBlobContentError(error = {}) {
const { networkError } = error;
@ -223,12 +173,11 @@ export default {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
},
dismissFailure() {
this.showFailureAlert = false;
hideFailure() {
this.showFailure = false;
},
dismissSuccess() {
this.showSuccessAlert = false;
hideSuccess() {
this.showSuccess = false;
},
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
@ -238,13 +187,13 @@ export default {
this.setAppStatus(EDITOR_APP_STATUS_ERROR);
window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailureAlert = true;
this.showFailure = true;
this.failureType = type;
this.failureReasons = reasons;
},
reportSuccess(type) {
window.scrollTo({ top: 0, behavior: 'smooth' });
this.showSuccessAlert = true;
this.showSuccess = true;
this.successType = type;
},
resetContent() {
@ -277,20 +226,6 @@ export default {
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
},
parseCodeSnippetSourceParam() {
const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
window.history.replaceState(
{},
document.title,
removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
);
}
},
dismissCodeSnippetAlert() {
this.codeSnippetCopiedFrom = '';
},
},
};
</script>
@ -303,31 +238,15 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
/>
<div v-else>
<code-snippet-alert
v-if="codeSnippetCopiedFrom"
:source="codeSnippetCopiedFrom"
class="gl-mb-5"
@dismiss="dismissCodeSnippetAlert"
<pipeline-editor-messages
:failure-type="failureType"
:failure-reasons="failureReasons"
:show-failure="showFailure"
:show-success="showSuccess"
:success-type="successType"
@hide-success="hideSuccess"
@hide-failure="hideFailure"
/>
<gl-alert
v-if="showSuccessAlert"
:variant="success.variant"
class="gl-mb-5"
@dismiss="dismissSuccess"
>
{{ success.text }}
</gl-alert>
<gl-alert
v-if="showFailureAlert"
:variant="failure.variant"
class="gl-mb-5"
@dismiss="dismissFailure"
>
{{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
</ul>
</gl-alert>
<pipeline-editor-home
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"

View File

@ -1,6 +1,7 @@
<script>
import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import produce from 'immer';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { assigneesQueries } from '~/sidebar/constants';
export default {
@ -12,60 +13,62 @@ export default {
required: false,
default: null,
},
issuableIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
issuableType: {
type: String,
required: true,
},
issuableId: {
type: Number,
required: true,
},
queryVariables: {
type: Object,
required: true,
},
},
computed: {
issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
},
},
apollo: {
workspace: {
issuable: {
query() {
return assigneesQueries[this.issuableType].query;
},
variables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
return this.queryVariables;
},
result(data) {
if (this.mediator) {
this.handleFetchResult(data);
}
update(data) {
return data.workspace?.issuable;
},
subscribeToMore: {
document() {
return assigneesQueries[this.issuableType].subscription;
},
variables() {
return {
issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
};
},
updateQuery(prev, { subscriptionData }) {
if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
const data = produce(prev, (draftData) => {
draftData.workspace.issuable.assignees.nodes =
subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
});
if (this.mediator) {
this.handleFetchResult(data);
}
return data;
}
return prev;
},
},
},
},
mounted() {
this.initActionCablePolling();
},
beforeDestroy() {
this.$options.subscription.unsubscribe();
},
methods: {
received(data) {
if (data.event === 'updated') {
this.$apollo.queries.workspace.refetch();
}
},
initActionCablePolling() {
this.$options.subscription = actionCable.subscriptions.create(
{
channel: 'IssuesChannel',
project_path: this.projectPath,
iid: this.issuableIid,
},
{ received: this.received },
);
},
handleFetchResult({ data }) {
handleFetchResult(data) {
const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({

View File

@ -44,6 +44,10 @@ export default {
type: String,
required: true,
},
issuableId: {
type: Number,
required: true,
},
assigneeAvailabilityStatus: {
type: Object,
required: false,
@ -61,6 +65,12 @@ export default {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
},
queryVariables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
},
relativeUrlRoot() {
return gon.relative_url_root ?? '';
},
@ -121,9 +131,9 @@ export default {
<div>
<assignees-realtime
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
:mediator="mediator"
/>
<assignee-title

View File

@ -73,6 +73,11 @@ export default {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
issuableId: {
type: Number,
required: false,
default: null,
},
multipleAssignees: {
type: Boolean,
required: false,
@ -340,9 +345,9 @@ export default {
<div data-testid="assignees-widget">
<sidebar-assignees-realtime
v-if="shouldEnableRealtime"
:project-path="fullPath"
:issuable-iid="iid"
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
/>
<sidebar-editable-item
ref="toggle"

View File

@ -1,5 +1,6 @@
import { IssuableType } from '~/issue_show/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
@ -17,6 +18,7 @@ export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation,
},
[IssuableType.MergeRequest]: {

View File

@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) {
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
const { id, iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
assigneeAvailabilityStatus,
},
}),
@ -85,7 +86,7 @@ function mountAssigneesComponent() {
if (!el) return;
const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
const { id, iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
@ -108,6 +109,7 @@ function mountAssigneesComponent() {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
multipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {

View File

@ -0,0 +1,16 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
subscription issuableAssigneesUpdated($issuableId: IssuableID!) {
issuableAssigneesUpdated(issuableId: $issuableId) {
... on Issue {
assignees {
nodes {
...User
status {
availability
}
}
}
}
}
}

View File

@ -386,6 +386,7 @@ module IssuablesHelper
rootPath: root_path,
fullPath: issuable[:project_full_path],
iid: issuable[:iid],
id: issuable[:id],
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email],

View File

@ -153,9 +153,9 @@ module ServicesHelper
private
def integration_level(integration)
if integration.instance
if integration.instance_level?
'instance'
elsif integration.group_id
elsif integration.group_level?
'group'
else
'project'

View File

@ -51,14 +51,14 @@ class Service < ApplicationRecord
belongs_to :group, inverse_of: :services
has_one :service_hook
validates :project_id, presence: true, unless: -> { template? || instance? || group_id }
validates :group_id, presence: true, unless: -> { template? || instance? || project_id }
validates :project_id, :group_id, absence: true, if: -> { template? || instance? }
validates :project_id, presence: true, unless: -> { template? || instance_level? || group_level? }
validates :group_id, presence: true, unless: -> { template? || instance_level? || project_level? }
validates :project_id, :group_id, absence: true, if: -> { template? || instance_level? }
validates :type, presence: true
validates :type, uniqueness: { scope: :template }, if: :template?
validates :type, uniqueness: { scope: :instance }, if: :instance?
validates :type, uniqueness: { scope: :project_id }, if: :project_id?
validates :type, uniqueness: { scope: :group_id }, if: :group_id?
validates :type, uniqueness: { scope: :instance }, if: :instance_level?
validates :type, uniqueness: { scope: :project_id }, if: :project_level?
validates :type, uniqueness: { scope: :group_id }, if: :group_level?
validate :validate_is_instance_or_template
validate :validate_belongs_to_project_or_group
@ -240,7 +240,7 @@ class Service < ApplicationRecord
service.instance = false
service.project_id = project_id
service.group_id = group_id
service.inherit_from_id = integration.id if integration.instance? || integration.group
service.inherit_from_id = integration.id if integration.instance_level? || integration.group_level?
service
end
@ -409,7 +409,7 @@ class Service < ApplicationRecord
# Disable test for instance-level and group-level services.
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138
def can_test?
!instance? && !group_id
!(instance_level? || group_level?)
end
def project_level?
@ -460,11 +460,11 @@ class Service < ApplicationRecord
private
def validate_is_instance_or_template
errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance?
errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance_level?
end
def validate_belongs_to_project_or_group
errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id
errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level?
end
def validate_recipients?

View File

@ -5,7 +5,7 @@ module Admin
include PropagateService
def propagate
if integration.instance?
if integration.instance_level?
update_inherited_integrations
create_integration_for_groups_without_integration
create_integration_for_projects_without_integration

View File

@ -12,7 +12,7 @@
%li
= image_tag avatar_icon_for_user(@user, 60), class: "avatar s60"
%li
%span.light Profile page:
%span.light= _('Profile page:')
%strong
= link_to user_path(@user) do
= @user.username
@ -20,25 +20,25 @@
.card
.card-header
Account:
= _('Account:')
%ul.content-list
%li
%span.light Name:
%span.light= _('Name:')
%strong= @user.name
%li
%span.light Username:
%span.light= _('Username:')
%strong
= @user.username
%li
%span.light Email:
%span.light= _('Email:')
%strong
= render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? }
- @user.emails.each do |email|
%li
%span.light Secondary email:
%span.light= _('Secondary email:')
%strong
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
= link_to remove_email_admin_user_path(@user, email), data: { confirm: "Are you sure you want to remove #{email.email}?" }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: 'Remove secondary email', id: "remove_email_#{email.id}" do
= link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email } }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do
= sprite_icon('close', size: 16, css_class: 'gl-icon')
%li
%span.light ID:
@ -50,65 +50,65 @@
= @user.namespace_id
%li.two-factor-status
%span.light Two-factor Authentication:
%span.light= _('Two-factor Authentication:')
%strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' }
- if @user.two_factor_enabled?
Enabled
= link_to 'Disable', disable_two_factor_admin_user_path(@user), data: {confirm: 'Are you sure?'}, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: 'Disable Two-factor Authentication'
= _('Enabled')
= link_to _('Disable'), disable_two_factor_admin_user_path(@user), data: { confirm: _('Are you sure?') }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication')
- else
Disabled
= _('Disabled')
= render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace
%li
%span.light External User:
%span.light= _('External User:')
%strong
= @user.external? ? "Yes" : "No"
= @user.external? ? _('Yes') : _('No')
%li
%span.light Can create groups:
%span.light= _('Can create groups:')
%strong
= @user.can_create_group ? "Yes" : "No"
= @user.can_create_group ? _('Yes') : _('No')
%li
%span.light Personal projects limit:
%span.light= _('Personal projects limit:')
%strong
= @user.projects_limit
%li
%span.light Member since:
%span.light= _('Member since:')
%strong
= @user.created_at.to_s(:medium)
- if @user.confirmed_at
%li
%span.light Confirmed at:
%span.light= _('Confirmed at:')
%strong
= @user.confirmed_at.to_s(:medium)
- else
%li
%span.light Confirmed:
%span.ligh= _('Confirmed:')
%strong.cred
No
= _('No')
%li
%span.light Current sign-in IP:
%span.light= _('Current sign-in IP:')
%strong
= @user.current_sign_in_ip || _('never')
%li
%span.light Current sign-in at:
%span.light= _('Current sign-in at:')
%strong
= @user.current_sign_in_at&.to_s(:medium) || _('never')
%li
%span.light Last sign-in IP:
%span.light= _('Last sign-in IP:')
%strong
= @user.last_sign_in_ip || _('never')
%li
%span.light Last sign-in at:
%span.light= _('Last sign-in at:')
%strong
= @user.last_sign_in_at&.to_s(:medium) || _('never')
%li
%span.light Sign-in count:
%span.light= _('Sign-in count:')
%strong
= @user.sign_in_count
@ -121,13 +121,13 @@
- if @user.ldap_user?
%li
%span.light LDAP uid:
%span.light= _('LDAP uid:')
%strong
= @user.ldap_identity.extern_uid
- if @user.created_by
%li
%span.light Created by:
%span.light= _('Created by:')
%strong
= link_to @user.created_by.name, [:admin, @user.created_by]
@ -140,13 +140,13 @@
- if can_force_email_confirmation?(@user)
.gl-card.border-info.gl-mb-5
.gl-card-header.bg-info.text-white
Confirm user
= _('Confirm user')
.gl-card-body
- if @user.unconfirmed_email.present?
- email = " (#{@user.unconfirmed_email})"
%p This user has an unconfirmed email address#{email}. You may force a confirmation.
%p= _('This user has an unconfirmed email address %{email}. You may force a confirmation.') % { email: email }
%br
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' }
= link_to _('Confirm user'), confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?'), qa_selector: 'confirm_user_button' }
= render 'admin/users/user_detail_note'
@ -154,7 +154,7 @@
- if @user.deactivated?
.gl-card.border-info.gl-mb-5
.gl-card-header.bg-info.text-white
Reactivate this user
= _('Reactivate this user')
.gl-card-body
= render partial: 'admin/users/user_activation_effects'
%br
@ -163,7 +163,7 @@
- elsif @user.can_be_deactivated?
.gl-card.border-warning.gl-mb-5
.gl-card-header.bg-warning.text-white
Deactivate this user
= _('Deactivate this user')
.gl-card-body
= user_deactivation_effects
%br
@ -176,12 +176,12 @@
- else
.gl-card.border-info.gl-mb-5
.gl-card-header.gl-bg-blue-500.gl-text-white
This user is blocked
= _('This user is blocked')
.gl-card-body
%p A blocked user cannot:
%p= _('A blocked user cannot:')
%ul
%li Log in
%li Access Git repositories
%li= _('Log in')
%li= _('Access Git repositories')
%br
%button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) }
= s_('AdminUsers|Unblock user')
@ -191,18 +191,18 @@
- if @user.access_locked?
.card.border-info.gl-mb-5
.card-header.bg-info.text-white
This account has been locked
= _('This account has been locked')
.card-body
%p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.
%p= _('This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.')
%br
= link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
= link_to _('Unlock user'), unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?') }
- if !@user.blocked_pending_approval?
.gl-card.border-danger.gl-mb-5
.gl-card-header.bg-danger.text-white
= s_('AdminUsers|Delete user')
.gl-card-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p Deleting a user has the following effects:
%p= _('Deleting a user has the following effects:')
= render 'users/deletion_guidance', user: @user
%br
%button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
@ -213,13 +213,13 @@
- else
- if @user.solo_owned_groups.present?
%p
This user is currently an owner in these groups:
= _('This user is currently an owner in these groups:')
%strong= @user.solo_owned_groups.map(&:name).join(', ')
%p
You must transfer ownership or delete these groups before you can delete this user.
= _('You must transfer ownership or delete these groups before you can delete this user.')
- else
%p
You don't have access to delete this user.
= _("You don't have access to delete this user.")
.gl-card.border-danger
.gl-card-header.bg-danger.text-white
@ -227,13 +227,8 @@
.gl-card-body
- if can?(current_user, :destroy_user, @user)
%p
This option deletes the user and any contributions that
would usually be moved to the
= succeed "." do
= link_to "system ghost user", help_page_path("user/profile/account/delete_account")
As well as the user's personal projects, groups owned solely by
the user, and projects in them, will also be removed. Commits
to other projects are unaffected.
- link_to_ghost_user = link_to(_("system ghost user"), help_page_path("user/profile/account/delete_account"))
= _("This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected.").html_safe % { link_to_ghost_user: link_to_ghost_user }
%br
%button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
delete_user_url: admin_user_path(@user, hard_delete: true),
@ -242,6 +237,6 @@
= s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
= _("You don't have access to delete this user.")
= render partial: 'admin/users/modals'

View File

@ -9,13 +9,13 @@
%h4.gl-mt-0
= page_title
%p
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') }
- if current_user.can?(:create_resource_access_tokens, @project)
= _('You can generate an access token scoped to this project for each application to use the GitLab API.')
-# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed
-# %p
-# = _('You can also use project access tokens to authenticate against Git over HTTP.')
= _('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.')
%p
= _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- else
= _('Project access token creation is disabled in this group. You can still use and manage existing tokens.')
= _('Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
%p
- root_group = @project.group.root_ancestor
- if current_user.can?(:admin_group, root_group)
@ -23,7 +23,6 @@
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
= _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.col-lg-8
- if @new_project_access_token
= render 'shared/access_tokens/created_container',

View File

@ -11,7 +11,7 @@ class PropagateIntegrationGroupWorker
integration = Service.find_by_id(integration_id)
return unless integration
batch = if integration.instance?
batch = if integration.instance_level?
Group.where(id: min_id..max_id).without_integration(integration)
else
integration.group.descendants.where(id: min_id..max_id).without_integration(integration)

View File

@ -12,7 +12,7 @@ class PropagateIntegrationProjectWorker
return unless integration
batch = Project.where(id: min_id..max_id).without_integration(integration)
batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_id
batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_level?
return if batch.empty?

View File

@ -0,0 +1,5 @@
---
title: Externalize strings in /users/show.html.haml
merge_request: 58126
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Revise project access tokens UI text
merge_request: 59878
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Fix Rails/SaveBang Rubocop offenses for wiki_page models
merge_request: 57899
author: Huzaifa Iftikhar @huzaifaiftikhar
type: fixed

View File

@ -4,13 +4,15 @@ description: Projects with tracing enabled
product_section: ops
product_stage:
product_group: group::monitor
product_category:
product_category: tracing
value_type: number
status: data_available
time_frame: 28d
data_source:
data_source: database
distribution:
- ce
- ce
- ee
tier:
- free
skip_validation: true
- free
- premium
- ultimate

View File

@ -10,7 +10,9 @@ status: data_available
time_frame: all
data_source: database
distribution:
- ce
- ce
- ee
tier:
- free
skip_validation: true
- free
- premium
- ultimate

View File

@ -8,9 +8,11 @@ product_category: tracing
value_type: number
status: data_available
time_frame: all
data_source:
data_source: database
distribution:
- ce
- ce
- ee
tier:
- free
skip_validation: true
- free
- premium
- ultimate

View File

@ -11900,22 +11900,6 @@ Represents the Geo sync and verification state of a snippet repository.
| <a id="submoduletype"></a>`type` | [`EntryType!`](#entrytype) | Type of tree entry. |
| <a id="submoduleweburl"></a>`webUrl` | [`String`](#string) | Web URL for the sub-module. |
### `Subscription`
#### Fields with arguments
##### `Subscription.issuableAssigneesUpdated`
Triggered when the assignees of an issuable are updated.
Returns [`Issuable`](#issuable).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="subscriptionissuableassigneesupdatedissuableid"></a>`issuableId` | [`IssuableID!`](#issuableid) | ID of the issuable. |
### `TaskCompletionStatus`
Completion status of tasks.

View File

@ -5082,7 +5082,7 @@ Group: `group::monitor`
Status: `data_available`
Tiers: `free`
Tiers: `free`, `premium`, `ultimate`
### `counts.projects_youtrack_active`
@ -15680,7 +15680,7 @@ Group: `group::monitor`
Status: `data_available`
Tiers: `free`
Tiers: `free`, `premium`, `ultimate`
### `usage_activity_by_stage.package.projects_with_packages`
@ -17600,7 +17600,7 @@ Group: `group::monitor`
Status: `data_available`
Tiers: `free`
Tiers: `free`, `premium`, `ultimate`
### `usage_activity_by_stage_monthly.package.projects_with_packages`

View File

@ -17,7 +17,6 @@ All training material is open to public contribution.
This section contains the following topics:
- [Agile and Git](topics/agile_git.md).
- [Bisect](topics/bisect.md).
- [Cherry pick](topics/cherry_picking.md).
- [Code review and collaboration with Merge Requests](topics/merge_requests.md).

View File

@ -1,33 +1,8 @@
---
stage: none
group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
comments: false
redirect_to: '../../../user/project/issue_board.md'
---
# Agile and Git
Information about using Agile concepts in GitLab can be found in [another location](../../../user/project/issue_board.md).
## Agile
Lean software development methods focused on collaboration and interaction
with fast and smaller deployment cycles.
## Where Git comes in
Git is an excellent tool for an Agile team considering that it allows
decentralized and simultaneous development.
### Branching And Workflows
Branching in an Agile environment usually happens around user stories with one
or more developers working on it.
If more than one developer then another branch for each developer is also used
with their initials, and US ID.
After its tested merge into master and remove the branch.
## What about GitLab
Tools like GitLab enhance collaboration by adding dialog around code mainly
through issues and merge requests.
<!-- This redirect file can be deleted after <2021-07-23>. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->

View File

@ -347,7 +347,7 @@ module Gitlab
mutations = schema.mutation&.fields&.keys&.to_set || []
graphql_object_types
.reject { |object_type| object_type[:name]["__"] } # We ignore introspection types.
.reject { |object_type| object_type[:name]["__"] || object_type[:name] == 'Subscription' } # We ignore introspection and subscription types.
.map do |type|
name = type[:name]
type.merge(

View File

@ -1370,6 +1370,9 @@ msgstr ""
msgid "A basic template for developing Linux programs using Kotlin Native"
msgstr ""
msgid "A blocked user cannot:"
msgstr ""
msgid "A complete DevOps platform"
msgstr ""
@ -1667,6 +1670,9 @@ msgstr ""
msgid "Acceptable for use in this project"
msgstr ""
msgid "Access Git repositories"
msgstr ""
msgid "Access Tokens"
msgstr ""
@ -1793,6 +1799,9 @@ msgstr ""
msgid "Account and limit"
msgstr ""
msgid "Account:"
msgstr ""
msgid "Account: %{account}"
msgstr ""
@ -4268,6 +4277,9 @@ msgstr ""
msgid "Are you sure you want to reindex?"
msgstr ""
msgid "Are you sure you want to remove %{email}?"
msgstr ""
msgid "Are you sure you want to remove %{group_name}?"
msgstr ""
@ -5696,6 +5708,9 @@ msgstr ""
msgid "Can be manually deployed to"
msgstr ""
msgid "Can create groups:"
msgstr ""
msgid "Can't apply as the source branch was deleted."
msgstr ""
@ -8374,6 +8389,9 @@ msgstr ""
msgid "Confirm new password"
msgstr ""
msgid "Confirm user"
msgstr ""
msgid "Confirm your account"
msgstr ""
@ -8386,6 +8404,12 @@ msgstr ""
msgid "Confirmation required"
msgstr ""
msgid "Confirmed at:"
msgstr ""
msgid "Confirmed:"
msgstr ""
msgid "Confluence"
msgstr ""
@ -9619,6 +9643,12 @@ msgstr ""
msgid "Current password"
msgstr ""
msgid "Current sign-in IP:"
msgstr ""
msgid "Current sign-in at:"
msgstr ""
msgid "Current vulnerabilities count"
msgstr ""
@ -10375,6 +10405,9 @@ msgstr ""
msgid "Days to merge"
msgstr ""
msgid "Deactivate this user"
msgstr ""
msgid "Dear Administrator,"
msgstr ""
@ -10615,6 +10648,9 @@ msgstr ""
msgid "Deleting a project places it into a read-only state until %{date}, at which point the project will be permanently deleted. Are you ABSOLUTELY sure?"
msgstr ""
msgid "Deleting a user has the following effects:"
msgstr ""
msgid "Deleting the project will delete its repository and all related resources including issues, merge requests, etc."
msgstr ""
@ -11361,6 +11397,9 @@ msgstr ""
msgid "Disable"
msgstr ""
msgid "Disable Two-factor Authentication"
msgstr ""
msgid "Disable for this project"
msgstr ""
@ -11879,6 +11918,9 @@ msgstr ""
msgid "Email updates (optional)"
msgstr ""
msgid "Email:"
msgstr ""
msgid "Email: %{email}"
msgstr ""
@ -13169,6 +13211,9 @@ msgstr ""
msgid "External URL"
msgstr ""
msgid "External User:"
msgstr ""
msgid "External authentication"
msgstr ""
@ -14218,6 +14263,9 @@ msgstr ""
msgid "Generate new token"
msgstr ""
msgid "Generate project access tokens scoped to this project for your applications that need access to the GitLab API."
msgstr ""
msgid "Generate site and private keys at"
msgstr ""
@ -18625,6 +18673,9 @@ msgstr ""
msgid "LDAP synchronizations"
msgstr ""
msgid "LDAP uid:"
msgstr ""
msgid "LFS"
msgstr ""
@ -18780,6 +18831,12 @@ msgstr ""
msgid "Last sign-in"
msgstr ""
msgid "Last sign-in IP:"
msgstr ""
msgid "Last sign-in at:"
msgstr ""
msgid "Last successful sync"
msgstr ""
@ -19439,6 +19496,9 @@ msgstr ""
msgid "Locks the discussion."
msgstr ""
msgid "Log in"
msgstr ""
msgid "Login with smartcard"
msgstr ""
@ -19922,6 +19982,9 @@ msgstr ""
msgid "Member since %{date}"
msgstr ""
msgid "Member since:"
msgstr ""
msgid "MemberInviteEmail|%{member_name} invited you to join GitLab"
msgstr ""
@ -23390,6 +23453,9 @@ msgstr ""
msgid "Personal projects"
msgstr ""
msgid "Personal projects limit:"
msgstr ""
msgid "Phabricator Server Import"
msgstr ""
@ -24407,6 +24473,9 @@ msgstr ""
msgid "Profile image guideline"
msgstr ""
msgid "Profile page:"
msgstr ""
msgid "ProfileSession|on"
msgstr ""
@ -24827,7 +24896,7 @@ msgstr ""
msgid "Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group."
msgstr ""
msgid "Project access token creation is disabled in this group. You can still use and manage existing tokens."
msgid "Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}"
msgstr ""
msgid "Project already deleted"
@ -26291,6 +26360,9 @@ msgstr ""
msgid "Re-verification interval"
msgstr ""
msgid "Reactivate this user"
msgstr ""
msgid "Read documentation"
msgstr ""
@ -26700,6 +26772,9 @@ msgstr ""
msgid "Remove runner"
msgstr ""
msgid "Remove secondary email"
msgstr ""
msgid "Remove secondary node"
msgstr ""
@ -28169,6 +28244,9 @@ msgstr ""
msgid "Secondary"
msgstr ""
msgid "Secondary email:"
msgstr ""
msgid "Seconds"
msgstr ""
@ -29478,6 +29556,9 @@ msgstr ""
msgid "Sign up was successful! Please confirm your email to sign in."
msgstr ""
msgid "Sign-in count:"
msgstr ""
msgid "Sign-in page"
msgstr ""
@ -32289,6 +32370,9 @@ msgstr ""
msgid "This URL is already used for another link; duplicate URLs are not allowed"
msgstr ""
msgid "This account has been locked"
msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr ""
@ -32631,6 +32715,9 @@ msgstr ""
msgid "This only applies to repository indexing operations."
msgstr ""
msgid "This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected."
msgstr ""
msgid "This option is only available on GitLab.com"
msgstr ""
@ -32724,6 +32811,12 @@ msgstr ""
msgid "This user does not have a pending request"
msgstr ""
msgid "This user has an unconfirmed email address %{email}. You may force a confirmation."
msgstr ""
msgid "This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account."
msgstr ""
msgid "This user has no active %{type}."
msgstr ""
@ -32739,6 +32832,12 @@ msgstr ""
msgid "This user has the %{access} role in the %{name} project."
msgstr ""
msgid "This user is blocked"
msgstr ""
msgid "This user is currently an owner in these groups:"
msgstr ""
msgid "This user is the author of this %{noteable}."
msgstr ""
@ -33711,6 +33810,9 @@ msgstr ""
msgid "Two-factor Authentication Recovery codes"
msgstr ""
msgid "Two-factor Authentication:"
msgstr ""
msgid "Two-factor authentication"
msgstr ""
@ -33972,6 +34074,9 @@ msgstr ""
msgid "Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment."
msgstr ""
msgid "Unlock user"
msgstr ""
msgid "Unlocked"
msgstr ""
@ -34824,6 +34929,9 @@ msgstr ""
msgid "Username or email"
msgstr ""
msgid "Username:"
msgstr ""
msgid "Username: %{username}"
msgstr ""
@ -36195,6 +36303,9 @@ msgstr ""
msgid "You can also upload existing files from your computer using the instructions below."
msgstr ""
msgid "You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}"
msgstr ""
msgid "You can always edit this later"
msgstr ""
@ -36240,9 +36351,6 @@ msgstr ""
msgid "You can find more information about GitLab subscriptions in %{subscriptions_doc_link}."
msgstr ""
msgid "You can generate an access token scoped to this project for each application to use the GitLab API."
msgstr ""
msgid "You can get started by cloning the repository or start adding files to it with one of the following options."
msgstr ""
@ -36378,6 +36486,9 @@ msgstr ""
msgid "You do not have permissions to run the import."
msgstr ""
msgid "You don't have access to delete this user."
msgstr ""
msgid "You don't have any U2F devices registered yet."
msgstr ""
@ -36528,6 +36639,9 @@ msgstr ""
msgid "You must solve the CAPTCHA in order to submit"
msgstr ""
msgid "You must transfer ownership or delete these groups before you can delete this user."
msgstr ""
msgid "You must upload a file with the same file name when dropping onto an existing design."
msgstr ""
@ -36771,6 +36885,9 @@ msgstr ""
msgid "Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO."
msgstr ""
msgid "Your action succeeded."
msgstr ""
msgid "Your applications (%{size})"
msgstr ""
@ -38404,6 +38521,9 @@ msgstr ""
msgid "suggestPipeline|Were adding a GitLab CI configuration file to add a pipeline to the project. You could create it manually, but we recommend that you start with a GitLab template that works out of the box."
msgstr ""
msgid "system ghost user"
msgstr ""
msgid "tag name"
msgstr ""

View File

@ -22,11 +22,7 @@ RSpec.describe 'ActionCable logging', :js do
subscription_data = a_hash_including(
remote_ip: '127.0.0.1',
user_id: user.id,
username: user.username,
params: a_hash_including(
project_path: project.full_path,
iid: issue.iid.to_s
)
username: user.username
)
expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data)

View File

@ -119,6 +119,59 @@ RSpec.describe 'File blob', :js do
end
end
context 'when ref switch' do
def switch_ref_to(ref_name)
first('.qa-branches-select').click
page.within '.project-refs-form' do
click_link ref_name
end
end
it 'displays single highlighted line number of different ref' do
visit_blob('files/js/application.js', anchor: 'L1')
switch_ref_to('feature')
page.within '.blob-content' do
expect(find_by_id('LC1')[:class]).to include("hll")
end
end
it 'displays multiple highlighted line numbers of different ref' do
visit_blob('files/js/application.js', anchor: 'L1-3')
switch_ref_to('feature')
page.within '.blob-content' do
expect(find_by_id('LC1')[:class]).to include("hll")
expect(find_by_id('LC2')[:class]).to include("hll")
expect(find_by_id('LC3')[:class]).to include("hll")
end
end
it 'displays no highlighted number of different ref' do
Files::UpdateService.new(
project,
project.owner,
commit_message: 'Update',
start_branch: 'feature',
branch_name: 'feature',
file_path: 'files/js/application.js',
file_content: 'new content'
).execute
project.commit('feature').diffs.diff_files.first
visit_blob('files/js/application.js', anchor: 'L3')
switch_ref_to('feature')
page.within '.blob-content' do
expect(page).not_to have_css('.hll')
end
end
end
context 'visiting with a line number anchor' do
before do
visit_blob('files/markdown/ruby-style-guide.md', anchor: 'L1')

View File

@ -99,7 +99,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
visit project_settings_access_tokens_path(personal_project)
expect(page).to have_selector('#new_project_access_token')
expect(page).to have_text('You can generate an access token scoped to this project for each application to use the GitLab API.')
expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.')
end
end

View File

@ -0,0 +1,110 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import cable from '~/actioncable_consumer';
import ActionCableLink from '~/actioncable_link';
// Mock uuids module for determinism
jest.mock('~/diffs/utils/uuids', () => ({
uuids: () => ['testuuid'],
}));
const TEST_OPERATION = {
query: gql`
query foo {
project {
id
}
}
`,
operationName: 'foo',
variables: [],
};
/**
* Create an observer that passes calls to the given spy.
*
* This helps us assert which calls were made in what order.
*/
const createSpyObserver = (spy) => ({
next: (...args) => spy('next', ...args),
error: (...args) => spy('error', ...args),
complete: (...args) => spy('complete', ...args),
});
const notify = (...notifications) => {
notifications.forEach((data) => cable.subscriptions.notifyAll('received', data));
};
const getSubscriptionCount = () => cable.subscriptions.subscriptions.length;
describe('~/actioncable_link', () => {
let cableLink;
beforeEach(() => {
jest.spyOn(cable.subscriptions, 'create');
cableLink = new ActionCableLink();
});
describe('request', () => {
let subscription;
let spy;
beforeEach(() => {
spy = jest.fn();
subscription = cableLink.request(TEST_OPERATION).subscribe(createSpyObserver(spy));
});
afterEach(() => {
subscription.unsubscribe();
});
it('creates a subscription', () => {
expect(getSubscriptionCount()).toBe(1);
expect(cable.subscriptions.create).toHaveBeenCalledWith(
{
channel: 'GraphqlChannel',
nonce: 'testuuid',
...TEST_OPERATION,
query: print(TEST_OPERATION.query),
},
{ received: expect.any(Function) },
);
});
it('when "unsubscribe", unsubscribes underlying cable subscription', () => {
subscription.unsubscribe();
expect(getSubscriptionCount()).toBe(0);
});
it('when receives data, triggers observer until no ".more"', () => {
notify(
{ result: 'test result', more: true },
{ result: 'test result 2', more: true },
{ result: 'test result 3' },
{ result: 'test result 4' },
);
expect(spy.mock.calls).toEqual([
['next', 'test result'],
['next', 'test result 2'],
['next', 'test result 3'],
['complete'],
]);
});
it('when receives errors, triggers observer', () => {
notify(
{ result: 'test result', more: true },
{ result: 'test result 2', errors: ['boom!'], more: true },
{ result: 'test result 3' },
);
expect(spy.mock.calls).toEqual([
['next', 'test result'],
['error', ['boom!']],
]);
});
});
});

View File

@ -0,0 +1,137 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants';
import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue';
import {
COMMIT_FAILURE,
COMMIT_SUCCESS,
DEFAULT_FAILURE,
DEFAULT_SUCCESS,
LOAD_FAILURE_UNKNOWN,
} from '~/pipeline_editor/constants';
describe('Pipeline Editor messages', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(PipelineEditorMessages, {
propsData: props,
});
};
const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert);
const findAlert = () => wrapper.findComponent(GlAlert);
describe('success alert', () => {
it('shows a message for successful commit type', () => {
createComponent({ successType: COMMIT_SUCCESS, showSuccess: true });
expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]);
});
it('does not show alert when there is a successType but visibility is off', () => {
createComponent({ successType: COMMIT_SUCCESS, showSuccess: false });
expect(findAlert().exists()).toBe(false);
});
it('shows a success alert with default copy if `showSuccess` is true and the `successType` is not valid,', () => {
createComponent({ successType: 'random', showSuccess: true });
expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[DEFAULT_SUCCESS]);
});
it('emit `hide-success` event when clicking on the dismiss button', async () => {
const expectedEvent = 'hide-success';
createComponent({ successType: COMMIT_SUCCESS, showSuccess: true });
expect(wrapper.emitted(expectedEvent)).not.toBeDefined();
await findAlert().vm.$emit('dismiss');
expect(wrapper.emitted(expectedEvent)).toBeDefined();
});
});
describe('failure alert', () => {
it.each`
failureType | message | expectedFailureType
${COMMIT_FAILURE} | ${'failed commit'} | ${COMMIT_FAILURE}
${LOAD_FAILURE_UNKNOWN} | ${'loading failure'} | ${LOAD_FAILURE_UNKNOWN}
${'random'} | ${'error without a specified type'} | ${DEFAULT_FAILURE}
`('shows a message for $message', ({ failureType, expectedFailureType }) => {
createComponent({ failureType, showFailure: true });
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[expectedFailureType]);
});
it('show failure reasons when there are some', () => {
const failureReasons = ['There was a problem', 'ouppps'];
createComponent({ failureType: COMMIT_FAILURE, failureReasons, showFailure: true });
expect(wrapper.html()).toContain(failureReasons[0]);
expect(wrapper.html()).toContain(failureReasons[1]);
});
it('does not show a message for error with a disabled visibility', () => {
createComponent({ failureType: 'random', showFailure: false });
expect(findAlert().exists()).toBe(false);
});
it('emit `hide-failure` event when clicking on the dismiss button', async () => {
const expectedEvent = 'hide-failure';
createComponent({ failureType: COMMIT_FAILURE, showFailure: true });
expect(wrapper.emitted(expectedEvent)).not.toBeDefined();
await findAlert().vm.$emit('dismiss');
expect(wrapper.emitted(expectedEvent)).toBeDefined();
});
});
describe('code snippet alert', () => {
const setCodeSnippetUrlParam = (value) => {
global.jsdom.reconfigure({
url: `${TEST_HOST}/?code_snippet_copied_from=${value}`,
});
};
it('does not show by default', () => {
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => {
jest.spyOn(window.history, 'replaceState');
setCodeSnippetUrlParam(source);
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(true);
expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`);
});
it('does not show if URL param is invalid', () => {
setCodeSnippetUrlParam('foo_bar');
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it('disappears on dismiss', async () => {
setCodeSnippetUrlParam('api_fuzzing');
createComponent();
const alert = findCodeSnippetAlert();
expect(alert.exists()).toBe(true);
await alert.vm.$emit('dismiss');
expect(alert.exists()).toBe(false);
});
});
});

View File

@ -2,17 +2,15 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status';
import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue';
import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
@ -56,6 +54,7 @@ describe('Pipeline editor app component', () => {
CommitForm,
PipelineEditorHome,
PipelineEditorTabs,
PipelineEditorMessages,
EditorLite: MockEditorLite,
PipelineEditorEmptyState,
},
@ -113,7 +112,6 @@ describe('Pipeline editor app component', () => {
const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
const findEmptyStateButton = () =>
wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton);
const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert);
beforeEach(() => {
mockBlobContentData = jest.fn();
@ -133,48 +131,6 @@ describe('Pipeline editor app component', () => {
});
});
describe('code snippet alert', () => {
const setCodeSnippetUrlParam = (value) => {
global.jsdom.reconfigure({
url: `${TEST_HOST}/?code_snippet_copied_from=${value}`,
});
};
it('does not show by default', () => {
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => {
jest.spyOn(window.history, 'replaceState');
setCodeSnippetUrlParam(source);
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(true);
expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`);
});
it('does not show if URL param is invalid', () => {
setCodeSnippetUrlParam('foo_bar');
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it('disappears on dismiss', async () => {
setCodeSnippetUrlParam('api_fuzzing');
createComponent();
const alert = findCodeSnippetAlert();
expect(alert.exists()).toBe(true);
await alert.vm.$emit('dismiss');
expect(alert.exists()).toBe(false);
});
});
describe('when queries are called', () => {
beforeEach(() => {
mockBlobContentData.mockResolvedValue(mockCiYml);
@ -235,11 +191,14 @@ describe('Pipeline editor app component', () => {
describe('because of a fetching error', () => {
it('shows a unkown error message', async () => {
const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.';
mockBlobContentData.mockRejectedValueOnce(new Error('My error!'));
await createComponentWithApollo();
expect(findEmptyState().exists()).toBe(false);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]);
expect(findAlert().text()).toBe(loadUnknownFailureText);
expect(findEditorHome().exists()).toBe(true);
});
});
@ -273,6 +232,7 @@ describe('Pipeline editor app component', () => {
describe('when the user commits', () => {
const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
const updateSuccessMessage = 'Your changes have been successfully committed.';
describe('and the commit mutation succeeds', () => {
beforeEach(() => {
@ -283,7 +243,7 @@ describe('Pipeline editor app component', () => {
});
it('shows a confirmation message', () => {
expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]);
expect(findAlert().text()).toBe(updateSuccessMessage);
});
it('scrolls to the top of the page to bring attention to the confirmation message', () => {

View File

@ -1,41 +1,44 @@
import ActionCable from '@rails/actioncable';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import { assigneesQueries } from '~/sidebar/constants';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import Mock from './mock_data';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data';
jest.mock('@rails/actioncable', () => {
const mockConsumer = {
subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) },
};
return {
createConsumer: jest.fn().mockReturnValue(mockConsumer),
};
});
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Assignees Realtime', () => {
let wrapper;
let mediator;
let fakeApollo;
const createComponent = (issuableType = 'issue') => {
const issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse);
const subscriptionInitialHandler = jest.fn().mockResolvedValue(subscriptionNullResponse);
const createComponent = ({
issuableType = 'issue',
issuableId = 1,
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[issuableAssigneesSubscription, subscriptionHandler],
]);
wrapper = shallowMount(AssigneesRealtime, {
propsData: {
issuableIid: '1',
mediator,
projectPath: 'path/to/project',
issuableType,
},
mocks: {
$apollo: {
query: assigneesQueries[issuableType].query,
queries: {
workspace: {
refetch: jest.fn(),
},
},
issuableId,
queryVariables: {
issuableIid: '1',
projectPath: 'path/to/project',
},
mediator,
},
apolloProvider: fakeApollo,
localVue,
});
};
@ -45,59 +48,24 @@ describe('Assignees Realtime', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
SidebarMediator.singleton = null;
});
describe('when handleFetchResult is called from smart query', () => {
it('sets assignees to the store', () => {
const data = {
workspace: {
issuable: {
assignees: {
nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
},
},
},
};
const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }];
createComponent();
it('calls the query with correct variables', () => {
createComponent();
wrapper.vm.handleFetchResult({ data });
expect(mediator.store.assignees).toEqual(expected);
expect(issuableQueryHandler).toHaveBeenCalledWith({
issuableIid: '1',
projectPath: 'path/to/project',
});
});
describe('when mounted', () => {
it('calls create subscription', () => {
const cable = ActionCable.createConsumer();
it('calls the subscription with correct variable for issue', () => {
createComponent();
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(cable.subscriptions.create).toHaveBeenCalledTimes(1);
expect(cable.subscriptions.create).toHaveBeenCalledWith(
{
channel: 'IssuesChannel',
iid: wrapper.props('issuableIid'),
project_path: wrapper.props('projectPath'),
},
{ received: wrapper.vm.received },
);
});
});
});
describe('when subscription is recieved', () => {
it('refetches the GraphQL project query', () => {
createComponent();
wrapper.vm.received({ event: 'updated' });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1);
});
expect(subscriptionInitialHandler).toHaveBeenCalledWith({
issuableId: 'gid://gitlab/Issue/1',
});
});
});

View File

@ -487,6 +487,9 @@ describe('Sidebar assignees widget', () => {
it('when realtime feature flag is enabled', async () => {
createComponent({
props: {
issuableId: 1,
},
provide: {
glFeatures: {
realTimeIssueSidebar: true,

View File

@ -401,4 +401,10 @@ export const updateIssueAssigneesMutationResponse = {
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export default mockData;

View File

@ -17,6 +17,7 @@ describe('sidebar assignees', () => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
issuableIid: '1',
issuableId: 1,
mediator,
field: '',
projectPath: 'projectPath',

View File

@ -42,7 +42,7 @@ RSpec.describe WikiPage::Meta do
subject { described_class.find(meta.id) }
let_it_be(:meta) do
described_class.create(title: generate(:wiki_page_title), project: project)
described_class.create!(title: generate(:wiki_page_title), project: project)
end
context 'there are no slugs' do
@ -183,7 +183,7 @@ RSpec.describe WikiPage::Meta do
# an old slug that = canonical_slug
different_slug = generate(:sluggified_title)
create(:wiki_page_meta, project: project, canonical_slug: different_slug)
.slugs.create(slug: wiki_page.slug)
.slugs.create!(slug: wiki_page.slug)
end
shared_examples 'metadata examples' do