Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-11-04 15:07:23 +00:00
parent f2fd07aa1c
commit 4938925517
88 changed files with 1989 additions and 1128 deletions

View File

@ -8,4 +8,6 @@
/vendor/
/sitespeed-result/
/fixtures/**/*.graphql
# Storybook build artifacts
/storybook/public
spec/fixtures/**/*.graphql

View File

@ -897,6 +897,7 @@
rules:
- !reference [".strict-ee-only-rules", rules]
- !reference [".frontend:rules:default-frontend-jobs-as-if-foss", rules]
- <<: *if-merge-request-labels-run-all-jest
- <<: *if-merge-request
changes: *frontend-patterns-for-as-if-foss

View File

@ -1817,7 +1817,6 @@ Layout/LineLength:
- 'ee/spec/features/groups/scim_token_spec.rb'
- 'ee/spec/features/groups/security/compliance_dashboards_spec.rb'
- 'ee/spec/features/groups/sso_spec.rb'
- 'ee/spec/features/groups/usage_quotas_spec.rb'
- 'ee/spec/features/integrations/jira/jira_issues_list_spec.rb'
- 'ee/spec/features/invites_spec.rb'
- 'ee/spec/features/issues/filtered_search/filter_issues_weight_spec.rb'

View File

@ -123,10 +123,8 @@ RSpec/ContextWording:
- 'ee/spec/features/groups/push_rules_spec.rb'
- 'ee/spec/features/groups/saml_enforcement_spec.rb'
- 'ee/spec/features/groups/saml_providers_spec.rb'
- 'ee/spec/features/groups/seat_usage/seat_usage_spec.rb'
- 'ee/spec/features/groups/security/compliance_dashboards_spec.rb'
- 'ee/spec/features/groups/sso_spec.rb'
- 'ee/spec/features/groups/usage_quotas_spec.rb'
- 'ee/spec/features/groups_spec.rb'
- 'ee/spec/features/ide/user_commits_changes_spec.rb'
- 'ee/spec/features/ide/user_opens_ide_spec.rb'

View File

@ -4,7 +4,6 @@ RSpec/EmptyLineAfterHook:
Exclude:
- 'ee/spec/controllers/projects/integrations/zentao/issues_controller_spec.rb'
- 'ee/spec/controllers/projects/push_rules_controller_spec.rb'
- 'ee/spec/features/groups/usage_quotas_spec.rb'
- 'ee/spec/features/issues/user_bulk_edits_issues_spec.rb'
- 'ee/spec/features/profiles/usage_quotas_spec.rb'
- 'ee/spec/lib/ee/api/entities/user_with_admin_spec.rb'

View File

@ -1 +1 @@
1cbf6d9ce79fe9df99b545529f7b7d754baea080
af0cd47633f6e0a5b8ac349a2584c01164af701a

View File

@ -67,7 +67,7 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
supports-quick-actions
init-on-autofocus
autofocus
@input="$emit('input', $event)"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"

View File

@ -95,13 +95,8 @@ export default {
<template #right-actions>
<slot name="commands"></slot>
</template>
<template #metadata-count>
<metadata-item
v-if="imagesCount"
data-testid="images-count"
icon="container-image"
:text="imagesCountText"
/>
<template v-if="imagesCount" #metadata-count>
<metadata-item data-testid="images-count" icon="container-image" :text="imagesCountText" />
</template>
<template #metadata-exp-policies>
<metadata-item

View File

@ -343,7 +343,7 @@ export default {
:uploads-path="pageInfo.uploadsPath"
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:init-on-autofocus="pageInfo.persisted"
:autofocus="pageInfo.persisted"
:form-field-placeholder="$options.i18n.content.placeholder"
:form-field-aria-label="$options.i18n.content.label"
form-field-id="wiki_content"

View File

@ -72,7 +72,7 @@ export default {
required: false,
default: '',
},
initOnAutofocus: {
autofocus: {
type: Boolean,
required: false,
default: false,
@ -87,20 +87,20 @@ export default {
return {
editingMode: EDITING_MODE_MARKDOWN_FIELD,
switchEditingControlEnabled: true,
autofocus: this.initOnAutofocus,
autofocused: false,
};
},
computed: {
isContentEditorActive() {
return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR;
},
contentEditorAutofocus() {
contentEditorAutofocused() {
// Match textarea focus behavior
return this.autofocus ? 'end' : false;
return this.autofocus && !this.autofocused ? 'end' : false;
},
},
mounted() {
this.autofocusTextarea(this.editingMode);
this.autofocusTextarea();
},
methods: {
updateMarkdownFromContentEditor({ markdown }) {
@ -120,7 +120,6 @@ export default {
},
onEditingModeChange(editingMode) {
this.notifyEditingModeChange(editingMode);
this.enableAutofocus(editingMode);
},
onEditingModeRestored(editingMode) {
this.notifyEditingModeChange(editingMode);
@ -128,15 +127,15 @@ export default {
notifyEditingModeChange(editingMode) {
this.$emit(editingMode);
},
enableAutofocus(editingMode) {
this.autofocus = true;
this.autofocusTextarea(editingMode);
},
autofocusTextarea(editingMode) {
if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) {
autofocusTextarea() {
if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
this.$refs.textarea.focus();
this.setEditorAsAutofocused();
}
},
setEditorAsAutofocused() {
this.autofocused = true;
},
},
switchEditingControlOptions: [
{ text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD },
@ -197,7 +196,8 @@ export default {
:render-markdown="renderMarkdown"
:uploads-path="uploadsPath"
:markdown="value"
:autofocus="contentEditorAutofocus"
:autofocus="contentEditorAutofocused"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@loading="disableSwitchEditingControl"
@loadingSuccess="enableSwitchEditingControl"

View File

@ -8,7 +8,7 @@ import { __, s__ } from '~/locale';
import EditedAt from '~/issues/show/components/edited.vue';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import workItemQuery from '../graphql/work_item.query.graphql';
import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
@ -32,6 +32,15 @@ export default {
type: String,
required: true,
},
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
queryVariables: {
type: Object,
required: true,
},
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
@ -45,11 +54,14 @@ export default {
},
apollo: {
workItem: {
query: workItemQuery,
query() {
return getWorkItemQuery(this.fetchByIid);
},
variables() {
return {
id: this.workItemId,
};
return this.queryVariables;
},
update(data) {
return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;

View File

@ -1,4 +1,5 @@
<script>
import { isEmpty } from 'lodash';
import {
GlAlert,
GlSkeletonLoader,
@ -11,6 +12,7 @@ import {
} from '@gitlab/ui';
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
@ -27,12 +29,12 @@ import {
WIDGET_TYPE_ITERATION,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
import { getWorkItemQuery } from '../utils';
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
@ -72,6 +74,7 @@ export default {
WorkItemMilestone,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
props: {
isModal: {
type: Boolean,
@ -83,6 +86,11 @@ export default {
required: false,
default: null,
},
iid: {
type: String,
required: false,
default: null,
},
workItemParentId: {
type: String,
required: false,
@ -100,20 +108,26 @@ export default {
},
apollo: {
workItem: {
query: workItemQuery,
query() {
return getWorkItemQuery(this.fetchByIid);
},
variables() {
return {
id: this.workItemId,
};
return this.queryVariables;
},
skip() {
return !this.workItemId;
},
update(data) {
const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
return workItem ?? {};
},
error() {
this.error = this.$options.i18n.fetchError;
document.title = s__('404|Not found');
this.setEmptyState();
},
result() {
if (isEmpty(this.workItem)) {
this.setEmptyState();
}
if (!this.isModal && this.workItem.project) {
const path = this.workItem.project?.fullPath
? ` · ${this.workItem.project.fullPath}`
@ -127,30 +141,33 @@ export default {
document: workItemTitleSubscription,
variables() {
return {
issuableId: this.workItemId,
issuableId: this.workItem.id,
};
},
skip() {
return !this.workItem?.id;
},
},
{
document: workItemDatesSubscription,
variables() {
return {
issuableId: this.workItemId,
issuableId: this.workItem.id,
};
},
skip() {
return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id;
},
},
{
document: workItemAssigneesSubscription,
variables() {
return {
issuableId: this.workItemId,
issuableId: this.workItem.id,
};
},
skip() {
return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id;
},
},
],
@ -214,6 +231,19 @@ export default {
workItemMilestone() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE);
},
fetchByIid() {
return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path);
},
queryVariables() {
return this.fetchByIid
? {
fullPath: this.fullPath,
iid: this.iid,
}
: {
id: this.workItemId,
};
},
},
beforeDestroy() {
/** make sure that if the user has not even dismissed the alert ,
@ -231,7 +261,7 @@ export default {
this.updateInProgress = true;
let updateMutation = updateWorkItemMutation;
let inputVariables = {
id: this.workItemId,
id: this.workItem.id,
confidential: confidentialStatus,
};
@ -240,7 +270,7 @@ export default {
inputVariables = {
id: this.parentWorkItem.id,
taskData: {
id: this.workItemId,
id: this.workItem.id,
confidential: confidentialStatus,
},
};
@ -275,6 +305,10 @@ export default {
this.updateInProgress = false;
});
},
setEmptyState() {
this.error = this.$options.i18n.fetchError;
document.title = s__('404|Not found');
},
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@ -352,7 +386,7 @@ export default {
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
@deleteWorkItem="$emit('deleteWorkItem', workItemType)"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
/>
@ -406,6 +440,8 @@ export default {
:work-item-id="workItem.id"
:can-update="canUpdate"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
:query-variables="queryVariables"
@error="updateError = $event"
/>
<work-item-due-date
@ -435,6 +471,8 @@ export default {
:weight="workItemWeight.weight"
:work-item-id="workItem.id"
:work-item-type="workItemType"
:fetch-by-iid="fetchByIid"
:query-variables="queryVariables"
@error="updateError = $event"
/>
<template v-if="workItemsMvc2Enabled">
@ -445,6 +483,8 @@ export default {
:can-update="canUpdate"
:work-item-id="workItem.id"
:work-item-type="workItemType"
:fetch-by-iid="fetchByIid"
:query-variables="queryVariables"
@error="updateError = $event"
/>
</template>
@ -452,6 +492,8 @@ export default {
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
:query-variables="queryVariables"
class="gl-pt-5"
@error="updateError = $event"
/>

View File

@ -8,7 +8,7 @@ import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/labe
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
import workItemQuery from '../graphql/work_item.query.graphql';
import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import {
@ -50,6 +50,15 @@ export default {
type: String,
required: true,
},
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
queryVariables: {
type: Object,
required: true,
},
},
data() {
return {
@ -64,11 +73,14 @@ export default {
},
apollo: {
workItem: {
query: workItemQuery,
query() {
return getWorkItemQuery(this.fetchByIid);
},
variables() {
return {
id: this.workItemId,
};
return this.queryVariables;
},
update(data) {
return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;

View File

@ -2,6 +2,7 @@
fragment WorkItem on WorkItem {
id
iid
title
state
description

View File

@ -0,0 +1,23 @@
#import "./work_item.fragment.graphql"
query workItemByIid($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
workItems(iid: $iid) {
nodes {
...WorkItem
mockWidgets @client {
... on LocalWorkItemMilestone {
type
nodes {
id
title
expired
dueDate
}
}
}
}
}
}
}

View File

@ -3,10 +3,11 @@ import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
import { getPreferredLocales, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
import { getWorkItemQuery } from '../utils';
import ItemTitle from '../components/item_title.vue';
@ -21,6 +22,7 @@ export default {
ItemTitle,
GlFormSelect,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
props: {
initialTitle: {
@ -71,6 +73,9 @@ export default {
return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
},
fetchByIid() {
return this.glFeatures.useIidInWorkItemsPath;
},
},
methods: {
async createWorkItem() {
@ -89,28 +94,47 @@ export default {
workItemTypeId: this.selectedWorkItemType,
},
},
update(store, { data: { workItemCreate } }) {
update: (store, { data: { workItemCreate } }) => {
const { workItem } = workItemCreate;
const data = this.fetchByIid
? {
workspace: {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Project',
id: workItem.project.id,
workItems: {
__typename: 'WorkItemConnection',
nodes: [workItem],
},
},
}
: { workItem };
store.writeQuery({
query: workItemQuery,
variables: {
id: workItem.id,
},
data: {
workItem,
},
query: getWorkItemQuery(this.fetchByIid),
variables: this.fetchByIid
? {
fullPath: this.fullPath,
iid: workItem.iid,
}
: {
id: workItem.id,
},
data,
});
},
});
const {
data: {
workItemCreate: {
workItem: { id },
workItem: { id, iid },
},
},
} = response;
this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
const routerParams = this.fetchByIid
? { name: 'workItem', params: { id: iid }, query: { iid_path: 'true' } }
: { name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } };
this.$router.push(routerParams);
} catch {
this.error = this.createErrorText;
}

View File

@ -38,14 +38,12 @@ export default {
this.ZenMode = new ZenMode();
},
methods: {
deleteWorkItem(workItemType) {
deleteWorkItem({ workItemType, workItemId: id }) {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
variables: {
input: {
id: this.gid,
},
input: { id },
},
})
.then(({ data: { workItemDelete, errors } }) => {
@ -72,6 +70,6 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert>
<work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" />
<work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" />
</div>
</template>

View File

@ -0,0 +1,6 @@
import workItemQuery from './graphql/work_item.query.graphql';
import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
export function getWorkItemQuery(isFetchedByIid) {
return isFetchedByIid ? workItemByIidQuery : workItemQuery;
}

View File

@ -3,6 +3,12 @@
class JiraConnect::ApplicationController < ApplicationController
include Gitlab::Utils::StrongMemoize
CORS_ALLOWED_METHODS = {
'/-/jira_connect/oauth_application_id' => %i[GET OPTIONS],
'/-/jira_connect/subscriptions' => %i[GET POST OPTIONS],
'/-/jira_connect/subscriptions/*' => %i[DELETE OPTIONS]
}.freeze
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
before_action :verify_atlassian_jwt!
@ -60,4 +66,25 @@ class JiraConnect::ApplicationController < ApplicationController
def auth_token
params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
end
def cors_allowed_methods
CORS_ALLOWED_METHODS[resource]
end
def resource
request.path.gsub(%r{/\d+$}, '/*')
end
def set_cors_headers
return unless allow_cors_request?
response.set_header('Access-Control-Allow-Origin', Gitlab::CurrentSettings.jira_connect_proxy_url)
response.set_header('Access-Control-Allow-Methods', cors_allowed_methods.join(', '))
end
def allow_cors_request?
return false if cors_allowed_methods.nil?
!Gitlab.com? && Gitlab::CurrentSettings.jira_connect_proxy_url.present?
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module JiraConnect
class CorsPreflightChecksController < ApplicationController
feature_category :integrations
skip_before_action :verify_atlassian_jwt!
before_action :set_cors_headers
def index
return render_404 unless allow_cors_request?
render plain: '', content_type: 'text/plain'
end
end
end

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true
module JiraConnect
class OauthApplicationIdsController < ::ApplicationController
class OauthApplicationIdsController < ApplicationController
feature_category :integrations
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
skip_before_action :verify_atlassian_jwt!
before_action :set_cors_headers
def show
if show_application_id?

View File

@ -27,6 +27,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
before_action :verify_qsh_claim!, only: :index
before_action :allow_self_managed_content_security_policy, only: :index
before_action :authenticate_user!, only: :create
before_action :set_cors_headers
def index
@subscriptions = current_jira_installation.subscriptions.preload_namespace_route

View File

@ -4,6 +4,7 @@ class Projects::WorkItemsController < Projects::ApplicationController
before_action do
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:use_iid_in_work_items_path, project)
end
feature_category :team_planning

View File

@ -2789,7 +2789,7 @@ class Project < ApplicationRecord
return unless service_desk_enabled?
config = Gitlab.config.incoming_email
wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER
wildcard = Gitlab::Email::Common::WILDCARD_PLACEHOLDER
config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}")
end

View File

@ -30,6 +30,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Limit::Deployments,
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::PopulateMetadata,
Gitlab::Ci::Pipeline::Chain::StopDryRun,
Gitlab::Ci::Pipeline::Chain::EnsureEnvironments,
Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups,

View File

@ -422,22 +422,15 @@ module Gitlab
allow do
origins '*'
resource oauth_path,
headers: %w(Authorization),
# These headers are added as defaults to axios.
# See: https://gitlab.com/gitlab-org/gitlab/-/blob/dd1e70d3676891025534dc4a1e89ca9383178fe7/app/assets/javascripts/lib/utils/axios_utils.js#L8)
# It's added to declare that this is a XHR request and add the CSRF token without which Rails may reject the request from the frontend.
headers: %w(Authorization X-CSRF-Token X-Requested-With),
credentials: false,
methods: %i(post options)
end
end
# Cross-origin requests must be enabled to fetch the self-managed application oauth application ID
# for the GitLab for Jira app.
allow do
origins '*'
resource '/-/jira_connect/oauth_application_id',
headers: :any,
methods: %i(get options),
credentials: false
end
# These are routes from doorkeeper-openid_connect:
# https://github.com/doorkeeper-gem/doorkeeper-openid_connect#routes
allow do

View File

@ -19,6 +19,8 @@ metadata:
description: Operations related to access requests
- name: cluster_agents
description: Operations related to the GitLab agent for Kubernetes
- name: ci_resource_groups
description: Operations to manage job concurrency with resource groups
- name: deploy_keys
description: Operations related to deploy keys
- name: deploy_tokens

View File

@ -55,7 +55,10 @@ InitializerConnections.with_disabled_database_connections do
match '/oauth/token' => 'oauth/tokens#create', via: :options
match '/oauth/revoke' => 'oauth/tokens#revoke', via: :options
match '/-/jira_connect/oauth_application_id' => 'jira_connect/oauth_application_ids#show', via: :options
match '/-/jira_connect/oauth_application_id' => 'jira_connect/cors_preflight_checks#index', via: :options
match '/-/jira_connect/subscriptions' => 'jira_connect/cors_preflight_checks#index', via: :options
match '/-/jira_connect/subscriptions/:id' => 'jira_connect/cors_preflight_checks#index', via: :options
match '/-/jira_connect/installations' => 'jira_connect/cors_preflight_checks#index', via: :options
# Sign up
scope path: '/users/sign_up', module: :registrations, as: :users_sign_up do

View File

@ -13,11 +13,11 @@
- name: "merged_by API field" # The name of the feature to be deprecated
announcement_milestone: "14.7" # The milestone when this feature was first announced as deprecated.
announcement_date: "2022-01-22" # The date of the milestone release when this feature was first announced as deprecated. This should almost always be the 22nd of a month (YYYY-MM-22), unless you did an out of band blog post.
removal_milestone: "15.0" # The milestone when this feature is planned to be removed
removal_date: "2022-05-22" # the date of the milestone release when this feature is planned to be removed
removal_milestone: "16.0" # The milestone when this feature is planned to be removed
removal_date: "2023-05-22" # the date of the milestone release when this feature is planned to be removed
breaking_change: true # If this deprecation is a breaking change, set this value to true
body: | # Do not modify this line, instead modify the lines below.
The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) is being deprecated and will be removed in GitLab 15.0. This field is being replaced with the `merge_user` field (already present in GraphQL) which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge.
The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) has been deprecated in favor of the `merge_user` field which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. API users are encouraged to use the new `merge_user` field instead. The `merged_by` field will be removed in v5 of the GitLab REST API.
# The following items are not published on the docs page, but may be used in the future.
stage: create # (optional - may be required in the future) String value of the stage that the feature was created in. e.g., Growth
tiers: # (optional - may be required in the future) An array of tiers that the feature is available in currently. e.g., [Free, Silver, Gold, Core, Premium, Ultimate]

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class RemoveTmpIndexMembersOnIdWhereNamespaceIdNull < Gitlab::Database::Migration[2.0]
INDEX_NAME = 'tmp_index_members_on_id_where_namespace_id_null'
disable_ddl_transaction!
def up
remove_concurrent_index_by_name :members, INDEX_NAME
end
def down
add_concurrent_index :members, :id, name: INDEX_NAME, where: 'member_namespace_id IS NULL'
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class MigrateSidekiqQueuedJobs < Gitlab::Database::Migration[2.0]
class SidekiqMigrateJobs
LOG_FREQUENCY_QUEUES = 10
attr_reader :logger, :mappings
# mappings is a hash of WorkerClassName => target_queue_name
def initialize(mappings, logger: nil)
@mappings = mappings
@logger = logger
end
# Migrates jobs from queues that are outside the mappings
# rubocop: disable Cop/SidekiqRedisCall
def migrate_queues
routing_rules_queues = mappings.values.uniq
logger&.info("List of queues based on routing rules: #{routing_rules_queues}")
Sidekiq.redis do |conn|
# Redis 6 supports conn.scan_each(match: "queue:*", type: 'list')
conn.scan_each(match: "queue:*") do |key|
# Redis 5 compatibility
next unless conn.type(key) == 'list'
queue_from = key.split(':', 2).last
next if routing_rules_queues.include?(queue_from)
logger&.info("Migrating #{queue_from} queue")
migrated = 0
while queue_length(queue_from) > 0
begin
if migrated >= 0 && migrated % LOG_FREQUENCY_QUEUES == 0
logger&.info("Migrating from #{queue_from}. Total: #{queue_length(queue_from)}. Migrated: #{migrated}.")
end
job = conn.rpop "queue:#{queue_from}"
job_hash = Sidekiq.load_json job
next unless mappings.has_key?(job_hash['class'])
destination_queue = mappings[job_hash['class']]
job_hash['queue'] = destination_queue
conn.lpush("queue:#{destination_queue}", Sidekiq.dump_json(job_hash))
migrated += 1
rescue JSON::ParserError
logger&.error("Unmarshal JSON payload from SidekiqMigrateJobs failed. Job: #{job}")
next
end
end
logger&.info("Finished migrating #{queue_from} queue")
end
end
end
private
def queue_length(queue_name)
Sidekiq.redis do |conn|
conn.llen("queue:#{queue_name}")
end
end
# rubocop: enable Cop/SidekiqRedisCall
end
def up
return if Gitlab.com?
mappings = Gitlab::SidekiqConfig.worker_queue_mappings
logger = ::Gitlab::BackgroundMigration::Logger.build
SidekiqMigrateJobs.new(mappings, logger: logger).migrate_queues
end
def down
# no-op
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class RecreateAsyncTrigramIndexForVulnerabilityReadsContainerImages < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
INDEX_NAME = 'index_vulnerability_reads_on_location_image_trigram'
REPORT_TYPES = { container_scanning: 2, cluster_image_scanning: 7 }.freeze
def up
remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME
prepare_async_index :vulnerability_reads, :location_image,
name: INDEX_NAME,
using: :gin, opclass: { location_image: :gin_trgm_ops },
where: "report_type = ANY (ARRAY[#{REPORT_TYPES.values.join(', ')}]) AND location_image IS NOT NULL"
end
def down
unprepare_async_index :vulnerability_reads, :location_image, name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
90794c6a9b8b9e08e8b0898e55bc581b8411fd0e85a17fefa916213d82e98099

View File

@ -0,0 +1 @@
662c4df2d65a9259e2eafc11e828ffc15765b92fe3a5291ff869129aaf7bb1c0

View File

@ -0,0 +1 @@
1d7912409bb5afc7de82b7507fb2aeb164253c70a58eaf88d502513577bad979

View File

@ -31204,8 +31204,6 @@ CREATE INDEX tmp_index_for_project_namespace_id_migration_on_routes ON routes US
CREATE INDEX tmp_index_issues_on_issue_type_and_id ON issues USING btree (issue_type, id);
CREATE INDEX tmp_index_members_on_id_where_namespace_id_null ON members USING btree (id) WHERE (member_namespace_id IS NULL);
CREATE INDEX tmp_index_members_on_state ON members USING btree (state) WHERE (state = 2);
CREATE INDEX tmp_index_migrated_container_registries ON container_repositories USING btree (project_id) WHERE ((migration_state = 'import_done'::text) OR (created_at >= '2022-01-23 00:00:00'::timestamp without time zone));

View File

@ -198,7 +198,8 @@ docker login gitlab.example.com:5050
When the Registry is configured to use its own domain, you need a TLS
certificate for that specific domain (for example, `registry.example.com`). You might need
a wildcard certificate if hosted under a subdomain of your existing GitLab
domain, for example, `registry.gitlab.example.com`.
domain. For example, `*.gitlab.example.com`, is a wildcard that matches `registry.gitlab.example.com`,
and is distinct from `*.example.com`.
As well as manually generated SSL certificates (explained here), certificates automatically
generated by Let's Encrypt are also [supported in Omnibus installs](https://docs.gitlab.com/omnibus/settings/ssl.html).

View File

@ -260,6 +260,7 @@ control over how the Pages daemon runs and serves content in your environment.
| `gitlab_id` | The OAuth application public ID. Leave blank to automatically fill when Pages authenticates with GitLab. |
| `gitlab_secret` | The OAuth application secret. Leave blank to automatically fill when Pages authenticates with GitLab. |
| `auth_scope` | The OAuth application scope to use for authentication. Must match GitLab Pages OAuth application settings. Leave blank to use `api` scope by default. |
| `auth_cookie_session_timeout` | Authentication cookie session timeout in seconds (default: 600s). A value of `0` means the cookie is deleted after the browser session ends. |
| `gitlab_server` | Server to use for authentication when access control is enabled; defaults to GitLab `external_url`. |
| `headers` | Specify any additional http headers that should be sent to the client with each response. Multiple headers can be given as an array, header and value as one string, for example `['my-header: myvalue', 'my-other-header: my-other-value']` |
| `enable_disk` | Allows the GitLab Pages daemon to serve content from disk. Shall be disabled if shared disk storage isn't available. |

View File

@ -43,35 +43,657 @@ components:
paths:
# METADATA
/v4/metadata:
$ref: 'v4/metadata.yaml'
$ref: '#/metadata'
# VERSION
/v4/version:
$ref: 'v4/version.yaml'
$ref: '#/version'
# ACCESS REQUESTS (PROJECTS)
/v4/projects/{id}/access_requests:
$ref: 'v4/access_requests.yaml#/accessRequestsProjects'
$ref: '#/accessRequestsProjects'
/v4/projects/{id}/access_requests/{user_id}/approve:
$ref: 'v4/access_requests.yaml#/accessRequestsProjectsApprove'
$ref: '#/accessRequestsProjectsApprove'
/v4/projects/{id}/access_requests/{user_id}:
$ref: 'v4/access_requests.yaml#/accessRequestsProjectsDeny'
$ref: '#/accessRequestsProjectsDeny'
# ACCESS REQUESTS (GROUPS)
/v4/groups/{id}/access_requests:
$ref: 'v4/access_requests.yaml#/accessRequestsGroups'
$ref: '#/accessRequestsGroups'
/v4/groups/{id}/access_requests/{user_id}/approve:
$ref: 'v4/access_requests.yaml#/accessRequestsGroupsApprove'
$ref: '#/accessRequestsGroupsApprove'
/v4/groups/{id}/access_requests/{user_id}:
$ref: 'v4/access_requests.yaml#/accessRequestsGroupsDeny'
$ref: '#/accessRequestsGroupsDeny'
# ACCESS REQUESTS (PROJECTS)
/v4/projects/{id}/access_tokens:
$ref: 'v4/access_tokens.yaml#/accessTokens'
$ref: '#/accessTokens'
/v4/projects/{id}/access_tokens/{token_id}:
$ref: 'v4/access_tokens.yaml#/accessTokensRevoke'
$ref: '#/accessTokensRevoke'
metadata:
get:
tags:
- metadata
summary: 'Retrieve metadata information for this GitLab instance.'
operationId: 'getMetadata'
responses:
'401':
description: 'unauthorized operation'
'200':
description: 'successful operation'
content:
'application/json':
schema:
title: 'MetadataResponse'
type: 'object'
properties:
version:
type: 'string'
revision:
type: 'string'
kas:
type: 'object'
properties:
enabled:
type: 'boolean'
externalUrl:
type: 'string'
nullable: true
version:
type: 'string'
nullable: true
examples:
Example:
value:
version: '15.0-pre'
revision: 'c401a659d0c'
kas:
enabled: true
externalUrl: 'grpc://gitlab.example.com:8150'
version: '15.0.0'
version:
get:
tags:
- version
summary: 'Retrieve version information for this GitLab instance.'
operationId: 'getVersion'
responses:
'401':
description: 'unauthorized operation'
'200':
description: 'successful operation'
content:
'application/json':
schema:
title: 'VersionResponse'
type: 'object'
properties:
version:
type: 'string'
revision:
type: 'string'
examples:
Example:
value:
version: '13.3.0-pre'
revision: 'f2b05afebb0'
#/v4/projects/{id}/access_requests
accessRequestsProjects:
get:
description: Lists access requests for a project
summary: List access requests for a project
operationId: accessRequestsProjects_get
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessResponse
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
- 'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
- 'id': 2
'username': 'john_doe'
'name': 'John Doe'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
post:
description: Requests access for the authenticated user to a project
summary: Requests access for the authenticated user to a project
operationId: accessRequestsProjects_post
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessRequest
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
#/v4/projects/{id}/access_requests/{user_id}/approve
accessRequestsProjectsApprove:
put:
description: Approves access for the authenticated user to a project
summary: Approves access for the authenticated user to a project
operationId: accessRequestsProjectsApprove_put
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
- name: access_level
in: query
description: A valid project access level. 0 = no access , 10 = guest, 20 = reporter, 30 = developer, 40 = Maintainer. Default is 30.'
required: false
schema:
enum: [0, 10, 20, 30, 40]
default: 30
type: integer
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessApprove
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
access_level:
type: integer
example:
'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'access_level': 20
#/v4/projects/{id}/access_requests/{user_id}
accessRequestsProjectsDeny:
delete:
description: Denies a project access request for the given user
summary: Denies a project access request for the given user
operationId: accessRequestProjectsDeny_delete
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The user ID of the access requester
required: true
schema:
type: integer
responses: # Does anything go here? Markdown doc does not list a response.
'401':
description: Unauthorized operation
'200':
description: Successful operation
#/v4/groups/{id}/access_requests
accessRequestsGroups:
get:
description: List access requests for a group
summary: List access requests for a group
operationId: accessRequestsGroups_get
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessResponse
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
- 'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
- 'id': 2
'username': 'john_doe'
'name': 'John Doe'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
post:
description: Requests access for the authenticated user to a group
summary: Requests access for the authenticated user to a group
operationId: accessRequestsGroups_post
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessRequest
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'requested_at': '2012-10-22T14:13:35Z'
#/v4/groups/{id}/access_requests/{user_id}/approve
accessRequestsGroupsApprove:
put:
description: Approves access for the authenticated user to a group
summary: Approves access for the authenticated user to a group
operationId: accessRequestsGroupsApprove_put
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
- name: access_level
in: query
description: A valid group access level. 0 = no access , 10 = Guest, 20 = Reporter, 30 = Developer, 40 = Maintainer, 50 = Owner. Default is 30.
required: false
schema:
enum: [0, 10, 20, 30, 40, 50]
default: 30
type: integer
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessApprove
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
access_level:
type: integer
example:
'id': 1
'username': 'raymond_smith'
'name': 'Raymond Smith'
'state': 'active'
'created_at': '2012-10-22T14:13:35Z'
'access_level': 20
#/v4/groups/{id}/access_requests/{user_id}
accessRequestsGroupsDeny:
delete:
description: Denies a group access request for the given user
summary: Denies a group access request for the given user
operationId: accessRequestsGroupsDeny_delete
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
responses: # Does anything go here? Markdown doc does not list a response.
'401':
description: Unauthorized operation
'200':
description: Successful operation
#/v4/projects/{id}/access_tokens
accessTokens:
get:
description: Lists access tokens for a project
summary: List access tokens for a project
operationId: accessTokens_get
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'404':
description: Not Found
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: AccessTokenList
type: object
properties:
user_id:
type: integer
scopes:
type: array
name:
type: string
expires_at:
type: date
id:
type: integer
active:
type: boolean
created_at:
type: date
revoked:
type: boolean
example:
'user_id': 141
'scopes': ['api']
'name': 'token'
'expires_at': '2022-01-31'
'id': 42
'active': true
'created_at': '2021-01-20T14:13:35Z'
'revoked': false
post:
description: Creates an access token for a project
summary: Creates an access token for a project
operationId: accessTokens_post
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
- name: name
in: query
description: The name of the project access token
required: true
schema:
type: string
- name: scopes
in: query
description: Defines read and write permissions for the token
required: true
schema:
type: array
items:
type: string
enum:
[
'api',
'read_api',
'read_registry',
'write_registry',
'read_repository',
'write_repository',
]
- name: expires_at
in: query
description: Date when the token expires. Time of day is Midnight UTC of that date.
required: false
schema:
type: date
responses:
'404':
description: Not Found
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: AccessTokenList
type: object
properties:
user_id:
type: integer
scopes:
type: array
name:
type: string
expires_at:
type: date
id:
type: integer
active:
type: boolean
created_at:
type: date
revoked:
type: boolean
token:
type: string
example:
'user_id': 166
'scopes': ['api', 'read_repository']
'name': 'test'
'expires_at': '2022-01-31'
'id': 58
'active': true
'created_at': '2021-01-20T14:13:35Z'
'revoked': false
'token': 'D4y...Wzr'
#/v4/projects/{id}/access_tokens/{token_id}
accessTokensRevoke:
delete:
description: Revokes an access token
summary: Revokes an access token
operationId: accessTokens_delete
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
- name: token_id
in: path
description: The ID of the project access token
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'400':
description: Bad Request
'404':
description: Not Found
'204':
description: No content if successfully revoked

View File

@ -1,381 +0,0 @@
# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/access_requests.md
#/v4/projects/{id}/access_requests
accessRequestsProjects:
get:
description: Lists access requests for a project
summary: List access requests for a project
operationId: accessRequestsProjects_get
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessResponse
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
- "id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
- "id": 2
"username": "john_doe"
"name": "John Doe"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
post:
description: Requests access for the authenticated user to a project
summary: Requests access for the authenticated user to a project
operationId: accessRequestsProjects_post
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessRequest
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
"id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
#/v4/projects/{id}/access_requests/{user_id}/approve
accessRequestsProjectsApprove:
put:
description: Approves access for the authenticated user to a project
summary: Approves access for the authenticated user to a project
operationId: accessRequestsProjectsApprove_put
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
- name: access_level
in: query
description: A valid project access level. 0 = no access , 10 = guest, 20 = reporter, 30 = developer, 40 = Maintainer. Default is 30.'
required: false
schema:
enum: [0, 10, 20, 30, 40]
default: 30
type: integer
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: ProjectAccessApprove
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
access_level:
type: integer
example:
"id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"access_level": 20
#/v4/projects/{id}/access_requests/{user_id}
accessRequestsProjectsDeny:
delete:
description: Denies a project access request for the given user
summary: Denies a project access request for the given user
operationId: accessRequestProjectsDeny_delete
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The user ID of the access requester
required: true
schema:
type: integer
responses: # Does anything go here? Markdown doc does not list a response.
'401':
description: Unauthorized operation
'200':
description: Successful operation
#/v4/groups/{id}/access_requests
accessRequestsGroups:
get:
description: List access requests for a group
summary: List access requests for a group
operationId: accessRequestsGroups_get
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessResponse
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
- "id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
- "id": 2
"username": "john_doe"
"name": "John Doe"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
post:
description: Requests access for the authenticated user to a group
summary: Requests access for the authenticated user to a group
operationId: accessRequestsGroups_post
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessRequest
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
requested_at:
type: string
example:
"id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"requested_at": "2012-10-22T14:13:35Z"
#/v4/groups/{id}/access_requests/{user_id}/approve
accessRequestsGroupsApprove:
put:
description: Approves access for the authenticated user to a group
summary: Approves access for the authenticated user to a group
operationId: accessRequestsGroupsApprove_put
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
- name: access_level
in: query
description: A valid group access level. 0 = no access , 10 = Guest, 20 = Reporter, 30 = Developer, 40 = Maintainer, 50 = Owner. Default is 30.
required: false
schema:
enum: [0, 10, 20, 30, 40, 50]
default: 30
type: integer
responses:
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: GroupAccessApprove
type: object
properties:
id:
type: integer
usename:
type: string
name:
type: string
state:
type: string
created_at:
type: string
access_level:
type: integer
example:
"id": 1
"username": "raymond_smith"
"name": "Raymond Smith"
"state": "active"
"created_at": "2012-10-22T14:13:35Z"
"access_level": 20
#/v4/groups/{id}/access_requests/{user_id}
accessRequestsGroupsDeny:
delete:
description: Denies a group access request for the given user
summary: Denies a group access request for the given user
operationId: accessRequestsGroupsDeny_delete
tags:
- access_requests
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the group owned by the authenticated user.
required: true
schema:
oneOf:
- type: integer
- type: string
- name: user_id
in: path
description: The userID of the access requester
required: true
schema:
type: integer
responses: # Does anything go here? Markdown doc does not list a response.
'401':
description: Unauthorized operation
'200':
description: Successful operation

View File

@ -1,170 +0,0 @@
# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/resource_access_tokens.md
#/v4/projects/{id}/access_tokens
accessTokens:
get:
description: Lists access tokens for a project
summary: List access tokens for a project
operationId: accessTokens_get
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'404':
description: Not Found
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: AccessTokenList
type: object
properties:
user_id:
type: integer
scopes:
type: array
name:
type: string
expires_at:
type: date
id:
type: integer
active:
type: boolean
created_at:
type: date
revoked:
type: boolean
example:
"user_id": 141
"scopes" : ["api"]
"name": "token"
"expires_at": "2022-01-31"
"id": 42
"active": true
"created_at": "2021-01-20T14:13:35Z"
"revoked" : false
post:
description: Creates an access token for a project
summary: Creates an access token for a project
operationId: accessTokens_post
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
- name: name
in: query
description: The name of the project access token
required: true
schema:
type: string
- name: scopes
in: query
description: Defines read and write permissions for the token
required: true
schema:
type: array
items:
type: string
enum: ["api", "read_api", "read_registry", "write_registry", "read_repository", "write_repository"]
- name: expires_at
in: query
description: Date when the token expires. Time of day is Midnight UTC of that date.
required: false
schema:
type: date
responses:
'404':
description: Not Found
'401':
description: Unauthorized operation
'200':
description: Successful operation
content:
application/json:
schema:
title: AccessTokenList
type: object
properties:
user_id:
type: integer
scopes:
type: array
name:
type: string
expires_at:
type: date
id:
type: integer
active:
type: boolean
created_at:
type: date
revoked:
type: boolean
token:
type: string
example:
"user_id": 166
"scopes" : [
"api",
"read_repository"
]
"name": "test"
"expires_at": "2022-01-31"
"id": 58
"active": true
"created_at": "2021-01-20T14:13:35Z"
"revoked" : false
"token" : "D4y...Wzr"
#/v4/projects/{id}/access_tokens/{token_id}
accessTokensRevoke:
delete:
description: Revokes an access token
summary: Revokes an access token
operationId: accessTokens_delete
tags:
- access_tokens
parameters:
- name: id
in: path
description: The ID or URL-encoded path of the project
required: true
schema:
oneOf:
- type: integer
- type: string
- name: token_id
in: path
description: The ID of the project access token
required: true
schema:
oneOf:
- type: integer
- type: string
responses:
'400':
description: Bad Request
'404':
description: Not Found
'204':
description: No content if successfully revoked

View File

@ -1,43 +0,0 @@
# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/metadata.md
get:
tags:
- metadata
summary: "Retrieve metadata information for this GitLab instance."
operationId: "getMetadata"
responses:
"401":
description: "unauthorized operation"
"200":
description: "successful operation"
content:
"application/json":
schema:
title: "MetadataResponse"
type: "object"
properties:
version:
type: "string"
revision:
type: "string"
kas:
type: "object"
properties:
enabled:
type: "boolean"
externalUrl:
type: "string"
nullable: true
version:
type: "string"
nullable: true
examples:
Example:
value:
version: "15.0-pre"
revision: "c401a659d0c"
kas:
enabled: true
externalUrl: "grpc://gitlab.example.com:8150"
version: "15.0.0"

View File

@ -1,28 +0,0 @@
# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/version.md
get:
tags:
- version
summary: "Retrieve version information for this GitLab instance."
operationId: "getVersion"
responses:
"401":
description: "unauthorized operation"
"200":
description: "successful operation"
content:
"application/json":
schema:
title: "VersionResponse"
type: "object"
properties:
version:
type: "string"
revision:
type: "string"
examples:
Example:
value:
version: "13.3.0-pre"
revision: "f2b05afebb0"

View File

@ -1707,17 +1707,17 @@ only supported report file in 15.0, but this is the first step towards GitLab su
</div>
<div class="deprecation removal-150 breaking-change">
<div class="deprecation removal-160 breaking-change">
### merged_by API field
Planned removal: GitLab <span class="removal-milestone">15.0</span> (2022-05-22)
Planned removal: GitLab <span class="removal-milestone">16.0</span> (2023-05-22)
WARNING:
This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/).
Review the details carefully before upgrading.
The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) is being deprecated and will be removed in GitLab 15.0. This field is being replaced with the `merge_user` field (already present in GraphQL) which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge.
The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) has been deprecated in favor of the `merge_user` field which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. API users are encouraged to use the new `merge_user` field instead. The `merged_by` field will be removed in v5 of the GitLab REST API.
</div>
</div>

View File

@ -45,7 +45,7 @@ mentions for yourself (the user currently signed in) are highlighted
in a different color.
Avoid mentioning `@all` in issues and merge requests. It sends an email notification
to all members of that project's parent group, not only the participants of the project,
to all members of that project's parent group, not only the participants of the project,
and may be interpreted as spam.
Notifications and mentions can be disabled in
[a group's settings](../group/manage.md#disable-email-notifications).
@ -144,7 +144,7 @@ If you edit an existing comment to add a user mention that wasn't there before,
- Creates a to-do item for the mentioned user.
- Does not send a notification email.
## Prevent comments by locking an issue
## Prevent comments by locking the discussion
You can prevent public comments in an issue or merge request.
When you do, only project members can add and edit comments.
@ -154,6 +154,8 @@ Prerequisite:
- In merge requests, you must have at least the Developer role.
- In issues, you must have at least the Reporter role.
To lock an issue or merge request:
1. On the right sidebar, next to **Lock issue** or **Lock merge request**, select **Edit**.
1. On the confirmation dialog, select **Lock**.
@ -161,6 +163,9 @@ Notes are added to the page details.
If an issue or merge request is locked and closed, you cannot reopen it.
<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
If you don't see this action on the right sidebar, your project or instance might have [moved sidebar actions](../project/merge_requests/index.md#move-sidebar-actions) enabled.
## Add an internal note
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207473) in GitLab 13.9 [with a flag](../../administration/feature_flags.md) named `confidential_notes`. Disabled by default.

View File

@ -228,20 +228,20 @@ to change their user notification settings to **Watch** instead.
### Edit notification settings for issues, merge requests, and epics
To enable notifications on a specific issue, merge request, or epic, you must turn on the
**Notifications** toggle in the right sidebar.
To toggle notifications on an issue, merge request, or epic: on the right sidebar, turn on or off the **Notifications** toggle.
- To subscribe, **turn on** if you are not a participant in the discussion, but want to receive
notifications on each update.
When you **turn on** notifications, you start receiving notifications on each update, even if you
haven't participated in the discussion.
When you turn notifications on in an epic, you aren't automatically subscribed to the issues linked
to the epic.
When you turn notifications on in an epic, you aren't automatically subscribed to the issues linked
to the epic.
When you **turn off** notifications, you stop receiving notifications for updates.
Turning this toggle off only unsubscribes you from updates related to this issue, merge request, or epic.
Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails).
- To unsubscribe, **turn off** if you are receiving notifications for updates but no longer want to
receive them.
Turning this toggle off only unsubscribes you from updates related to this issue, merge request, or epic.
Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails).
<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
If you don't see this action on the right sidebar, your project or instance may have
enabled a feature flag for [moved sidebar actions](../project/merge_requests/index.md#move-sidebar-actions).
### Notification events on issues, merge requests, and epics

View File

@ -61,7 +61,7 @@ To add a user to a project:
1. Select **Invite members**.
1. Enter an email address and select a [role](../../permissions.md).
1. Optional. Select an **Access expiration date**.
On that date, the user can no longer access the project.
From that date onwards, the user can no longer access the project.
1. Select **Invite**.
If the user has a GitLab account, they are added to the members list.
@ -97,19 +97,20 @@ Each user's access is based on:
- The role they're assigned in the group.
- The maximum role you choose when you invite the group.
Prerequisite:
Prerequisites:
- You must have the Maintainer or Owner role.
- Sharing the project with other groups must not be [prevented](../../group/access_and_permissions.md#prevent-a-project-from-being-shared-with-groups).
To add groups to a project:
To add a group to a project:
1. On the top bar, select **Main menu > Projects** and find your project.
1. On the left sidebar, select **Project information > Members**.
1. Select **Invite a group**.
1. Select a group.
1. Select the highest [role](../../permissions.md) for users in the group.
1. Optional. Select an **Access expiration date**. On that date, the group can no longer access the project.
1. Optional. Select an **Access expiration date**.
From that date onwards, the group can no longer access the project.
1. Select **Invite**.
The members of the group are not displayed on the **Members** tab.

View File

@ -250,6 +250,28 @@ This feature works only when a merge request is merged. Selecting **Remove sourc
after merging does not retarget open merge requests. This improvement is
[proposed as a follow-up](https://gitlab.com/gitlab-org/gitlab/-/issues/321559).
## Move sidebar actions
<!-- When the `moved_mr_sidebar` feature flag is removed, delete this topic and update the steps for these actions
like in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87727/diffs?diff_id=522279685#5d9afba799c4af9920dab533571d7abb8b9e9163 -->
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85584) in GitLab 14.10 [with a flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`.
On GitLab.com, this feature is not available.
When this feature flag is enabled, you can find the following actions in
**Merge request actions** (**{ellipsis_v}**) on the top right:
- The [notifications](../../profile/notifications.md#edit-notification-settings-for-issues-merge-requests-and-epics) toggle
- Mark merge request as ready or [draft](../merge_requests/drafts.md)
- Close merge request
- [Lock discussion](../../discussions/index.md#prevent-comments-by-locking-the-discussion)
- Copy reference
When this feature flag is disabled, these actions are in the right sidebar.
## Merge request workflows
For a software developer working in a team:

View File

@ -173,6 +173,7 @@ module API
mount ::API::AccessRequests
mount ::API::Appearance
mount ::API::BulkImports
mount ::API::Ci::ResourceGroups
mount ::API::Ci::Runner
mount ::API::Ci::Runners
mount ::API::Clusters::Agents
@ -226,7 +227,6 @@ module API
mount ::API::Ci::Jobs
mount ::API::Ci::PipelineSchedules
mount ::API::Ci::Pipelines
mount ::API::Ci::ResourceGroups
mount ::API::Ci::SecureFiles
mount ::API::Ci::Triggers
mount ::API::Ci::Variables

View File

@ -5,17 +5,27 @@ module API
class ResourceGroups < ::API::Base
include PaginationParams
ci_resource_groups_tags = %w[ci_resource_groups]
before { authenticate! }
feature_category :continuous_delivery
urgency :low
params do
requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
requires :id,
types: [String, Integer],
desc: 'The ID or URL-encoded path of the project owned by the authenticated user'
end
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get all resource groups for this project' do
desc 'Get all resource groups for a project' do
success Entities::Ci::ResourceGroup
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 404, message: 'Not found' }
]
is_array true
tags ci_resource_groups_tags
end
params do
use :pagination
@ -26,8 +36,13 @@ module API
present paginate(user_project.resource_groups), with: Entities::Ci::ResourceGroup
end
desc 'Get a single resource group' do
desc 'Get a specific resource group' do
success Entities::Ci::ResourceGroup
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 404, message: 'Not found' }
]
tags ci_resource_groups_tags
end
params do
requires :key, type: String, desc: 'The key of the resource group'
@ -38,8 +53,14 @@ module API
present resource_group, with: Entities::Ci::ResourceGroup
end
desc 'List upcoming jobs of a resource group' do
desc 'List upcoming jobs for a specific resource group' do
success Entities::Ci::JobBasic
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 404, message: 'Not found' }
]
is_array true
tags ci_resource_groups_tags
end
params do
requires :key, type: String, desc: 'The key of the resource group'
@ -57,13 +78,23 @@ module API
present paginate(upcoming_processables), with: Entities::Ci::JobBasic
end
desc 'Edit a resource group' do
desc 'Edit an existing resource group' do
detail "Updates an existing resource group's properties."
success Entities::Ci::ResourceGroup
failure [
{ code: 400, message: 'Bad request' },
{ code: 401, message: 'Unauthorized' },
{ code: 404, message: 'Not found' }
]
tags ci_resource_groups_tags
end
params do
requires :key, type: String, desc: 'The key of the resource group'
optional :process_mode, type: String, desc: 'The process mode',
values: ::Ci::ResourceGroup.process_modes.keys
optional :process_mode,
type: String,
desc: 'The process mode of the resource group',
values: ::Ci::ResourceGroup.process_modes.keys
end
put ':id/resource_groups/:key' do
authorize! :update_resource_group, resource_group

View File

@ -4,16 +4,26 @@ module API
module Entities
module Ci
class JobBasic < Grape::Entity
expose :id, :status, :stage, :name, :ref, :tag, :coverage, :allow_failure
expose :created_at, :started_at, :finished_at
expose :id, documentation: { type: 'integer', example: 1 }
expose :status, documentation: { type: 'string', example: 'waiting_for_resource' }
expose :stage, documentation: { type: 'string', example: 'deploy' }
expose :name, documentation: { type: 'string', example: 'deploy_to_production' }
expose :ref, documentation: { type: 'string', example: 'main' }
expose :tag, documentation: { type: 'boolean' }
expose :coverage, documentation: { type: 'number', format: 'float', example: 0.90 }
expose :allow_failure, documentation: { type: 'boolean' }
expose :created_at, documentation: { type: 'dateTime', example: '2015-12-24T15:51:21.880Z' }
expose :started_at, documentation: { type: 'dateTime', example: '2015-12-24T17:54:30.733Z' }
expose :finished_at, documentation: { type: 'dateTime', example: '2015-12-24T17:54:31.198Z' }
expose :duration,
documentation: { type: 'number', format: 'float', desc: 'Time spent running' }
documentation: { type: 'number', format: 'float', desc: 'Time spent running', example: 0.465 }
expose :queued_duration,
documentation: { type: 'number', format: 'float', desc: 'Time spent enqueued' }
documentation: { type: 'number', format: 'float', desc: 'Time spent enqueued', example: 0.123 }
expose :user, with: ::API::Entities::User
expose :commit, with: ::API::Entities::Commit
expose :pipeline, with: ::API::Entities::Ci::PipelineBasic
expose :failure_reason, if: -> (job) { job.failed? }
expose :failure_reason,
documentation: { type: 'string', example: 'script_failure' }, if: -> (job) { job.failed? }
expose :web_url do |job, _options|
Gitlab::Routing.url_helpers.project_job_url(job.project, job)

View File

@ -4,7 +4,11 @@ module API
module Entities
module Ci
class ResourceGroup < Grape::Entity
expose :id, :key, :process_mode, :created_at, :updated_at
expose :id, documentation: { type: 'integer', example: 1 }
expose :key, documentation: { type: 'string', example: 'production' }
expose :process_mode, documentation: { type: 'string', example: 'unordered' }
expose :created_at, documentation: { type: 'dateTime', example: '2021-09-01T08:04:59.650Z' }
expose :updated_at, documentation: { type: 'dateTime', example: '2021-09-01T08:04:59.650Z' }
end
end
end

View File

@ -47,12 +47,9 @@ module Gitlab
end
def validate!
context.logger.instrument(:config_file_validation) do
validate_execution_time!
validate_location!
validate_content! if errors.none?
validate_hash! if errors.none?
end
validate_location!
fetch_and_validate_content! if valid?
load_and_validate_expanded_hash! if valid?
end
def metadata
@ -72,11 +69,41 @@ module Gitlab
protected
def expanded_content_hash
return unless content_hash
def validate_location!
if invalid_location_type?
errors.push("Included file `#{masked_location}` needs to be a string")
elsif invalid_extension?
errors.push("Included file `#{masked_location}` does not have YAML extension!")
end
end
strong_memoize(:expanded_content_yaml) do
expand_includes(content_hash)
def fetch_and_validate_content!
context.logger.instrument(:config_file_fetch_content) do
content # calling the method fetches then memoizes the result
end
return if errors.any?
context.logger.instrument(:config_file_validate_content) do
validate_content!
end
end
def load_and_validate_expanded_hash!
context.logger.instrument(:config_file_fetch_content_hash) do
content_hash # calling the method loads then memoizes the result
end
context.logger.instrument(:config_file_expand_content_includes) do
expanded_content_hash # calling the method expands then memoizes the result
end
validate_hash!
end
def validate_content!
if content.blank?
errors.push("Included file `#{masked_location}` is empty or does not exist!")
end
end
@ -88,21 +115,11 @@ module Gitlab
nil
end
def validate_execution_time!
context.check_execution_time!
end
def expanded_content_hash
return unless content_hash
def validate_location!
if invalid_location_type?
errors.push("Included file `#{masked_location}` needs to be a string")
elsif invalid_extension?
errors.push("Included file `#{masked_location}` does not have YAML extension!")
end
end
def validate_content!
if content.blank?
errors.push("Included file `#{masked_location}` is empty or does not exist!")
strong_memoize(:expanded_content_yaml) do
expand_includes(content_hash)
end
end

View File

@ -127,6 +127,7 @@ module Gitlab
def verify!(location_object)
verify_max_includes!
verify_execution_time!
location_object.validate!
expandset.add(location_object)
end
@ -137,6 +138,10 @@ module Gitlab
end
end
def verify_execution_time!
context.check_execution_time!
end
def expand_variables(data)
logger.instrument(:config_mapper_variables) do
expand_variables_without_instrumentation(data)

View File

@ -25,8 +25,6 @@ module Gitlab
return error('Failed to build the pipeline!')
end
set_pipeline_name
raise Populate::PopulateError if pipeline.persisted?
end
@ -36,21 +34,6 @@ module Gitlab
private
def set_pipeline_name
return if Feature.disabled?(:pipeline_name, pipeline.project) ||
@command.yaml_processor_result.workflow_name.blank?
name = @command.yaml_processor_result.workflow_name
name = ExpandVariables.expand(name, -> { global_context.variables.sort_and_expand_all })
pipeline.build_pipeline_metadata(project: pipeline.project, name: name)
end
def global_context
Gitlab::Ci::Build::Context::Global.new(
pipeline, yaml_variables: @command.pipeline_seed.root_variables)
end
def stage_names
# We filter out `.pre/.post` stages, as they alone are not considered
# a complete pipeline:

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module Gitlab
module Ci
module Pipeline
module Chain
class PopulateMetadata < Chain::Base
include Chain::Helpers
def perform!
set_pipeline_name
return if pipeline.pipeline_metadata.nil? || pipeline.pipeline_metadata.valid?
message = pipeline.pipeline_metadata.errors.full_messages.join(', ')
error("Failed to build pipeline metadata! #{message}")
end
def break?
pipeline.pipeline_metadata&.errors&.any?
end
private
def set_pipeline_name
return if Feature.disabled?(:pipeline_name, pipeline.project) ||
@command.yaml_processor_result.workflow_name.blank?
name = @command.yaml_processor_result.workflow_name
name = ExpandVariables.expand(name, -> { global_context.variables.sort_and_expand_all })
pipeline.build_pipeline_metadata(project: pipeline.project, name: name)
end
def global_context
Gitlab::Ci::Build::Context::Global.new(
pipeline, yaml_variables: @command.pipeline_seed.root_variables)
end
end
end
end
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Gitlab
module Email
# Contains common methods which must be present in all email classes
module Common
UNSUBSCRIBE_SUFFIX = '-unsubscribe'
UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe'
WILDCARD_PLACEHOLDER = '%{key}'
# This can be overridden for a custom config
def config
raise NotImplementedError
end
def incoming_email_config
Gitlab.config.incoming_email
end
def enabled?
!!config&.enabled && config.address.present?
end
def supports_wildcard?
config_address = incoming_email_config.address
config_address.present? && config_address.include?(WILDCARD_PLACEHOLDER)
end
def supports_issue_creation?
enabled? && supports_wildcard?
end
def reply_address(key)
incoming_email_config.address.sub(WILDCARD_PLACEHOLDER, key)
end
# example: incoming+1234567890abcdef1234567890abcdef-unsubscribe@incoming.gitlab.com
def unsubscribe_address(key)
incoming_email_config.address.sub(WILDCARD_PLACEHOLDER, "#{key}#{UNSUBSCRIBE_SUFFIX}")
end
def key_from_address(address, wildcard_address: nil)
raise NotImplementedError
end
def key_from_fallback_message_id(mail_id)
message_id_regexp = /\Areply-(.+)@#{Gitlab.config.gitlab.host}\z/
mail_id[message_id_regexp, 1]
end
def scan_fallback_references(references)
# It's looking for each <...>
references.scan(/(?!<)[^<>]+(?=>)/)
end
end
end
end

View File

@ -73,7 +73,7 @@ module Gitlab
end
def can_handle_legacy_format?
project_path && !incoming_email_token.include?('+') && !mail_key.include?(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY)
project_path && !incoming_email_token.include?('+') && !mail_key.include?(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY)
end
end
end

View File

@ -12,8 +12,8 @@ module Gitlab
delegate :project, to: :sent_notification, allow_nil: true
HANDLER_REGEX_FOR = -> (suffix) { /\A(?<reply_token>\w+)#{Regexp.escape(suffix)}\z/ }.freeze
HANDLER_REGEX = HANDLER_REGEX_FOR.call(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX).freeze
HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY).freeze
HANDLER_REGEX = HANDLER_REGEX_FOR.call(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX).freeze
HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY).freeze
def initialize(mail, mail_key)
super(mail, mail_key)

View File

@ -2,30 +2,11 @@
module Gitlab
module IncomingEmail
UNSUBSCRIBE_SUFFIX = '-unsubscribe'
UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe'
WILDCARD_PLACEHOLDER = '%{key}'
class << self
def enabled?
config.enabled && config.address.present?
end
include Gitlab::Email::Common
def supports_wildcard?
config.address.present? && config.address.include?(WILDCARD_PLACEHOLDER)
end
def supports_issue_creation?
enabled? && supports_wildcard?
end
def reply_address(key)
config.address.sub(WILDCARD_PLACEHOLDER, key)
end
# example: incoming+1234567890abcdef1234567890abcdef-unsubscribe@incoming.gitlab.com
def unsubscribe_address(key)
config.address.sub(WILDCARD_PLACEHOLDER, "#{key}#{UNSUBSCRIBE_SUFFIX}")
def config
incoming_email_config
end
def key_from_address(address, wildcard_address: nil)
@ -39,21 +20,6 @@ module Gitlab
match[1]
end
def key_from_fallback_message_id(mail_id)
message_id_regexp = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\z/
mail_id[message_id_regexp, 1]
end
def scan_fallback_references(references)
# It's looking for each <...>
references.scan(/(?!<)[^<>]+(?=>)/)
end
def config
Gitlab.config.incoming_email
end
private
def address_regex(wildcard_address)

View File

@ -3,8 +3,10 @@
module Gitlab
module ServiceDeskEmail
class << self
def enabled?
!!config&.enabled && config&.address.present?
include Gitlab::Email::Common
def config
Gitlab.config.service_desk_email
end
def key_from_address(address)
@ -14,20 +16,10 @@ module Gitlab
Gitlab::IncomingEmail.key_from_address(address, wildcard_address: wildcard_address)
end
def config
Gitlab.config.service_desk_email
end
def address_for_key(key)
return if config.address.blank?
config.address.sub(Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER, key)
end
def key_from_fallback_message_id(mail_id)
message_id_regexp = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\z/
mail_id[message_id_regexp, 1]
config.address.sub(WILDCARD_PLACEHOLDER, key)
end
end
end

View File

@ -3,6 +3,7 @@
module Gitlab
class SidekiqMigrateJobs
LOG_FREQUENCY = 1_000
LOG_FREQUENCY_QUEUES = 10
attr_reader :logger, :mappings
@ -72,7 +73,7 @@ module Gitlab
migrated = 0
while queue_length(queue_from) > 0
begin
if migrated >= 0 && migrated % LOG_FREQUENCY == 0
if migrated >= 0 && migrated % LOG_FREQUENCY_QUEUES == 0
logger&.info("Migrating from #{queue_from}. Total: #{queue_length(queue_from)}. Migrated: #{migrated}.")
end

View File

@ -11,7 +11,6 @@ function retrieve_tests_metadata() {
if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then
curl --location -o "${FLAKY_RSPEC_SUITE_REPORT_PATH}" "https://gitlab-org.gitlab.io/gitlab/${FLAKY_RSPEC_SUITE_REPORT_PATH}" ||
curl --location -o "${FLAKY_RSPEC_SUITE_REPORT_PATH}" "https://gitlab-org.gitlab.io/gitlab/rspec_flaky/report-suite.json" || # temporary back-compat
echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
fi
else
@ -35,13 +34,7 @@ function retrieve_tests_metadata() {
if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then
scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ||
scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "rspec_flaky/report-suite.json" || # temporary back-compat
echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
# temporary back-compat
if [[ -f "rspec_flaky/report-suite.json" ]]; then
mv "rspec_flaky/report-suite.json" "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
fi
fi
else
echo "test_metadata_job_id couldn't be found!"

View File

@ -101,6 +101,35 @@ RSpec.describe "Issues > User edits issue", :js do
visit project_issue_path(project, issue)
end
describe 'edit description' do
def click_edit_issue_description
click_on 'Edit title and description'
end
it 'places focus on the web editor' do
toggle_editing_mode_selector = '[data-testid="toggle-editing-mode-button"] label'
content_editor_focused_selector = '[data-testid="content-editor"].is-focused'
markdown_field_focused_selector = 'textarea:focus'
click_edit_issue_description
expect(page).to have_selector(markdown_field_focused_selector)
find(toggle_editing_mode_selector, text: 'Rich text').click
expect(page).not_to have_selector(content_editor_focused_selector)
refresh
click_edit_issue_description
expect(page).to have_selector(content_editor_focused_selector)
find(toggle_editing_mode_selector, text: 'Source').click
expect(page).not_to have_selector(markdown_field_focused_selector)
end
end
describe 'update labels' do
it 'will not send ajax request when no data is changed' do
page.within '.labels' do

View File

@ -86,7 +86,7 @@ describe('Description field component', () => {
renderMarkdownPath: '/',
markdownDocsPath: '/',
quickActionsDocsPath: expect.any(String),
initOnAutofocus: true,
autofocus: true,
supportsQuickActions: true,
enableAutocomplete: true,
}),

View File

@ -58,6 +58,12 @@ describe('registry_header', () => {
describe('sub header parts', () => {
describe('images count', () => {
it('does not exist', async () => {
await mountComponent({ imagesCount: 0 });
expect(findImagesCountSubHeader().exists()).toBe(false);
});
it('exists', async () => {
await mountComponent({ imagesCount: 1 });

View File

@ -116,7 +116,7 @@ describe('WikiForm', () => {
renderMarkdownPath: pageInfoPersisted.markdownPreviewPath,
markdownDocsPath: pageInfoPersisted.markdownHelpPath,
uploadsPath: pageInfoPersisted.uploadsPath,
initOnAutofocus: pageInfoPersisted.persisted,
autofocus: pageInfoPersisted.persisted,
formFieldId: 'wiki_content',
formFieldName: 'wiki[content]',
}),

View File

@ -27,7 +27,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
const formFieldAriaLabel = 'Edit your content';
let mock;
const buildWrapper = ({ propsData = {}, attachTo } = {}) => {
const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => {
wrapper = mountExtended(MarkdownEditor, {
attachTo,
propsData: {
@ -45,6 +45,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
},
stubs: {
BubbleMenu: stubComponent(BubbleMenu),
...stubs,
},
});
};
@ -138,9 +139,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
expect(wrapper.emitted('input')).toEqual([[newValue]]);
});
describe('when initOnAutofocus is true', () => {
describe('when autofocus is true', () => {
beforeEach(async () => {
buildWrapper({ attachTo: document.body, propsData: { initOnAutofocus: true } });
buildWrapper({ attachTo: document.body, propsData: { autofocus: true } });
await nextTick();
});
@ -171,7 +172,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
renderMarkdown: expect.any(Function),
uploadsPath: window.uploads_path,
markdown: value,
autofocus: 'end',
}),
);
});
@ -204,10 +204,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR);
});
describe('when initOnAutofocus is true', () => {
describe('when autofocus is true', () => {
beforeEach(() => {
buildWrapper({ propsData: { initOnAutofocus: true } });
findLocalStorageSync().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR);
buildWrapper({
propsData: { autofocus: true },
stubs: { ContentEditor: stubComponent(ContentEditor) },
});
});
it('sets the content editor autofocus property to end', () => {
@ -247,19 +249,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
it('updates localStorage value', () => {
expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD);
});
it('sets the textarea as the activeElement in the document', async () => {
// The component should be rebuilt to attach it to the document body
buildWrapper({ attachTo: document.body });
await findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR);
expect(findContentEditor().exists()).toBe(true);
await findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD);
await findSegmentedControl().vm.$emit('change', EDITING_MODE_MARKDOWN_FIELD);
expect(document.activeElement).toBe(findTextarea().element);
});
});
describe('when content editor emits loading event', () => {

View File

@ -12,10 +12,12 @@ import WorkItemDescription from '~/work_items/components/work_item_description.v
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
updateWorkItemMutationResponse,
workItemResponseFactory,
workItemQueryResponse,
projectWorkItemResponse,
} from '../mock_data';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
@ -29,6 +31,8 @@ describe('WorkItemDescription', () => {
Vue.use(VueApollo);
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
let workItemResponseHandler;
const findEditButton = () => wrapper.find('[data-testid="edit-description"]');
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
@ -44,18 +48,24 @@ describe('WorkItemDescription', () => {
canUpdate = true,
workItemResponse = workItemResponseFactory({ canUpdate }),
isEditing = false,
fetchByIid = false,
} = {}) => {
const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
const { id } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemDescription, {
apolloProvider: createMockApollo([
[workItemQuery, workItemResponseHandler],
[updateWorkItemMutation, mutationHandler],
[workItemByIidQuery, workItemByIidResponseHandler],
]),
propsData: {
workItemId: id,
fullPath: 'test-project-path',
queryVariables: {
id: workItemId,
},
fetchByIid,
},
stubs: {
MarkdownField,
@ -242,4 +252,20 @@ describe('WorkItemDescription', () => {
expect(updateDraft).toHaveBeenCalled();
});
});
it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
createComponent({ fetchByIid: false });
await waitForPromises();
expect(workItemResponseHandler).toHaveBeenCalled();
expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
});
it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
createComponent({ fetchByIid: true });
await waitForPromises();
expect(workItemResponseHandler).not.toHaveBeenCalled();
expect(workItemByIidResponseHandler).toHaveBeenCalled();
});
});

View File

@ -86,6 +86,7 @@ describe('WorkItemDetailModal component', () => {
isModal: true,
workItemId: defaultPropsData.workItemId,
workItemParentId: defaultPropsData.issueGid,
iid: null,
});
});

View File

@ -24,6 +24,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemInformation from '~/work_items/components/work_item_information.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
@ -37,6 +38,7 @@ import {
workItemResponseFactory,
workItemTitleSubscriptionResponse,
workItemAssigneesSubscriptionResponse,
projectWorkItemResponse,
} from '../mock_data';
describe('WorkItemDetail component', () => {
@ -52,6 +54,7 @@ describe('WorkItemDetail component', () => {
canDelete: true,
});
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const successByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const assigneesSubscriptionHandler = jest
@ -87,12 +90,15 @@ describe('WorkItemDetail component', () => {
error = undefined,
includeWidgets = false,
workItemsMvc2Enabled = false,
fetchByIid = false,
iidPathQueryParam = undefined,
} = {}) => {
const handlers = [
[workItemQuery, handler],
[workItemTitleSubscription, subscriptionHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
[workItemAssigneesSubscription, assigneesSubscriptionHandler],
[workItemByIidQuery, successByIidHandler],
confidentialityMock,
];
@ -104,7 +110,7 @@ describe('WorkItemDetail component', () => {
typePolicies: includeWidgets ? config.cacheConfig.typePolicies : {},
},
),
propsData: { isModal, workItemId },
propsData: { isModal, workItemId, iid: '1' },
data() {
return {
updateInProgress,
@ -114,15 +120,24 @@ describe('WorkItemDetail component', () => {
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
useIidInWorkItemsPath: fetchByIid,
},
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
projectNamespace: 'namespace',
fullPath: 'group/project',
},
stubs: {
WorkItemWeight: true,
WorkItemIteration: true,
},
mocks: {
$route: {
query: {
iid_path: iidPathQueryParam,
},
},
},
});
};
@ -421,8 +436,9 @@ describe('WorkItemDetail component', () => {
});
describe('subscriptions', () => {
it('calls the title subscription', () => {
it('calls the title subscription', async () => {
createComponent();
await waitForPromises();
expect(titleSubscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.data.workItem.id,
@ -571,4 +587,35 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemInformationAlert().exists()).toBe(false);
});
});
it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is false', async () => {
createComponent();
await waitForPromises();
expect(successHandler).toHaveBeenCalledWith({
id: workItemQueryResponse.data.workItem.id,
});
expect(successByIidHandler).not.toHaveBeenCalled();
});
it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is true but there is no `iid_path` parameter in URL', async () => {
createComponent({ fetchByIid: true });
await waitForPromises();
expect(successHandler).toHaveBeenCalledWith({
id: workItemQueryResponse.data.workItem.id,
});
expect(successByIidHandler).not.toHaveBeenCalled();
});
it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present', async () => {
createComponent({ fetchByIid: true, iidPathQueryParam: 'true' });
await waitForPromises();
expect(successHandler).not.toHaveBeenCalled();
expect(successByIidHandler).toHaveBeenCalledWith({
fullPath: 'group/project',
iid: '1',
});
});
});

View File

@ -9,6 +9,7 @@ import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widg
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants';
import {
@ -18,6 +19,7 @@ import {
workItemResponseFactory,
updateWorkItemMutationResponse,
workItemLabelsSubscriptionResponse,
projectWorkItemResponse,
} from '../mock_data';
Vue.use(VueApollo);
@ -33,6 +35,7 @@ describe('WorkItemLabels component', () => {
const findLabelsTitle = () => wrapper.findByTestId('labels-title');
const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse);
const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
const successUpdateWorkItemMutationHandler = jest
.fn()
@ -45,12 +48,14 @@ describe('WorkItemLabels component', () => {
workItemQueryHandler = workItemQuerySuccess,
searchQueryHandler = successSearchQueryHandler,
updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler,
fetchByIid = false,
} = {}) => {
const apolloProvider = createMockApollo([
[workItemQuery, workItemQueryHandler],
[labelSearchQuery, searchQueryHandler],
[updateWorkItemMutation, updateWorkItemMutationHandler],
[workItemLabelsSubscription, subscriptionHandler],
[workItemByIidQuery, workItemByIidResponseHandler],
]);
wrapper = mountExtended(WorkItemLabels, {
@ -58,6 +63,10 @@ describe('WorkItemLabels component', () => {
workItemId,
canUpdate,
fullPath: 'test-project-path',
queryVariables: {
id: workItemId,
},
fetchByIid,
},
attachTo: document.body,
apolloProvider,
@ -226,4 +235,20 @@ describe('WorkItemLabels component', () => {
});
});
});
it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
createComponent({ fetchByIid: false });
await waitForPromises();
expect(workItemQuerySuccess).toHaveBeenCalled();
expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
});
it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
createComponent({ fetchByIid: true });
await waitForPromises();
expect(workItemQuerySuccess).not.toHaveBeenCalled();
expect(workItemByIidResponseHandler).toHaveBeenCalled();
});
});

View File

@ -41,6 +41,7 @@ export const workItemQueryResponse = {
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
iid: '1',
title: 'Test',
state: 'OPEN',
description: 'description',
@ -113,6 +114,7 @@ export const updateWorkItemMutationResponse = {
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
iid: '1',
title: 'Updated title',
state: 'OPEN',
description: 'description',
@ -199,6 +201,7 @@ export const workItemResponseFactory = ({
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
iid: 1,
title: 'Updated title',
state: 'OPEN',
description: 'description',
@ -331,6 +334,7 @@ export const createWorkItemMutationResponse = {
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
iid: '1',
title: 'Updated title',
state: 'OPEN',
description: 'description',
@ -368,6 +372,7 @@ export const createWorkItemFromTaskMutationResponse = {
__typename: 'WorkItem',
description: 'New description',
id: 'gid://gitlab/WorkItem/1',
iid: '1',
title: 'Updated title',
state: 'OPEN',
confidential: false,
@ -405,6 +410,7 @@ export const createWorkItemFromTaskMutationResponse = {
newWorkItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1000000',
iid: '100',
title: 'Updated title',
state: 'OPEN',
createdAt: '2022-08-03T12:41:54Z',
@ -776,6 +782,7 @@ export const changeWorkItemParentMutationResponse = {
},
description: null,
id: 'gid://gitlab/WorkItem/2',
iid: '2',
state: 'OPEN',
title: 'Foo',
confidential: false,
@ -1122,3 +1129,14 @@ export const projectMilestonesResponseWithNoMilestones = {
},
},
};
export const projectWorkItemResponse = {
data: {
workspace: {
id: 'gid://gitlab/Project/1',
workItems: {
nodes: [workItemQueryResponse.data.workItem],
},
},
},
};

View File

@ -37,12 +37,17 @@ describe('Create work item component', () => {
props = {},
queryHandler = querySuccessHandler,
mutationHandler = createWorkItemSuccessHandler,
fetchByIid = false,
} = {}) => {
fakeApollo = createMockApollo([
[projectWorkItemTypesQuery, queryHandler],
[createWorkItemMutation, mutationHandler],
[createWorkItemFromTaskMutation, mutationHandler],
]);
fakeApollo = createMockApollo(
[
[projectWorkItemTypesQuery, queryHandler],
[createWorkItemMutation, mutationHandler],
[createWorkItemFromTaskMutation, mutationHandler],
],
{},
{ typePolicies: { Project: { merge: true } } },
);
wrapper = shallowMount(CreateWorkItem, {
apolloProvider: fakeApollo,
data() {
@ -61,6 +66,9 @@ describe('Create work item component', () => {
},
provide: {
fullPath: 'full-path',
glFeatures: {
useIidInWorkItemsPath: fetchByIid,
},
},
});
};
@ -99,7 +107,12 @@ describe('Create work item component', () => {
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(wrapper.vm.$router.push).toHaveBeenCalled();
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
name: 'workItem',
params: {
id: '1',
},
});
});
it('adds right margin for create button', () => {
@ -197,4 +210,18 @@ describe('Create work item component', () => {
'Something went wrong when creating work item. Please try again.',
);
});
it('performs a correct redirect when `useIidInWorkItemsPath` feature flag is enabled', async () => {
createComponent({ fetchByIid: true });
findTitleInput().vm.$emit('title-input', 'Test title');
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
name: 'workItem',
params: { id: '1' },
query: { iid_path: 'true' },
});
});
});

View File

@ -55,6 +55,7 @@ describe('Work items root component', () => {
isModal: false,
workItemId: 'gid://gitlab/WorkItem/1',
workItemParentId: null,
iid: '1',
});
});
@ -65,11 +66,15 @@ describe('Work items root component', () => {
deleteWorkItemHandler,
});
findWorkItemDetail().vm.$emit('deleteWorkItem');
findWorkItemDetail().vm.$emit('deleteWorkItem', { workItemType: 'task', workItemId: '1' });
await waitForPromises();
expect(deleteWorkItemHandler).toHaveBeenCalled();
expect(deleteWorkItemHandler).toHaveBeenCalledWith({
input: {
id: '1',
},
});
expect(mockToastShow).toHaveBeenCalled();
expect(visitUrl).toHaveBeenCalledWith(issuesListPath);
});
@ -81,7 +86,7 @@ describe('Work items root component', () => {
deleteWorkItemHandler,
});
findWorkItemDetail().vm.$emit('deleteWorkItem');
findWorkItemDetail().vm.$emit('deleteWorkItem', { workItemType: 'task', workItemId: '1' });
await waitForPromises();

View File

@ -0,0 +1,136 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Chain::PopulateMetadata do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:pipeline) do
build(:ci_pipeline, project: project, ref: 'master', user: user)
end
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
project: project,
current_user: user,
origin_ref: 'master')
end
let(:dependencies) do
[
Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::SeedBlock.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::Seed.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::Populate.new(pipeline, command)
]
end
let(:step) { described_class.new(pipeline, command) }
let(:config) do
{ rspec: { script: 'rspec' } }
end
def run_chain
dependencies.map(&:perform!)
step.perform!
end
before do
stub_ci_pipeline_yaml_file(YAML.dump(config))
end
context 'with pipeline name' do
let(:config) do
{ workflow: { name: ' Pipeline name ' }, rspec: { script: 'rspec' } }
end
it 'does not break the chain' do
run_chain
expect(step.break?).to be false
end
context 'with feature flag disabled' do
before do
stub_feature_flags(pipeline_name: false)
end
it 'does not build pipeline_metadata' do
run_chain
expect(pipeline.pipeline_metadata).to be_nil
end
end
context 'with feature flag enabled' do
before do
stub_feature_flags(pipeline_name: true)
end
it 'builds pipeline_metadata' do
run_chain
expect(pipeline.pipeline_metadata.name).to eq('Pipeline name')
expect(pipeline.pipeline_metadata.project).to eq(pipeline.project)
expect(pipeline.pipeline_metadata).not_to be_persisted
end
context 'with empty name' do
let(:config) do
{ workflow: { name: ' ' }, rspec: { script: 'rspec' } }
end
it 'strips whitespace from name' do
run_chain
expect(pipeline.pipeline_metadata).to be_nil
end
end
context 'with variables' do
let(:config) do
{
variables: { ROOT_VAR: 'value $WORKFLOW_VAR1' },
workflow: {
name: 'Pipeline $ROOT_VAR $WORKFLOW_VAR2 $UNKNOWN_VAR',
rules: [{ variables: { WORKFLOW_VAR1: 'value1', WORKFLOW_VAR2: 'value2' } }]
},
rspec: { script: 'rspec' }
}
end
it 'substitutes variables' do
run_chain
expect(pipeline.pipeline_metadata.name).to eq('Pipeline value value1 value2 ')
end
end
context 'with invalid name' do
let(:config) do
{
variables: { ROOT_VAR: 'a' * 256 },
workflow: {
name: 'Pipeline $ROOT_VAR'
},
rspec: { script: 'rspec' }
}
end
it 'returns error and breaks chain' do
ret = run_chain
expect(ret)
.to match_array(["Failed to build pipeline metadata! Name is too long (maximum is 255 characters)"])
expect(pipeline.pipeline_metadata.errors.full_messages)
.to match_array(['Name is too long (maximum is 255 characters)'])
expect(step.break?).to be true
end
end
end
end
end

View File

@ -236,66 +236,4 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate do
end
end
end
context 'with pipeline name' do
let(:config) do
{ workflow: { name: ' Pipeline name ' }, rspec: { script: 'rspec' } }
end
context 'with feature flag disabled' do
before do
stub_feature_flags(pipeline_name: false)
end
it 'does not build pipeline_metadata' do
run_chain
expect(pipeline.pipeline_metadata).to be_nil
end
end
context 'with feature flag enabled' do
before do
stub_feature_flags(pipeline_name: true)
end
it 'builds pipeline_metadata' do
run_chain
expect(pipeline.pipeline_metadata.name).to eq('Pipeline name')
expect(pipeline.pipeline_metadata.project).to eq(pipeline.project)
end
context 'with empty name' do
let(:config) do
{ workflow: { name: ' ' }, rspec: { script: 'rspec' } }
end
it 'strips whitespace from name' do
run_chain
expect(pipeline.pipeline_metadata).to be_nil
end
end
context 'with variables' do
let(:config) do
{
variables: { ROOT_VAR: 'value $WORKFLOW_VAR1' },
workflow: {
name: 'Pipeline $ROOT_VAR $WORKFLOW_VAR2 $UNKNOWN_VAR',
rules: [{ variables: { WORKFLOW_VAR1: 'value1', WORKFLOW_VAR2: 'value2' } }]
},
rspec: { script: 'rspec' }
}
end
it 'substitutes variables' do
run_chain
expect(pipeline.pipeline_metadata.name).to eq('Pipeline value value1 value2 ')
end
end
end
end
end

View File

@ -10,7 +10,7 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do
stub_config_setting(host: 'localhost')
end
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}") }
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}") }
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:noteable) { create(:issue, project: project) }
@ -21,19 +21,19 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do
let(:mail) { Mail::Message.new(email_raw) }
it "matches the new format" do
handler = described_class.new(mail, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}")
handler = described_class.new(mail, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}")
expect(handler.can_handle?).to be_truthy
end
it "matches the legacy format" do
handler = described_class.new(mail, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}")
handler = described_class.new(mail, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}")
expect(handler.can_handle?).to be_truthy
end
it "doesn't match either format" do
handler = described_class.new(mail, "+#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}")
handler = described_class.new(mail, "+#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}")
expect(handler.can_handle?).to be_falsey
end
@ -64,7 +64,7 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do
end
context 'when using old style unsubscribe link' do
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}") }
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}") }
it 'unsubscribes user from notable' do
expect { receiver.execute }.to change { noteable.subscribed?(user) }.from(true).to(false)

View File

@ -60,7 +60,7 @@ RSpec.describe Gitlab::Email::Handler do
describe 'regexps are set properly' do
let(:addresses) do
%W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}) +
%W(sent_notification_key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX} sent_notification_key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}) +
%w(sent_notification_key path-to-project-123-user_email_token-merge-request) +
%w(path-to-project-123-user_email_token-issue path-to-project-123-user_email_token-issue-123) +
%w(path/to/project+user_email_token path/to/project+merge-request+user_email_token some/project)

View File

@ -1,87 +1,17 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'spec_helper'
RSpec.describe Gitlab::IncomingEmail do
describe "self.enabled?" do
context "when reply by email is enabled" do
before do
stub_incoming_email_setting(enabled: true)
end
let(:setting_name) { :incoming_email }
it 'returns true' do
expect(described_class.enabled?).to be(true)
end
end
it_behaves_like 'common email methods'
context "when reply by email is disabled" do
before do
stub_incoming_email_setting(enabled: false)
end
it "returns false" do
expect(described_class.enabled?).to be(false)
end
end
end
describe 'self.supports_wildcard?' do
context 'address contains the wildcard placeholder' do
before do
stub_incoming_email_setting(address: 'replies+%{key}@example.com')
end
it 'confirms that wildcard is supported' do
expect(described_class.supports_wildcard?).to be(true)
end
end
context "address doesn't contain the wildcard placeholder" do
before do
stub_incoming_email_setting(address: 'replies@example.com')
end
it 'returns that wildcard is not supported' do
expect(described_class.supports_wildcard?).to be(false)
end
end
context 'address is not set' do
before do
stub_incoming_email_setting(address: nil)
end
it 'returns that wildcard is not supported' do
expect(described_class.supports_wildcard?).to be(false)
end
end
end
context 'self.unsubscribe_address' do
describe 'self.key_from_address' do
before do
stub_incoming_email_setting(address: 'replies+%{key}@example.com')
end
it 'returns the address with interpolated reply key and unsubscribe suffix' do
expect(described_class.unsubscribe_address('key')).to eq("replies+key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}@example.com")
end
end
context "self.reply_address" do
before do
stub_incoming_email_setting(address: "replies+%{key}@example.com")
end
it "returns the address with an interpolated reply key" do
expect(described_class.reply_address("key")).to eq("replies+key@example.com")
end
end
context "self.key_from_address" do
before do
stub_incoming_email_setting(address: "replies+%{key}@example.com")
end
it "returns reply key" do
expect(described_class.key_from_address("replies+key@example.com")).to eq("key")
end
@ -101,25 +31,4 @@ RSpec.describe Gitlab::IncomingEmail do
end
end
end
context 'self.key_from_fallback_message_id' do
it 'returns reply key' do
expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key')
end
end
context 'self.scan_fallback_references' do
let(:references) do
'<issue_1@localhost>' \
' <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>' \
',<exchange@microsoft.com>'
end
it 'returns reply key' do
expect(described_class.scan_fallback_references(references))
.to eq(%w[issue_1@localhost
reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost
exchange@microsoft.com])
end
end
end

View File

@ -1,39 +1,11 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'spec_helper'
RSpec.describe Gitlab::ServiceDeskEmail do
describe '.enabled?' do
context 'when service_desk_email is enabled and address is set' do
before do
stub_service_desk_email_setting(enabled: true, address: 'foo')
end
let(:setting_name) { :service_desk_email }
it 'returns true' do
expect(described_class.enabled?).to be_truthy
end
end
context 'when service_desk_email is disabled' do
before do
stub_service_desk_email_setting(enabled: false, address: 'foo')
end
it 'returns false' do
expect(described_class.enabled?).to be_falsey
end
end
context 'when service desk address is not set' do
before do
stub_service_desk_email_setting(enabled: true, address: nil)
end
it 'returns false' do
expect(described_class.enabled?).to be_falsey
end
end
end
it_behaves_like 'common email methods'
describe '.key_from_address' do
context 'when service desk address is set' do
@ -78,10 +50,4 @@ RSpec.describe Gitlab::ServiceDeskEmail do
end
end
end
context 'self.key_from_fallback_message_id' do
it 'returns reply key' do
expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key')
end
end
end

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe MigrateSidekiqQueuedJobs, :clean_gitlab_redis_queues do
around do |example|
Sidekiq::Testing.disable!(&example)
end
describe '#up', :aggregate_failures, :silence_stdout do
before do
EmailReceiverWorker.sidekiq_options queue: 'email_receiver'
EmailReceiverWorker.perform_async('foo')
EmailReceiverWorker.perform_async('bar')
end
after do
EmailReceiverWorker.set_queue
end
context 'with worker_queue_mappings mocked' do
it 'migrates the jobs to the correct destination queue' do
allow(Gitlab::SidekiqConfig).to receive(:worker_queue_mappings)
.and_return({ "EmailReceiverWorker" => "default" })
expect(queue_length('email_receiver')).to eq(2)
expect(queue_length('default')).to eq(0)
migrate!
expect(queue_length('email_receiver')).to eq(0)
expect(queue_length('default')).to eq(2)
jobs = list_jobs('default')
expect(jobs[0]).to include("class" => "EmailReceiverWorker", "args" => ["bar"])
expect(jobs[1]).to include("class" => "EmailReceiverWorker", "args" => ["foo"])
end
end
context 'without worker_queue_mappings mocked' do
it 'migration still works' do
# Assuming Settings.sidekiq.routing_rules is [['*', 'default']]
# If routing_rules or Gitlab::SidekiqConfig.worker_queue_mappings changed,
# this spec might be failing. We'll have to adjust the migration or this spec.
expect(queue_length('email_receiver')).to eq(2)
expect(queue_length('default')).to eq(0)
migrate!
expect(queue_length('email_receiver')).to eq(0)
expect(queue_length('default')).to eq(2)
jobs = list_jobs('default')
expect(jobs[0]).to include("class" => "EmailReceiverWorker", "args" => ["bar"])
expect(jobs[1]).to include("class" => "EmailReceiverWorker", "args" => ["foo"])
end
end
context 'with illegal JSON payload' do
let(:job) { '{foo: 1}' }
before do
Sidekiq.redis do |conn|
conn.lpush("queue:email_receiver", job)
end
end
it 'logs an error' do
allow(::Gitlab::BackgroundMigration::Logger).to receive(:build).and_return(Logger.new($stdout))
migrate!
expect($stdout.string).to include("Unmarshal JSON payload from SidekiqMigrateJobs failed. Job: #{job}")
end
end
context 'when run in GitLab.com' do
it 'skips the migration' do
allow(Gitlab).to receive(:com?).and_return(true)
expect(described_class::SidekiqMigrateJobs).not_to receive(:new)
migrate!
end
end
def queue_length(queue_name)
Sidekiq.redis do |conn|
conn.llen("queue:#{queue_name}")
end
end
def list_jobs(queue_name)
Sidekiq.redis { |conn| conn.lrange("queue:#{queue_name}", 0, -1) }
.map { |item| Sidekiq.load_json item }
end
end
end

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe JiraConnect::CorsPreflightChecksController do
shared_examples 'allows cross-origin requests on self managed' do
it 'renders not found' do
options path
expect(response).to have_gitlab_http_status(:not_found)
expect(response.headers['Access-Control-Allow-Origin']).to be_nil
end
context 'with jira_connect_proxy_url setting' do
before do
stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com')
options path, headers: { 'Origin' => 'http://notgitlab.com' }
end
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
it 'responds with access-control-allow headers', :aggregate_failures do
expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq allowed_methods
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
context 'when on GitLab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'renders not found' do
options path
expect(response).to have_gitlab_http_status(:not_found)
expect(response.headers['Access-Control-Allow-Origin']).to be_nil
end
end
end
end
describe 'OPTIONS /-/jira_connect/oauth_application_id' do
let(:allowed_methods) { 'GET, OPTIONS' }
let(:path) { '/-/jira_connect/oauth_application_id' }
it_behaves_like 'allows cross-origin requests on self managed'
end
describe 'OPTIONS /-/jira_connect/subscriptions' do
let(:allowed_methods) { 'GET, POST, OPTIONS' }
let(:path) { '/-/jira_connect/subscriptions' }
it_behaves_like 'allows cross-origin requests on self managed'
end
describe 'OPTIONS /-/jira_connect/subscriptions/:id' do
let(:allowed_methods) { 'DELETE, OPTIONS' }
let(:path) { '/-/jira_connect/subscriptions/123' }
it_behaves_like 'allows cross-origin requests on self managed'
end
end

View File

@ -3,42 +3,12 @@
require 'spec_helper'
RSpec.describe JiraConnect::OauthApplicationIdsController do
describe 'OPTIONS /-/jira_connect/oauth_application_id' do
before do
stub_application_setting(jira_connect_application_key: '123456')
options '/-/jira_connect/oauth_application_id', headers: { 'Origin' => 'http://notgitlab.com' }
end
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
it 'allows cross-origin requests', :aggregate_failures do
expect(response.headers['Access-Control-Allow-Origin']).to eq '*'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, OPTIONS'
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
context 'on GitLab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'renders not found' do
options '/-/jira_connect/oauth_application_id'
expect(response).to have_gitlab_http_status(:not_found)
expect(response.headers['Access-Control-Allow-Origin']).not_to eq '*'
end
end
end
describe 'GET /-/jira_connect/oauth_application_id' do
let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } }
before do
stub_application_setting(jira_connect_application_key: '123456')
stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com')
end
it 'renders the jira connect application id' do
@ -51,7 +21,7 @@ RSpec.describe JiraConnect::OauthApplicationIdsController do
it 'allows cross-origin requests', :aggregate_failures do
get '/-/jira_connect/oauth_application_id', headers: cors_request_headers
expect(response.headers['Access-Control-Allow-Origin']).to eq '*'
expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, OPTIONS'
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end

View File

@ -10,9 +10,16 @@ RSpec.describe JiraConnect::SubscriptionsController do
end
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } }
let(:path) { '/-/jira_connect/subscriptions' }
let(:params) { { jwt: jwt } }
before do
stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com')
end
subject(:content_security_policy) do
get '/-/jira_connect/subscriptions', params: { jwt: jwt }
get path, params: params
response.headers['Content-Security-Policy']
end
@ -21,6 +28,14 @@ RSpec.describe JiraConnect::SubscriptionsController do
it { is_expected.to include('http://self-managed-gitlab.com/api/') }
it { is_expected.to include('http://self-managed-gitlab.com/oauth/') }
it 'allows cross-origin requests', :aggregate_failures do
get path, params: params, headers: cors_request_headers
expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, POST, OPTIONS'
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
context 'with no self-managed instance configured' do
let_it_be(:installation) { create(:jira_connect_installation, instance_url: '') }
@ -39,4 +54,57 @@ RSpec.describe JiraConnect::SubscriptionsController do
it { is_expected.not_to include('http://self-managed-gitlab.com/oauth/') }
end
end
describe 'POST /-/jira_connect/subscriptions' do
let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'http://self-managed-gitlab.com') }
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let(:qsh) do
Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test')
end
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } }
let(:params) { { jwt: jwt, namespace_path: group.path, format: :json } }
before do
group.add_maintainer(user)
sign_in(user)
stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com')
end
it 'allows cross-origin requests', :aggregate_failures do
post '/-/jira_connect/subscriptions', params: params, headers: cors_request_headers
expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, POST, OPTIONS'
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
end
describe 'DELETE /-/jira_connect/subscriptions/:id' do
let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'http://self-managed-gitlab.com') }
let_it_be(:subscription) { create(:jira_connect_subscription, installation: installation) }
let(:qsh) do
Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test')
end
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } }
let(:params) { { jwt: jwt, format: :json } }
before do
stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com')
end
it 'allows cross-origin requests', :aggregate_failures do
delete "/-/jira_connect/subscriptions/#{subscription.id}", params: params, headers: cors_request_headers
expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'DELETE, OPTIONS'
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
end
end

View File

@ -7,6 +7,7 @@ RSpec.describe Oauth::TokensController do
let(:other_headers) { {} }
let(:headers) { cors_request_headers.merge(other_headers) }
let(:allowed_methods) { 'POST, OPTIONS' }
let(:authorization_methods) { %w[Authorization X-CSRF-Token X-Requested-With] }
shared_examples 'cross-origin POST request' do
it 'allows cross-origin requests' do
@ -25,7 +26,7 @@ RSpec.describe Oauth::TokensController do
it 'allows cross-origin requests' do
expect(response.headers['Access-Control-Allow-Origin']).to eq '*'
expect(response.headers['Access-Control-Allow-Methods']).to eq allowed_methods
expect(response.headers['Access-Control-Allow-Headers']).to eq 'Authorization'
expect(response.headers['Access-Control-Allow-Headers']).to eq authorization_methods
expect(response.headers['Access-Control-Allow-Credentials']).to be_nil
end
end
@ -39,7 +40,7 @@ RSpec.describe Oauth::TokensController do
end
describe 'OPTIONS /oauth/token' do
let(:other_headers) { { 'Access-Control-Request-Headers' => 'Authorization', 'Access-Control-Request-Method' => 'POST' } }
let(:other_headers) { { 'Access-Control-Request-Headers' => authorization_methods, 'Access-Control-Request-Method' => 'POST' } }
before do
options '/oauth/token', headers: headers
@ -63,7 +64,7 @@ RSpec.describe Oauth::TokensController do
end
describe 'OPTIONS /oauth/revoke' do
let(:other_headers) { { 'Access-Control-Request-Headers' => 'Authorization', 'Access-Control-Request-Method' => 'POST' } }
let(:other_headers) { { 'Access-Control-Request-Headers' => authorization_methods, 'Access-Control-Request-Method' => 'POST' } }
before do
options '/oauth/revoke', headers: headers

View File

@ -78,6 +78,7 @@ require_relative '../tooling/quality/test_level'
quality_level = Quality::TestLevel.new
RSpec.configure do |config|
config.threadsafe = false
config.use_transactional_fixtures = true
config.use_instantiated_fixtures = false
config.fixture_path = Rails.root

View File

@ -0,0 +1,140 @@
# frozen_string_literal: true
# Set the particular setting as a key-value pair
# Setting method is different depending on klass and must be defined in the calling spec
def stub_email_setting(key_value_pairs)
case setting_name
when :incoming_email
stub_incoming_email_setting(key_value_pairs)
when :service_desk_email
stub_service_desk_email_setting(key_value_pairs)
end
end
RSpec.shared_examples_for 'enabled? method for email' do
using RSpec::Parameterized::TableSyntax
subject { described_class.enabled? }
where(:value, :address, :result) do
false | nil | false
false | 'replies+%{key}@example.com' | false
true | nil | false
true | 'replies+%{key}@example.com' | true
end
with_them do
before do
stub_email_setting(enabled: value)
stub_email_setting(address: address)
end
it { is_expected.to eq result }
end
end
RSpec.shared_examples_for 'supports_wildcard? method for email' do
subject { described_class.supports_wildcard? }
before do
stub_incoming_email_setting(address: value)
end
context 'when address contains the wildcard placeholder' do
let(:value) { 'replies+%{key}@example.com' }
it 'confirms that wildcard is supported' do
expect(subject).to be_truthy
end
end
context "when address doesn't contain the wildcard placeholder" do
let(:value) { 'replies@example.com' }
it 'returns that wildcard is not supported' do
expect(subject).to be_falsey
end
end
context 'when address is nil' do
let(:value) { nil }
it 'returns that wildcard is not supported' do
expect(subject).to be_falsey
end
end
end
RSpec.shared_examples_for 'unsubscribe_address method for email' do
before do
stub_incoming_email_setting(address: 'replies+%{key}@example.com')
end
it 'returns the address with interpolated reply key and unsubscribe suffix' do
expect(described_class.unsubscribe_address('key'))
.to eq("replies+key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}@example.com")
end
end
RSpec.shared_examples_for 'key_from_fallback_message_id method for email' do
it 'returns reply key' do
expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key')
end
end
RSpec.shared_examples_for 'supports_issue_creation? method for email' do
using RSpec::Parameterized::TableSyntax
subject { described_class.supports_issue_creation? }
where(:enabled_value, :supports_wildcard_value, :result) do
false | false | false
false | true | false
true | false | false
true | true | true
end
with_them do
before do
allow(described_class).to receive(:enabled?).and_return(enabled_value)
allow(described_class).to receive(:supports_wildcard?).and_return(supports_wildcard_value)
end
it { is_expected.to eq result }
end
end
RSpec.shared_examples_for 'reply_address method for email' do
before do
stub_incoming_email_setting(address: "replies+%{key}@example.com")
end
it "returns the address with an interpolated reply key" do
expect(described_class.reply_address("key")).to eq("replies+key@example.com")
end
end
RSpec.shared_examples_for 'scan_fallback_references method for email' do
let(:references) do
'<issue_1@localhost>' \
' <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>' \
',<exchange@microsoft.com>'
end
it 'returns reply key' do
expect(described_class.scan_fallback_references(references))
.to eq(%w[issue_1@localhost
reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost
exchange@microsoft.com])
end
end
RSpec.shared_examples_for 'common email methods' do
it_behaves_like 'enabled? method for email'
it_behaves_like 'supports_wildcard? method for email'
it_behaves_like 'key_from_fallback_message_id method for email'
it_behaves_like 'supports_issue_creation? method for email'
it_behaves_like 'reply_address method for email'
it_behaves_like 'unsubscribe_address method for email'
it_behaves_like 'scan_fallback_references method for email'
end