Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-11-13 18:09:11 +00:00
parent 37699393e9
commit feb61d56e7
104 changed files with 1938 additions and 646 deletions

View File

@ -55,10 +55,7 @@ Graphql/ResolverType:
- 'app/graphql/resolvers/merge_requests_resolver.rb' - 'app/graphql/resolvers/merge_requests_resolver.rb'
- 'app/graphql/resolvers/project_merge_requests_resolver.rb' - 'app/graphql/resolvers/project_merge_requests_resolver.rb'
- 'app/graphql/resolvers/project_pipelines_resolver.rb' - 'app/graphql/resolvers/project_pipelines_resolver.rb'
- 'app/graphql/resolvers/projects/snippets_resolver.rb'
- 'app/graphql/resolvers/snippets_resolver.rb'
- 'app/graphql/resolvers/users/group_count_resolver.rb' - 'app/graphql/resolvers/users/group_count_resolver.rb'
- 'app/graphql/resolvers/users/snippets_resolver.rb'
- 'ee/app/graphql/resolvers/ci/jobs_resolver.rb' - 'ee/app/graphql/resolvers/ci/jobs_resolver.rb'
- 'ee/app/graphql/resolvers/geo/merge_request_diff_registries_resolver.rb' - 'ee/app/graphql/resolvers/geo/merge_request_diff_registries_resolver.rb'
- 'ee/app/graphql/resolvers/geo/package_file_registries_resolver.rb' - 'ee/app/graphql/resolvers/geo/package_file_registries_resolver.rb'

View File

@ -1,3 +1,21 @@
import Vue from 'vue';
import initUserInternalRegexPlaceholder from '../account_and_limits'; import initUserInternalRegexPlaceholder from '../account_and_limits';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder()); document.addEventListener('DOMContentLoaded', () => {
initUserInternalRegexPlaceholder();
const gitpodSettingEl = document.querySelector('#js-gitpod-settings-help-text');
if (!gitpodSettingEl) {
return;
}
// eslint-disable-next-line no-new
new Vue({
el: gitpodSettingEl,
name: 'GitpodSettings',
components: {
IntegrationHelpText,
},
});
});

View File

@ -0,0 +1,3 @@
import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle';
document.addEventListener('DOMContentLoaded', initProfilePreferences);

View File

@ -0,0 +1,81 @@
<script>
import { GlFormText, GlIcon, GlLink } from '@gitlab/ui';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
export default {
name: 'IntegrationView',
components: {
GlFormText,
GlIcon,
GlLink,
IntegrationHelpText,
},
inject: ['userFields'],
props: {
helpLink: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
messageUrl: {
type: String,
required: true,
},
config: {
type: Object,
required: true,
},
},
data() {
return {
isEnabled: this.userFields[this.config.formName],
};
},
computed: {
formName() {
return `user[${this.config.formName}]`;
},
formId() {
return `user_${this.config.formName}`;
},
},
};
</script>
<template>
<div>
<label class="label-bold">
{{ config.title }}
</label>
<gl-link class="has-tooltip" title="More information" :href="helpLink">
<gl-icon name="question-o" class="vertical-align-middle" />
</gl-link>
<div class="form-group form-check" data-testid="profile-preferences-integration-form-group">
<!-- Necessary for Rails to receive the value when not checked -->
<input
:name="formName"
type="hidden"
value="0"
data-testid="profile-preferences-integration-hidden-field"
/>
<input
:id="formId"
v-model="isEnabled"
type="checkbox"
class="form-check-input"
:name="formName"
value="1"
data-testid="profile-preferences-integration-checkbox"
/>
<label class="form-check-label" :for="formId">
{{ config.label }}
</label>
<gl-form-text tag="div">
<integration-help-text :message="message" :message-url="messageUrl" />
</gl-form-text>
</div>
</div>
</template>

View File

@ -0,0 +1,56 @@
<script>
import { s__ } from '~/locale';
import IntegrationView from './integration_view.vue';
const INTEGRATION_VIEW_CONFIGS = {
sourcegraph: {
title: s__('ProfilePreferences|Sourcegraph'),
label: s__('ProfilePreferences|Enable integrated code intelligence on code views'),
formName: 'sourcegraph_enabled',
},
gitpod: {
title: s__('ProfilePreferences|Gitpod'),
label: s__('ProfilePreferences|Enable Gitpod integration'),
formName: 'gitpod_enabled',
},
};
export default {
name: 'ProfilePreferences',
components: {
IntegrationView,
},
inject: {
integrationViews: {
default: [],
},
},
integrationViewConfigs: INTEGRATION_VIEW_CONFIGS,
};
</script>
<template>
<div class="row gl-mt-3 js-preferences-form">
<div v-if="integrationViews.length" class="col-sm-12">
<hr data-testid="profile-preferences-integrations-rule" />
</div>
<div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar">
<h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading">
{{ s__('ProfilePreferences|Integrations') }}
</h4>
<p>
{{ s__('ProfilePreferences|Customize integrations with third party services.') }}
</p>
</div>
<div v-if="integrationViews.length" class="col-lg-8">
<integration-view
v-for="view in integrationViews"
:key="view.name"
:help-link="view.help_link"
:message="view.message"
:message-url="view.message_url"
:config="$options.integrationViewConfigs[view.name]"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,23 @@
import Vue from 'vue';
import ProfilePreferences from './components/profile_preferences.vue';
export default () => {
const el = document.querySelector('#js-profile-preferences-app');
const shouldParse = ['integrationViews', 'userFields'];
const provide = Object.keys(el.dataset).reduce((memo, key) => {
let value = el.dataset[key];
if (shouldParse.includes(key)) {
value = JSON.parse(value);
}
return { ...memo, [key]: value };
}, {});
return new Vue({
el,
name: 'ProfilePreferencesApp',
provide,
render: createElement => createElement(ProfilePreferences),
});
};

View File

@ -29,7 +29,6 @@ export default {
'markdownDocsPath', 'markdownDocsPath',
'markdownPreviewPath', 'markdownPreviewPath',
'releasesPagePath', 'releasesPagePath',
'updateReleaseApiDocsPath',
'release', 'release',
'newMilestonePath', 'newMilestonePath',
'manageMilestonesPath', 'manageMilestonesPath',

View File

@ -1,14 +1,14 @@
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import FormFieldContainer from './form_field_container.vue'; import FormFieldContainer from './form_field_container.vue';
export default { export default {
name: 'TagFieldExisting', name: 'TagFieldExisting',
components: { GlFormGroup, GlFormInput, GlSprintf, GlLink, FormFieldContainer }, components: { GlFormGroup, GlFormInput, FormFieldContainer },
computed: { computed: {
...mapState('detail', ['release', 'updateReleaseApiDocsPath']), ...mapState('detail', ['release']),
inputId() { inputId() {
return uniqueId('tag-name-input-'); return uniqueId('tag-name-input-');
}, },
@ -32,19 +32,7 @@ export default {
</form-field-container> </form-field-container>
<template #description> <template #description>
<div :id="helpId" data-testid="tag-name-help"> <div :id="helpId" data-testid="tag-name-help">
<gl-sprintf {{ __("The tag name can't be changed for an existing release.") }}
:message="
__(
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
)
"
>
<template #link="{ content }">
<gl-link :href="updateReleaseApiDocsPath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</div> </div>
</template> </template>
</gl-form-group> </gl-form-group>

View File

@ -5,7 +5,6 @@ export default ({
projectPath, projectPath,
markdownDocsPath, markdownDocsPath,
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath, manageMilestonesPath,
newMilestonePath, newMilestonePath,
@ -20,7 +19,6 @@ export default ({
projectPath, projectPath,
markdownDocsPath, markdownDocsPath,
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath, manageMilestonesPath,
newMilestonePath, newMilestonePath,

View File

@ -83,7 +83,13 @@ export default {
return this.editorMode === EDITOR_TYPES.wysiwyg; return this.editorMode === EDITOR_TYPES.wysiwyg;
}, },
customRenderers() { customRenderers() {
const imageRenderer = renderImage.build(this.mounts, this.project, this.branch, this.baseUrl); const imageRenderer = renderImage.build(
this.mounts,
this.project,
this.branch,
this.baseUrl,
this.$options.imageRepository,
);
return { return {
image: [imageRenderer], image: [imageRenderer],
}; };

View File

@ -12,9 +12,11 @@ const imageRepository = () => {
.catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); .catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
}; };
const get = path => images.get(path);
const getAll = () => images; const getAll = () => images;
return { add, getAll }; return { add, get, getAll };
}; };
export default imageRepository; export default imageRepository;

View File

@ -4,6 +4,8 @@ const canRender = ({ type }) => type === 'image';
let metadata; let metadata;
const getCachedContent = basePath => metadata.imageRepository.get(basePath);
const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/'); const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/');
const extractSourceDirectory = url => { const extractSourceDirectory = url => {
@ -46,7 +48,11 @@ const generateSourceDirectory = basePath => {
return sourceDir || defaultSourceDir; return sourceDir || defaultSourceDir;
}; };
const resolveFullPath = originalSrc => { const resolveFullPath = (originalSrc, cachedContent) => {
if (cachedContent) {
return `data:image;base64,${cachedContent}`;
}
if (isAbsolute(originalSrc)) { if (isAbsolute(originalSrc)) {
return originalSrc; return originalSrc;
} }
@ -61,20 +67,22 @@ const resolveFullPath = originalSrc => {
const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => { const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
skipChildren(); skipChildren();
const cachedContent = getCachedContent(originalSrc);
return { return {
type: 'openTag', type: 'openTag',
tagName: 'img', tagName: 'img',
selfClose: true, selfClose: true,
attributes: { attributes: {
'data-original-src': !isAbsolute(originalSrc) ? originalSrc : '', 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '',
src: resolveFullPath(originalSrc), src: resolveFullPath(originalSrc, cachedContent),
alt: firstChild.literal, alt: firstChild.literal,
}, },
}; };
}; };
const build = (mounts = [], project, branch, baseUrl) => { const build = (mounts = [], project, branch, baseUrl, imageRepository) => {
metadata = { mounts, project, branch, baseUrl }; metadata = { mounts, project, branch, baseUrl, imageRepository };
return { canRender, render }; return { canRender, render };
}; };

View File

@ -0,0 +1,35 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
export default {
name: 'IntegrationsHelpText',
components: {
GlIcon,
GlLink,
GlSprintf,
},
props: {
message: {
type: String,
required: true,
},
messageUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<span>
<gl-sprintf :message="message">
<template #link="{ content }">
<gl-link :href="messageUrl" target="_blank">
{{ content }}
<gl-icon name="external-link" class="gl-vertical-align-middle" :size="12" />
</gl-link>
</template>
</gl-sprintf>
</span>
</template>

View File

@ -2,7 +2,6 @@
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
import { isSafeURL, joinPaths } from '~/lib/utils/url_utility'; import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants'; import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue'; import UploadImageTab from './upload_image_tab.vue';
@ -15,7 +14,6 @@ export default {
GlTabs, GlTabs,
GlTab, GlTab,
}, },
mixins: [glFeatureFlagMixin()],
props: { props: {
imageRoot: { imageRoot: {
type: String, type: String,
@ -34,10 +32,10 @@ export default {
}, },
modalTitle: __('Image details'), modalTitle: __('Image details'),
okTitle: __('Insert image'), okTitle: __('Insert image'),
urlTabTitle: __('By URL'), urlTabTitle: __('Link to an image'),
urlLabel: __('Image URL'), urlLabel: __('Image URL'),
descriptionLabel: __('Description'), descriptionLabel: __('Description'),
uploadTabTitle: __('Upload file'), uploadTabTitle: __('Upload an image'),
computed: { computed: {
altText() { altText() {
return this.description; return this.description;
@ -54,7 +52,7 @@ export default {
this.$refs.modal.show(); this.$refs.modal.show();
}, },
onOk(event) { onOk(event) {
if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event); this.submitFile(event);
return; return;
} }
@ -108,7 +106,7 @@ export default {
:ok-title="$options.okTitle" :ok-title="$options.okTitle"
@ok="onOk" @ok="onOk"
> >
<gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex"> <gl-tabs v-model="tabIndex">
<!-- Upload file Tab --> <!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle"> <gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" /> <upload-image-tab ref="uploadImageTab" @input="setFile" />
@ -128,17 +126,6 @@ export default {
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
<gl-form-group
v-else
class="gl-mt-5 gl-mb-3"
:label="$options.urlLabel"
label-for="url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
<!-- Description Input --> <!-- Description Input -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input"> <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />

View File

@ -114,10 +114,9 @@ export default {
if (file) { if (file) {
this.$emit('uploadImage', { file, imageUrl }); this.$emit('uploadImage', { file, imageUrl });
// TODO - ensure that the actual repo URL for the image is used in Markdown mode
} }
addImage(this.editorInstance, image); addImage(this.editorInstance, image, file);
}, },
onOpenInsertVideoModal() { onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show(); this.$refs.insertVideoModal.show();

View File

@ -34,6 +34,20 @@ const buildVideoIframe = src => {
return wrapper; return wrapper;
}; };
const buildImg = (alt, originalSrc, file) => {
const img = document.createElement('img');
const src = file ? URL.createObjectURL(file) : originalSrc;
const attributes = { alt, src };
if (file) {
img.dataset.originalSrc = originalSrc;
}
Object.assign(img, attributes);
return img;
};
export const generateToolbarItem = config => { export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config; const { icon, classes, event, command, tooltip, isDivider } = config;
@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => {
export const removeCustomEventListener = (editorApi, event, handler) => export const removeCustomEventListener = (editorApi, event, handler) =>
editorApi.eventManager.removeEventHandler(event, handler); editorApi.eventManager.removeEventHandler(event, handler);
export const addImage = ({ editor }, image) => editor.exec('AddImage', image); export const addImage = ({ editor }, { altText, imageUrl }, file) => {
if (editor.isWysiwygMode()) {
const img = buildImg(altText, imageUrl, file);
editor.getSquire().insertElement(img);
} else {
editor.insertText(`![${altText}](${imageUrl})`);
}
};
export const insertVideo = ({ editor }, url) => { export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url); const videoIframe = buildVideoIframe(url);

View File

@ -31,6 +31,10 @@ module NotesActions
# We know there's more data, so tell the frontend to poll again after 1ms # We know there's more data, so tell the frontend to poll again after 1ms
set_polling_interval_header(interval: 1) if meta[:more] set_polling_interval_header(interval: 1) if meta[:more]
# Only present an ETag for the empty response to ensure pagination works
# as expected
::Gitlab::EtagCaching::Middleware.skip!(response) if notes.present?
render json: meta.merge(notes: notes) render json: meta.merge(notes: notes)
end end
@ -115,7 +119,7 @@ module NotesActions
end end
def gather_some_notes def gather_some_notes
paginator = Gitlab::UpdatedNotesPaginator.new( paginator = ::Gitlab::UpdatedNotesPaginator.new(
notes_finder.execute.inc_relations_for_view, notes_finder.execute.inc_relations_for_view,
last_fetched_at: last_fetched_at last_fetched_at: last_fetched_at
) )

View File

@ -34,6 +34,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
environment: environment, environment: environment,
merge_request: @merge_request, merge_request: @merge_request,
diff_view: diff_view, diff_view: diff_view,
merge_ref_head_diff: render_merge_ref_head_diff?,
pagination_data: diffs.pagination_data pagination_data: diffs.pagination_data
} }
@ -67,7 +68,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render: ->(partial, locals) { view_to_html_string(partial, locals) } render: ->(partial, locals) { view_to_html_string(partial, locals) }
} }
options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view) options = additional_attributes.merge(
diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view,
merge_ref_head_diff: render_merge_ref_head_diff?
)
if @merge_request.project.context_commits_enabled? if @merge_request.project.context_commits_enabled?
options[:context_commits] = @merge_request.recent_context_commits options[:context_commits] = @merge_request.recent_context_commits
@ -116,7 +120,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end end
end end
if Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref? if render_merge_ref_head_diff?
return CompareService.new(@project, @merge_request.merge_ref_head.sha) return CompareService.new(@project, @merge_request.merge_ref_head.sha)
.execute(@project, @merge_request.target_branch) .execute(@project, @merge_request.target_branch)
end end
@ -158,6 +162,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request) @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
end end
def render_merge_ref_head_diff?
Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref?
end
def note_positions def note_positions
@note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position)) @note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position))
end end

View File

@ -16,9 +16,6 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:show] prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, only: [:show] before_action :assign_ref_and_path, only: [:show]
before_action :authorize_edit_tree!, only: [:show] before_action :authorize_edit_tree!, only: [:show]
before_action do
push_frontend_feature_flag(:sse_image_uploads)
end
feature_category :static_site_editor feature_category :static_site_editor

View File

@ -0,0 +1,128 @@
# frozen_string_literal: true
# Concern that will eliminate N+1 queries for size-constrained
# collections of items.
#
# **note**: The resolver will never load more items than
# `@field.max_page_size` if defined, falling back to
# `context.schema.default_max_page_size`.
#
# provided that:
#
# - the query can be uniquely determined by the object and the arguments
# - the model class includes FromUnion
# - the model class defines a scalar primary key
#
# This comes at the cost of returning arrays, not relations, so we don't get
# any keyset pagination goodness. Consequently, this is only suitable for small-ish
# result sets, as the full result set will be loaded into memory.
#
# To enforce this, the resolver limits the size of result sets to
# `@field.max_page_size || context.schema.default_max_page_size`.
#
# **important**: If the cardinality of your collection is likely to be greater than 100,
# then you will want to pass `max_page_size:` as part of the field definition
# or (ideally) as part of the resolver `field_options`.
#
# How to implement:
# --------------------
#
# Each including class operates on two generic parameters, A and R:
# - A is any Object that can be used as a Hash key. Instances of A
# are returned by `query_input` and then passed to `query_for`.
# - R is any subclass of ApplicationRecord that includes FromUnion.
# R must have a single scalar primary_key
#
# Classes must implement:
# - #model_class -> Class[R]. (Must respond to :primary_key, and :from_union)
# - #query_input(**kwargs) -> A (Must be hashable)
# - #query_for(A) -> ActiveRecord::Relation[R]
#
# Note the relationship between query_input and query_for, one of which
# consumes the input of the other
# (i.e. `resolve(**args).sync == query_for(query_input(**args)).to_a`).
#
# Classes may implement:
# - #item_found(A, R) (return value is ignored)
# - max_union_size Integer (the maximum number of queries to run in any one union)
module CachingArrayResolver
MAX_UNION_SIZE = 50
def resolve(**args)
key = query_input(**args)
BatchLoader::GraphQL.for(key).batch(**batch) do |keys, loader|
if keys.size == 1
# We can avoid the union entirely.
k = keys.first
limit(query_for(k)).each { |item| found(loader, k, item) }
else
queries = keys.map { |key| query_for(key) }
queries.in_groups_of(max_union_size, false).each do |group|
by_id = model_class
.from_union(tag(group), remove_duplicates: false)
.group_by { |r| r[primary_key] }
by_id.values.each do |item_group|
item = item_group.first
item_group.map(&:union_member_idx).each do |i|
found(loader, keys[i], item)
end
end
end
end
end
end
# Override this to intercept the items once they are found
def item_found(query_input, item)
end
def max_union_size
MAX_UNION_SIZE
end
private
def primary_key
@primary_key ||= (model_class.primary_key || raise("No primary key for #{model_class}"))
end
def batch
{ key: self.class, default_value: [] }
end
def found(loader, key, value)
loader.call(key) do |vs|
item_found(key, value)
vs << value
end
end
# Tag each row returned from each query with a the index of which query in
# the union it comes from. This lets us map the results back to the cache key.
def tag(queries)
queries.each_with_index.map do |q, i|
limit(q.select(all_fields, member_idx(i)))
end
end
def limit(query)
query.limit(query_limit) # rubocop: disable CodeReuse/ActiveRecord
end
def all_fields
model_class.arel_table[Arel.star]
end
# rubocop: disable Graphql/Descriptions (false positive!)
def query_limit
field&.max_page_size.presence || context.schema.default_max_page_size
end
# rubocop: enable Graphql/Descriptions
def member_idx(idx)
::Arel::Nodes::SqlLiteral.new(idx.to_s).as('union_member_idx')
end
end

View File

@ -4,7 +4,7 @@ module ResolvesSnippets
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
type Types::SnippetType, null: false type Types::SnippetType.connection_type, null: false
argument :ids, [::Types::GlobalIDType[::Snippet]], argument :ids, [::Types::GlobalIDType[::Snippet]],
required: false, required: false,

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Graphql/ResolverType
module Resolvers module Resolvers
module Projects module Projects

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Graphql/ResolverType
module Resolvers module Resolvers
class SnippetsResolver < BaseResolver class SnippetsResolver < BaseResolver

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Graphql/ResolverType
module Resolvers module Resolvers
module Users module Users

View File

@ -252,4 +252,18 @@ module DiffHelper
"...#{path[-(max - 3)..-1]}" "...#{path[-(max - 3)..-1]}"
end end
def code_navigation_path(diffs)
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
def conflicts
return unless options[:merge_ref_head_diff]
conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request) # rubocop:disable CodeReuse/ServiceClass
return unless conflicts_service.can_be_resolved_in_ui?
conflicts_service.conflicts.files.index_by(&:our_path)
end
end end

View File

@ -2,9 +2,6 @@
module GitpodHelper module GitpodHelper
def gitpod_enable_description def gitpod_enable_description
link_start = '<a href="https://gitpod.io/" target="_blank" rel="noopener noreferrer">'.html_safe s_('Enable %{linkStart}Gitpod%{linkEnd} integration to launch a development environment in your browser directly from GitLab.')
link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
s_('Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab.').html_safe % { link_start: link_start, link_end: link_end }
end end
end end

View File

@ -82,8 +82,8 @@ module PreferencesHelper
def integration_views def integration_views
[].tap do |views| [].tap do |views|
views << 'gitpod' if Gitlab::Gitpod.feature_and_settings_enabled? views << { name: 'gitpod', message: gitpod_enable_description, message_url: 'https://gitpod.io/', help_link: help_page_path('integration/gitpod.md') } if Gitlab::Gitpod.feature_and_settings_enabled?
views << 'sourcegraph' if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences.md', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
end end
end end

View File

@ -65,7 +65,6 @@ module ReleasesHelper
project_path: @project.full_path, project_path: @project.full_path,
markdown_preview_path: preview_markdown_path(@project), markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'), markdown_docs_path: help_page_path('user/markdown'),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
release_assets_docs_path: help_page(anchor: 'release-assets'), release_assets_docs_path: help_page(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project), manage_milestones_path: project_milestones_path(@project),
new_milestone_path: new_project_milestone_path(@project) new_milestone_path: new_project_milestone_path(@project)

View File

@ -2,26 +2,22 @@
module SourcegraphHelper module SourcegraphHelper
def sourcegraph_url_message def sourcegraph_url_message
link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: Gitlab::CurrentSettings.sourcegraph_url }
link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
message = message =
if Gitlab::CurrentSettings.sourcegraph_url_is_com? if Gitlab::CurrentSettings.sourcegraph_url_is_com?
s_('SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}.').html_safe s_('SourcegraphPreferences|Uses %{linkStart}Sourcegraph.com%{linkEnd}.').html_safe
else else
s_('SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}.').html_safe s_('SourcegraphPreferences|Uses a custom %{linkStart}Sourcegraph instance%{linkEnd}.').html_safe
end end
message % { link_start: link_start, link_end: link_end } experimental_message =
end if Gitlab::Sourcegraph.feature_conditional?
s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.")
elsif Gitlab::CurrentSettings.sourcegraph_public_only
s_("SourcegraphPreferences|This feature is experimental and limited to public projects.")
else
s_("SourcegraphPreferences|This feature is experimental.")
end
def sourcegraph_experimental_message "#{message} #{experimental_message}"
if Gitlab::Sourcegraph.feature_conditional?
s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.")
elsif Gitlab::CurrentSettings.sourcegraph_public_only
s_("SourcegraphPreferences|This feature is experimental and limited to public projects.")
else
s_("SourcegraphPreferences|This feature is experimental.")
end
end end
end end

View File

@ -3,6 +3,7 @@
class DiffFileEntity < DiffFileBaseEntity class DiffFileEntity < DiffFileBaseEntity
include CommitsHelper include CommitsHelper
include IconsHelper include IconsHelper
include Gitlab::Utils::StrongMemoize
expose :added_lines expose :added_lines
expose :removed_lines expose :removed_lines
@ -54,11 +55,16 @@ class DiffFileEntity < DiffFileBaseEntity
# Used for inline diffs # Used for inline diffs
expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options, diff_file) && diff_file.text? } do |diff_file| expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options, diff_file) && diff_file.text? } do |diff_file|
diff_file.diff_lines_for_serializer file = conflict_file(options, diff_file) || diff_file
file.diff_lines_for_serializer
end end
expose :is_fully_expanded do |diff_file| expose :is_fully_expanded do |diff_file|
diff_file.fully_expanded? if conflict_file(options, diff_file)
false
else
diff_file.fully_expanded?
end
end end
# Used for parallel diffs # Used for parallel diffs
@ -79,4 +85,10 @@ class DiffFileEntity < DiffFileBaseEntity
# If nothing is present, inline will be the default. # If nothing is present, inline will be the default.
options.fetch(:diff_view, :inline).to_sym == :inline options.fetch(:diff_view, :inline).to_sym == :inline
end end
def conflict_file(options, diff_file)
strong_memoize(:conflict_file) do
options[:conflicts] && options[:conflicts][diff_file.new_path]
end
end
end end

View File

@ -71,7 +71,7 @@ class DiffsEntity < Grape::Entity
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository) submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
DiffFileEntity.represent(diffs.diff_files, DiffFileEntity.represent(diffs.diff_files,
options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs))) options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs), conflicts: conflicts))
end end
expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs| expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs|
@ -88,10 +88,6 @@ class DiffsEntity < Grape::Entity
private private
def code_navigation_path(diffs)
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
def commit_ids def commit_ids
@commit_ids ||= merge_request.recent_commits.map(&:id) @commit_ids ||= merge_request.recent_commits.map(&:id)
end end

View File

@ -7,6 +7,7 @@
# #
class PaginatedDiffEntity < Grape::Entity class PaginatedDiffEntity < Grape::Entity
include RequestAwareEntity include RequestAwareEntity
include DiffHelper
expose :diff_files do |diffs, options| expose :diff_files do |diffs, options|
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository) submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
@ -15,7 +16,8 @@ class PaginatedDiffEntity < Grape::Entity
diffs.diff_files, diffs.diff_files,
options.merge( options.merge(
submodule_links: submodule_links, submodule_links: submodule_links,
code_navigation_path: code_navigation_path(diffs) code_navigation_path: code_navigation_path(diffs),
conflicts: conflicts
) )
) )
end end
@ -41,10 +43,6 @@ class PaginatedDiffEntity < Grape::Entity
private private
def code_navigation_path(diffs)
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
%i[current_page next_page total_pages].each do |method| %i[current_page next_page total_pages].each do |method|
define_method method do define_method method do
pagination_data[method] pagination_data[method]

View File

@ -17,6 +17,7 @@ module Users
user.accept_pending_invitations! if user.active_for_authentication? user.accept_pending_invitations! if user.active_for_authentication?
DeviseMailer.user_admin_approval(user).deliver_later DeviseMailer.user_admin_approval(user).deliver_later
after_approve_hook(user)
success success
else else
error(user.errors.full_messages.uniq.join('. ')) error(user.errors.full_messages.uniq.join('. '))
@ -27,6 +28,10 @@ module Users
attr_reader :current_user attr_reader :current_user
def after_approve_hook(user)
# overridden by EE module
end
def allowed? def allowed?
can?(current_user, :approve_user) can?(current_user, :approve_user)
end end
@ -36,3 +41,5 @@ module Users
end end
end end
end end
Users::ApproveService.prepend_if_ee('EE::Users::ApproveService')

View File

@ -8,7 +8,7 @@
%button.btn.btn-default.js-settings-toggle{ type: 'button' } %button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand') = expanded ? _('Collapse') : _('Expand')
%p %p
= gitpod_enable_description %integration-help-text{ "id" => "js-gitpod-settings-help-text", "message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" }
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information') = link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')

View File

@ -2,5 +2,9 @@
- add_page_specific_style 'page_bundles/signup' - add_page_specific_style 'page_bundles/signup'
.signup-page .signup-page
= render 'devise/shared/signup_box', url: registration_path(resource_name), button_text: _('Register'), show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? = render 'devise/shared/signup_box',
url: registration_path(resource_name),
button_text: _('Register'),
show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?,
suggestion_path: nil
= render 'devise/shared/sign_in_link' = render 'devise/shared/sign_in_link'

View File

@ -1,9 +0,0 @@
%label.label-bold#gitpod
= s_('Gitpod')
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
.form-group.form-check
= f.check_box :gitpod_enabled, class: 'form-check-input'
= f.label :gitpod_enabled, class: 'form-check-label' do
= s_('Gitpod|Enable Gitpod integration').html_safe
.form-text.text-muted
= gitpod_enable_description

View File

@ -1,18 +0,0 @@
- views = integration_views
- return unless views.any?
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#integrations
%h4.gl-mt-0
= s_('Preferences|Integrations')
%p
= s_('Preferences|Customize integrations with third party services.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank'
.col-lg-8
- views.each do |view|
= render view, f: f

View File

@ -1,10 +0,0 @@
%label.label-bold
= s_('Preferences|Sourcegraph')
= link_to sprite_icon('question-o'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information')
.form-group.form-check
= f.check_box :sourcegraph_enabled, class: 'form-check-input'
= f.label :sourcegraph_enabled, class: 'form-check-label' do
= s_('Preferences|Enable integrated code intelligence on code views').html_safe
.form-text.text-muted
= sourcegraph_url_message
= sourcegraph_experimental_message

View File

@ -1,146 +1,151 @@
- page_title _('Preferences') - page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- user_fields = { gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }
- user_theme_id = Gitlab::Themes.for_user(@user).id
- data_attributes = { integration_views: integration_views.to_json, user_fields: user_fields.to_json }
- Gitlab::Themes.each do |theme| - Gitlab::Themes.each do |theme|
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f| = form_for @user, url: profile_preferences_path, remote: true, method: :put do |f|
.col-lg-4.application-theme#navigation-theme .row.gl-mt-3.js-preferences-form
%h4.gl-mt-0 .col-lg-4.application-theme#navigation-theme
= s_('Preferences|Navigation theme') %h4.gl-mt-0
%p = s_('Preferences|Navigation theme')
= s_('Preferences|Customize the appearance of the application header and navigation sidebar.') %p
.col-lg-8.application-theme = s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
.row .col-lg-8.application-theme
- Gitlab::Themes.each do |theme| .row
%label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center - Gitlab::Themes.each do |theme|
.preview{ class: theme.css_class } %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center
= f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id .preview{ class: theme.css_class }
= theme.name = f.radio_button :theme_id, theme.id, checked: user_theme_id == theme.id
= theme.name
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#syntax-highlighting-theme
%h4.gl-mt-0
= s_('Preferences|Syntax highlighting theme')
%p
= s_('Preferences|This setting allows you to customize the appearance of the syntax.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
.col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
.preview= image_tag "#{scheme.css_class}-scheme-preview.png"
= f.radio_button :color_scheme_id, scheme.id
= scheme.name
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#behavior
%h4.gl-mt-0
= s_('Preferences|Behavior')
%p
= s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
.col-lg-8
.form-group
= f.label :layout, class: 'label-bold' do
= s_('Preferences|Layout width')
= f.select :layout, layout_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
.form-group
= f.label :dashboard, class: 'label-bold' do
= s_('Preferences|Homepage content')
= f.select :dashboard, dashboard_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on your homepage.')
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
.form-group
= f.label :project_view, class: 'label-bold' do
= s_('Preferences|Project overview content')
= f.select :project_view, project_view_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a projects overview page.')
.form-group.form-check
= f.check_box :render_whitespace_in_code, class: 'form-check-input'
= f.label :render_whitespace_in_code, class: 'form-check-label' do
= s_('Preferences|Render whitespace characters in the Web IDE')
.form-group.form-check
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
= s_('Preferences|Show whitespace changes in diffs')
- if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true)
.form-group.form-check
= f.check_box :view_diffs_file_by_file, class: 'form-check-input'
= f.label :view_diffs_file_by_file, class: 'form-check-label' do
= s_("Preferences|Show one file at a time on merge request's Changes tab")
.form-text.text-muted
= s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
= f.number_field :tab_width,
class: 'form-control',
min: Gitlab::TabWidth::MIN,
max: Gitlab::TabWidth::MAX,
required: true
.form-text.text-muted
= s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#localization
%h4.gl-mt-0
= _('Localization')
%p
= _('Customize language and region related settings.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
.col-lg-8
.form-group
= f.label :preferred_language, class: 'label-bold' do
= _('Language')
= f.select :preferred_language, language_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|This feature is experimental and translations are not complete yet')
.form-group
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2'
- if Feature.enabled?(:user_time_settings)
.col-sm-12 .col-sm-12
%hr %hr
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0= s_('Preferences|Time preferences') .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme
%p= s_('Preferences|These settings will update how dates and times are displayed for you.') %h4.gl-mt-0
= s_('Preferences|Syntax highlighting theme')
%p
= s_('Preferences|This setting allows you to customize the appearance of the syntax.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
.col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
.preview= image_tag "#{scheme.css_class}-scheme-preview.png"
= f.radio_button :color_scheme_id, scheme.id
= scheme.name
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar#behavior
%h4.gl-mt-0
= s_('Preferences|Behavior')
%p
= s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
.col-lg-8 .col-lg-8
.form-group .form-group
%h5= s_('Preferences|Time format') = f.label :layout, class: 'label-bold' do
.checkbox-icon-inline-wrapper = s_('Preferences|Layout width')
- time_format_label = capture do = f.select :layout, layout_choices, {}, class: 'select2'
= s_('Preferences|Display time in 24-hour format') .form-text.text-muted
= f.check_box :time_format_in_24h = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
= f.label :time_format_in_24h do .form-group
= time_format_label = f.label :dashboard, class: 'label-bold' do
%h5= s_('Preferences|Time display') = s_('Preferences|Homepage content')
.checkbox-icon-inline-wrapper = f.select :dashboard, dashboard_choices, {}, class: 'select2'
- time_display_label = capture do .form-text.text-muted
= s_('Preferences|Use relative times') = s_('Preferences|Choose what content you want to see on your homepage.')
= f.check_box :time_display_relative
= f.label :time_display_relative do = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
= time_display_label
.form-group
= f.label :project_view, class: 'label-bold' do
= s_('Preferences|Project overview content')
= f.select :project_view, project_view_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a projects overview page.')
.form-group.form-check
= f.check_box :render_whitespace_in_code, class: 'form-check-input'
= f.label :render_whitespace_in_code, class: 'form-check-label' do
= s_('Preferences|Render whitespace characters in the Web IDE')
.form-group.form-check
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
= s_('Preferences|Show whitespace changes in diffs')
- if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true)
.form-group.form-check
= f.check_box :view_diffs_file_by_file, class: 'form-check-input'
= f.label :view_diffs_file_by_file, class: 'form-check-label' do
= s_("Preferences|Show one file at a time on merge request's Changes tab")
.form-text.text-muted .form-text.text-muted
= s_('Preferences|For example: 30 mins ago.') = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
= f.number_field :tab_width,
class: 'form-control',
min: Gitlab::TabWidth::MIN,
max: Gitlab::TabWidth::MAX,
required: true
.form-text.text-muted
= s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
= render 'integrations', f: f .col-sm-12
%hr
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar#localization
.col-lg-8 %h4.gl-mt-0
.form-group = _('Localization')
= f.submit _('Save changes'), class: 'gl-button btn btn-success' %p
= _('Customize language and region related settings.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
.col-lg-8
.form-group
= f.label :preferred_language, class: 'label-bold' do
= _('Language')
= f.select :preferred_language, language_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|This feature is experimental and translations are not complete yet')
.form-group
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2'
- if Feature.enabled?(:user_time_settings)
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0= s_('Preferences|Time preferences')
%p= s_('Preferences|These settings will update how dates and times are displayed for you.')
.col-lg-8
.form-group
%h5= s_('Preferences|Time format')
.checkbox-icon-inline-wrapper
- time_format_label = capture do
= s_('Preferences|Display time in 24-hour format')
= f.check_box :time_format_in_24h
= f.label :time_format_in_24h do
= time_format_label
%h5= s_('Preferences|Time display')
.checkbox-icon-inline-wrapper
- time_display_label = capture do
= s_('Preferences|Use relative times')
= f.check_box :time_display_relative
= f.label :time_display_relative do
= time_display_label
.form-text.text-muted
= s_('Preferences|For example: 30 mins ago.')
#js-profile-preferences-app{ data: data_attributes, user_fields: user_fields.to_json }
.row.gl-mt-3.js-preferences-form
.col-lg-4.profile-settings-sidebar
.col-lg-8
.form-group
= f.submit _('Save changes'), class: 'gl-button btn btn-success'

View File

@ -20,7 +20,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
changes = Base64.decode64(changes) unless changes.include?(' ') changes = Base64.decode64(changes) unless changes.include?(' ')
# Use Sidekiq.logger so arguments can be correlated with execution # Use Sidekiq.logger so arguments can be correlated with execution
# time and thread ID's. # time and thread ID's.
Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] Sidekiq.logger.info "changes: #{changes.inspect}" if SidekiqLogArguments.enabled?
post_received = Gitlab::GitPostReceive.new(container, identifier, changes, push_options) post_received = Gitlab::GitPostReceive.new(container, identifier, changes, push_options)
if repo_type.wiki? if repo_type.wiki?

View File

@ -0,0 +1,5 @@
---
title: Only set an ETag for the notes endpoint after all notes have been sent
merge_request: 46810
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Enable the ability to upload images via the SSE
merge_request: 36299
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add type annotation for snippet resolvers
merge_request: 47548
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Update the tag name field helper text on the Edit Release page
merge_request: 47234
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Enable Sidekiq argument logging by default
merge_request: 44853
author:
type: changed

View File

@ -1,7 +1,7 @@
--- ---
name: display_merge_conflicts_in_diff name: display_merge_conflicts_in_diff
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45008 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45008
rollout_issue_url: rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/277097
milestone: '13.5' milestone: '13.5'
type: development type: development
group: group::source code group: group::source code

View File

@ -1,4 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
module SidekiqLogArguments
def self.enabled?
Gitlab::Utils.to_boolean(ENV['SIDEKIQ_LOG_ARGUMENTS'], default: true)
end
end
def enable_reliable_fetch? def enable_reliable_fetch?
return true unless Feature::FlipperFeature.table_exists? return true unless Feature::FlipperFeature.table_exists?
@ -35,7 +40,7 @@ Sidekiq.configure_server do |config|
config.server_middleware(&Gitlab::SidekiqMiddleware.server_configurator({ config.server_middleware(&Gitlab::SidekiqMiddleware.server_configurator({
metrics: Settings.monitoring.sidekiq_exporter, metrics: Settings.monitoring.sidekiq_exporter,
arguments_logger: ENV['SIDEKIQ_LOG_ARGUMENTS'] && !enable_json_logs, arguments_logger: SidekiqLogArguments.enabled? && !enable_json_logs,
memory_killer: enable_sidekiq_memory_killer && use_sidekiq_legacy_memory_killer memory_killer: enable_sidekiq_memory_killer && use_sidekiq_legacy_memory_killer
})) }))

View File

@ -99,6 +99,7 @@ From there, you can see the following actions:
- Number of required approvals was updated ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7531) in GitLab 12.9) - Number of required approvals was updated ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7531) in GitLab 12.9)
- Added or removed users and groups from project approval groups ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213603) in GitLab 13.2) - Added or removed users and groups from project approval groups ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213603) in GitLab 13.2)
- Project CI/CD variable added, removed, or protected status changed ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30857) in GitLab 13.4) - Project CI/CD variable added, removed, or protected status changed ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30857) in GitLab 13.4)
- User was approved via Admin Area ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276250) in GitLab 13.6)
Project events can also be accessed via the [Project Audit Events API](../api/audit_events.md#project-audit-events). Project events can also be accessed via the [Project Audit Events API](../api/audit_events.md#project-audit-events).

View File

@ -856,7 +856,9 @@ This file is stored in:
- `/var/log/gitlab/gitlab-rails/update_mirror_service_json.log` for Omnibus GitLab installations. - `/var/log/gitlab/gitlab-rails/update_mirror_service_json.log` for Omnibus GitLab installations.
- `/home/git/gitlab/log/update_mirror_service_json.log` for installations from source. - `/home/git/gitlab/log/update_mirror_service_json.log` for installations from source.
This file contains information about any errors that occurred during project mirroring. This file contains information about LFS errors that occurred during project mirroring.
While we work to move other project mirroring errors into this log, the [general log](#productionlog)
can be used.
```json ```json
{ {

View File

@ -26,19 +26,11 @@ preventing other threads from continuing.
## Log arguments to Sidekiq jobs ## Log arguments to Sidekiq jobs
If you want to see what arguments are being passed to Sidekiq jobs you can set [In GitLab 13.6 and later](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44853)
the `SIDEKIQ_LOG_ARGUMENTS` [environment variable](https://docs.gitlab.com/omnibus/settings/environment-variables.html) to `1` (true). some arguments passed to Sidekiq jobs are logged by default.
To avoid logging sensitive information (for instance, password reset tokens),
Example: GitLab logs numeric arguments for all workers, with overrides for some specific
workers where their arguments are not sensitive.
```ruby
gitlab_rails['env'] = {"SIDEKIQ_LOG_ARGUMENTS" => "1"}
```
This does not log all job arguments. To avoid logging sensitive
information (for instance, password reset tokens), it logs numeric
arguments for all workers, with overrides for some specific workers
where their arguments are not sensitive.
Example log output: Example log output:
@ -53,6 +45,17 @@ arguments logs are limited to a maximum size of 10 kilobytes of text;
any arguments after this limit will be discarded and replaced with a any arguments after this limit will be discarded and replaced with a
single argument containing the string `"..."`. single argument containing the string `"..."`.
You can set `SIDEKIQ_LOG_ARGUMENTS` [environment variable](https://docs.gitlab.com/omnibus/settings/environment-variables.html)
to `0` (false) to disable argument logging.
Example:
```ruby
gitlab_rails['env'] = {"SIDEKIQ_LOG_ARGUMENTS" => "0"}
```
In GitLab 13.5 and earlier, set `SIDEKIQ_LOG_ARGUMENTS` to `1` to start logging arguments passed to Sidekiq.
## Thread dump ## Thread dump
Send the Sidekiq process ID the `TTIN` signal and it will output thread Send the Sidekiq process ID the `TTIN` signal and it will output thread

View File

@ -1904,6 +1904,16 @@ input BoardIssueInput {
""" """
epicWildcardId: EpicWildcardId epicWildcardId: EpicWildcardId
"""
Filter by iteration title
"""
iterationTitle: String
"""
Filter by iteration ID wildcard
"""
iterationWildcardId: IterationWildcardId
""" """
Filter by label name Filter by label name
""" """
@ -11239,11 +11249,6 @@ enum IssueType {
Represents an iteration object Represents an iteration object
""" """
type Iteration implements TimeboxReportInterface { type Iteration implements TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
""" """
Timestamp of iteration creation Timestamp of iteration creation
""" """
@ -11371,6 +11376,21 @@ enum IterationState {
upcoming upcoming
} }
"""
Iteration ID wildcard values
"""
enum IterationWildcardId {
"""
An iteration is assigned
"""
ANY
"""
No iteration is assigned
"""
NONE
}
""" """
Represents untyped JSON Represents untyped JSON
""" """
@ -13314,11 +13334,6 @@ type MetricsDashboardAnnotationEdge {
Represents a milestone Represents a milestone
""" """
type Milestone implements TimeboxReportInterface { type Milestone implements TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
""" """
Timestamp of milestone creation Timestamp of milestone creation
""" """
@ -13883,6 +13898,11 @@ input NegatedBoardIssueInput {
""" """
epicId: EpicID epicId: EpicID
"""
Filter by iteration title
"""
iterationTitle: String
""" """
Filter by label name Filter by label name
""" """
@ -20948,11 +20968,6 @@ type TimeboxReport {
} }
interface TimeboxReportInterface { interface TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
""" """
Historically accurate report about the timebox Historically accurate report about the timebox
""" """

View File

@ -5111,6 +5111,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "iterationTitle",
"description": "Filter by iteration title",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "weight", "name": "weight",
"description": "Filter by weight", "description": "Filter by weight",
@ -5150,6 +5160,16 @@
"ofType": null "ofType": null
}, },
"defaultValue": null "defaultValue": null
},
{
"name": "iterationWildcardId",
"description": "Filter by iteration ID wildcard",
"type": {
"kind": "ENUM",
"name": "IterationWildcardId",
"ofType": null
},
"defaultValue": null
} }
], ],
"interfaces": null, "interfaces": null,
@ -30696,28 +30716,6 @@
"name": "Iteration", "name": "Iteration",
"description": "Represents an iteration object", "description": "Represents an iteration object",
"fields": [ "fields": [
{
"name": "burnupTimeSeries",
"description": "Daily scope and completed totals for burnup charts",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "createdAt", "name": "createdAt",
"description": "Timestamp of iteration creation", "description": "Timestamp of iteration creation",
@ -31135,6 +31133,29 @@
], ],
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "ENUM",
"name": "IterationWildcardId",
"description": "Iteration ID wildcard values",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "NONE",
"description": "No iteration is assigned",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ANY",
"description": "An iteration is assigned",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "SCALAR", "kind": "SCALAR",
"name": "JSON", "name": "JSON",
@ -36633,28 +36654,6 @@
"name": "Milestone", "name": "Milestone",
"description": "Represents a milestone", "description": "Represents a milestone",
"fields": [ "fields": [
{
"name": "burnupTimeSeries",
"description": "Daily scope and completed totals for burnup charts",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "createdAt", "name": "createdAt",
"description": "Timestamp of milestone creation", "description": "Timestamp of milestone creation",
@ -41116,6 +41115,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "iterationTitle",
"description": "Filter by iteration title",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "weight", "name": "weight",
"description": "Filter by weight", "description": "Filter by weight",
@ -60920,28 +60929,6 @@
"name": "TimeboxReportInterface", "name": "TimeboxReportInterface",
"description": null, "description": null,
"fields": [ "fields": [
{
"name": "burnupTimeSeries",
"description": "Daily scope and completed totals for burnup charts",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "report", "name": "report",
"description": "Historically accurate report about the timebox", "description": "Historically accurate report about the timebox",

View File

@ -1719,7 +1719,6 @@ Represents an iteration object.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `burnupTimeSeries` | BurnupChartDailyTotals! => Array | Daily scope and completed totals for burnup charts |
| `createdAt` | Time! | Timestamp of iteration creation | | `createdAt` | Time! | Timestamp of iteration creation |
| `description` | String | Description of the iteration | | `description` | String | Description of the iteration |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
@ -2042,7 +2041,6 @@ Represents a milestone.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `burnupTimeSeries` | BurnupChartDailyTotals! => Array | Daily scope and completed totals for burnup charts |
| `createdAt` | Time! | Timestamp of milestone creation | | `createdAt` | Time! | Timestamp of milestone creation |
| `description` | String | Description of the milestone | | `description` | String | Description of the milestone |
| `dueDate` | Time | Timestamp of the milestone due date | | `dueDate` | Time | Timestamp of the milestone due date |
@ -3994,6 +3992,15 @@ State of a GitLab iteration.
| `started` | | | `started` | |
| `upcoming` | | | `upcoming` | |
### IterationWildcardId
Iteration ID wildcard values.
| Value | Description |
| ----- | ----------- |
| `ANY` | An iteration is assigned |
| `NONE` | No iteration is assigned |
### ListLimitMetric ### ListLimitMetric
List limit metric setting. List limit metric setting.

View File

@ -1927,6 +1927,38 @@ The returned `url` is relative to the project path. The returned `full_path` is
the absolute path to the file. In Markdown contexts, the link is expanded when the absolute path to the file. In Markdown contexts, the link is expanded when
the format in `markdown` is used. the format in `markdown` is used.
## Upload a project avatar
Uploads an avatar to the specified project.
```plaintext
PUT /projects/:id
```
| Attribute | Type | Required | Description |
|-----------|----------------|------------------------|-------------|
| `avatar` | string | **{check-circle}** Yes | The file to be uploaded. |
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
To upload an avatar from your file system, use the `--form` argument. This causes
cURL to post data using the header `Content-Type: multipart/form-data`. The
`file=` parameter must point to an image file on your file system and be
preceded by `@`. For example:
Example request:
```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "avatar=@dk.png" "https://gitlab.example.com/api/v4/projects/5"
```
Returned object:
```json
{
"avatar_url": "https://gitlab.example.com/uploads/-/system/project/avatar/2/dk.png"
}
```
## Share project with group ## Share project with group
Allow to share project with group. Allow to share project with group.

View File

@ -284,11 +284,14 @@ When running your project pipeline at this point:
#### Custom build job for Auto DevOps #### Custom build job for Auto DevOps
To leverage [Auto DevOps](../../topics/autodevops/index.md) for your project when deploying to > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216008) in GitLab 13.6.
AWS EC2, you must specify a job for the `build` stage.
To do so, you must reference the `Auto-DevOps.gitlab-ci.yml` template and include a job named To leverage [Auto DevOps](../../topics/autodevops/index.md) for your project when deploying to
`build_artifact` in your `.gitlab-ci.yml` file. For example: AWS EC2, first you must define [your AWS credentials as environment variables](#run-aws-commands-from-gitlab-cicd).
Next, define a job for the `build` stage. To do so, you must reference the
`Auto-DevOps.gitlab-ci.yml` template and include a job named `build_artifact` in your
`.gitlab-ci.yml` file. For example:
```yaml ```yaml
# .gitlab-ci.yml # .gitlab-ci.yml

View File

@ -447,6 +447,23 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
- `end_date`: end date of the period for which we want to get event data. - `end_date`: end date of the period for which we want to get event data.
- `context`: context of the event. Allowed values are `default`, `free`, `bronze`, `silver`, `gold`, `starter`, `premium`, `ultimate`. - `context`: context of the event. Allowed values are `default`, `free`, `bronze`, `silver`, `gold`, `starter`, `premium`, `ultimate`.
1. Testing tracking and getting unique events
Trigger events in rails console by using `track_event` method
```ruby
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(1, 'g_compliance_audit_events')
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(2, 'g_compliance_audit_events')
```
Next, get the unique events for the current week.
```ruby
# Get unique events for metric for current_week
Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: 'g_compliance_audit_events',
start_date: Date.current.beginning_of_week, end_date: Date.current.end_of_week)
```
Recommendations: Recommendations:
- Key should expire in 29 days for daily and 42 days for weekly. - Key should expire in 29 days for daily and 42 days for weekly.

View File

@ -696,8 +696,8 @@ blocks:
## Arguments logging ## Arguments logging
When [`SIDEKIQ_LOG_ARGUMENTS`](../administration/troubleshooting/sidekiq.md#log-arguments-to-sidekiq-jobs) As of GitLab 13.6, Sidekiq job arguments will be logged by default, unless [`SIDEKIQ_LOG_ARGUMENTS`](../administration/troubleshooting/sidekiq.md#log-arguments-to-sidekiq-jobs)
is enabled, Sidekiq job arguments will be logged. is disabled.
By default, the only arguments logged are numeric arguments, because By default, the only arguments logged are numeric arguments, because
arguments of other types could contain sensitive information. To arguments of other types could contain sensitive information. To

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -5,50 +5,29 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference, concepts type: reference, concepts
--- ---
# Instance-level merge request approval rules **(PREMIUM ONLY)** # Merge request approval rules **(PREMIUM ONLY)**
> Introduced in [GitLab Premium](https://gitlab.com/gitlab-org/gitlab/-/issues/39060) 12.8. > Introduced in [GitLab Premium](https://gitlab.com/gitlab-org/gitlab/-/issues/39060) 12.8.
Merge request approvals rules prevent users overriding certain settings on a project Merge request approval rules prevent users from overriding certain settings on the project
level. When configured, only administrators can change these settings on a project level level. When enabled at the instance level, these settings are no longer editable on the
if they are enabled at an instance level. project level.
To enable merge request approval rules for an instance: To enable merge request approval rules for an instance:
1. Navigate to **Admin Area >** **{push-rules}** **Push Rules** and expand **Merge 1. Navigate to **Admin Area >** **{push-rules}** **Push Rules** and expand **Merge
requests approvals**. requests approvals**.
1. Set the required rule. 1. Set the required rule.
1. Click **Save changes**. 1. Click **Save changes**.
GitLab administrators can later override these settings in a projects settings.
## Available rules ## Available rules
Merge request approval rules that can be set at an instance level are: Merge request approval rules that can be set at an instance level are:
- **Prevent approval of merge requests by merge request author**. Prevents project - **Prevent approval of merge requests by merge request author**. Prevents project
maintainers from allowing request authors to merge their own merge requests. maintainers from allowing request authors to merge their own merge requests.
- **Prevent approval of merge requests by merge request committers**. Prevents project - **Prevent approval of merge requests by merge request committers**. Prevents project
maintainers from allowing users to approve merge requests if they have submitted maintainers from allowing users to approve merge requests if they have submitted
any commits to the source branch. any commits to the source branch.
- **Can override approvers and approvals required per merge request**. Allows project - **Prevent users from modifying merge request approvers list**. Prevents users from
maintainers to modify the approvers list in individual merge requests. modifying the approvers list in project settings or in individual merge requests.
## Scope rules to compliance-labeled projects
> Introduced in [GitLab Premium](https://gitlab.com/groups/gitlab-org/-/epics/3432) 13.2.
Merge request approval rules can be further scoped to specific compliance frameworks.
When the compliance framework label is selected and the project is assigned the compliance
label, the instance-level MR approval settings will take effect and the
[project-level settings](../project/merge_requests/merge_request_approvals.md#adding--editing-a-default-approval-rule)
is locked for modification.
When the compliance framework label is not selected or the project is not assigned the
compliance label, the project-level MR approval settings will take effect and the users with
Maintainer role and above can modify these.
| Instance-level | Project-level |
| -------------- | ------------- |
| ![Scope MR approval settings to compliance frameworks](img/scope_mr_approval_settings_v13_5.png) | ![MR approval settings on compliance projects](img/mr_approval_settings_compliance_project_v13_5.png) |

View File

@ -68,6 +68,7 @@ project to make sure it complies with the separation of duties described above.
The Chain of Custody report allows customers to export a list of merge commits within the group. The Chain of Custody report allows customers to export a list of merge commits within the group.
The data provides a comprehensive view with respect to merge commits. It includes the merge commit SHA, The data provides a comprehensive view with respect to merge commits. It includes the merge commit SHA,
merge request author, merge request ID, merge user, pipeline ID, group name, project name, and merge request approvers. merge request author, merge request ID, merge user, pipeline ID, group name, project name, and merge request approvers.
Depending on the merge strategy, the merge commit SHA can either be a merge commit, squash commit or a diff head commit.
To download the Chain of Custody report, navigate to **{shield}** **Security & Compliance > Compliance** on the group's menu and click **List of all merge commits** To download the Chain of Custody report, navigate to **{shield}** **Security & Compliance > Compliance** on the group's menu and click **List of all merge commits**

View File

@ -432,7 +432,7 @@ and the following environment variables:
| `SIDEKIQ_MEMORY_KILLER_CHECK_INTERVAL` | - | `3` | | `SIDEKIQ_MEMORY_KILLER_CHECK_INTERVAL` | - | `3` |
| `SIDEKIQ_MEMORY_KILLER_GRACE_TIME` | - | `900` | | `SIDEKIQ_MEMORY_KILLER_GRACE_TIME` | - | `900` |
| `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT` | - | `30` | | `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT` | - | `30` |
| `SIDEKIQ_LOG_ARGUMENTS` | `1` | - | | `SIDEKIQ_LOG_ARGUMENTS` | `1` | `1` |
NOTE: **Note:** NOTE: **Note:**
The `SIDEKIQ_MEMORY_KILLER_MAX_RSS` setting is `16000000` on Sidekiq import The `SIDEKIQ_MEMORY_KILLER_MAX_RSS` setting is `16000000` on Sidekiq import

View File

@ -240,6 +240,14 @@ For users without permissions to view the project's code:
- The wiki homepage is displayed, if any. - The wiki homepage is displayed, if any.
- The list of issues within the project is displayed. - The list of issues within the project is displayed.
## GitLab Workflow - VS Code extension
To avoid switching from the GitLab UI and VS Code while working in GitLab repositories, you can integrate
the [VS Code](https://code.visualstudio.com/) editor with GitLab through the
[GitLab Workflow extension](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow).
To review or contribute to the extension's code, visit [its codebase in GitLab](https://gitlab.com/gitlab-org/gitlab-vscode-extension/).
## Redirects when changing repository paths ## Redirects when changing repository paths
When a repository path changes, it is essential to smoothly transition from the When a repository path changes, it is essential to smoothly transition from the

View File

@ -107,13 +107,36 @@ The Static Site Editors supports Markdown files (`.md`, `.md.erb`) for editing t
### Images ### Images
> - Support for adding images through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216640) in GitLab 13.1. > - Support for adding images through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216640) in GitLab 13.1.
> - Support for uploading images via the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218529) in GitLab 13.6.
You can add image files on the WYSIWYG mode by clicking the image icon (**{doc-image}**). #### Upload an image
From there, link to a URL, add optional [ALT text](https://moz.com/learn/seo/alt-text),
and you're done. The link can reference images already hosted in your project, an asset hosted You can upload image files via the WYSIWYG editor directly to the repository to default upload directory
`source/images`. To do so:
1. Click the image icon (**{doc-image}**).
1. Choose the **Upload file** tab.
1. Click **Choose file** to select a file from your computer.
1. Optional: add a description to the image for SEO and accessibility ([ALT text](https://moz.com/learn/seo/alt-text)).
1. Click **Insert image**.
The selected file can be any supported image file (`.png`, `.jpg`, `.jpeg`, `.gif`). The editor renders
thumbnail previews so you can verify the correct image is included and there aren't any references to
missing images.
#### Link to an image
You can also link to an image if you'd like:
1. Click the image icon (**{doc-image}**).
1. Choose the **Link to an image** tab.
1. Add the link to the image into the **Image URL** field (use the full path; relative paths are not supported yet).
1. Optional: add a description to the image for SEO and accessibility ([ALT text](https://moz.com/learn/seo/alt-text)).
1. Click **Insert image**.
The link can reference images already hosted in your project, an asset hosted
externally on a content delivery network, or any other external URL. The editor renders thumbnail previews externally on a content delivery network, or any other external URL. The editor renders thumbnail previews
so you can verify the correct image is included and there aren't any references to missing images. so you can verify the correct image is included and there aren't any references to missing images.
default directory (`source/images/`).
### Videos ### Videos

View File

@ -9,6 +9,11 @@ module Gitlab
CONTEXT_LINES = 3 CONTEXT_LINES = 3
CONFLICT_TYPES = {
"old" => "conflict_marker_their",
"new" => "conflict_marker_our"
}.freeze
attr_reader :merge_request attr_reader :merge_request
# 'raw' holds the Gitlab::Git::Conflict::File that this instance wraps # 'raw' holds the Gitlab::Git::Conflict::File that this instance wraps
@ -46,6 +51,34 @@ module Gitlab
end end
end end
def diff_lines_for_serializer
# calculate sections and highlight lines before changing types
sections && highlight_lines!
sections.flat_map do |section|
if section[:conflict]
lines = []
initial_type = nil
section[:lines].each do |line|
if line.type != initial_type
lines << create_separator_line(line)
initial_type = line.type
end
line.type = CONFLICT_TYPES[line.type]
lines << line
end
lines << create_separator_line(lines.last)
lines
else
section[:lines]
end
end
end
def sections def sections
return @sections if @sections return @sections if @sections
@ -93,9 +126,15 @@ module Gitlab
lines = tail_lines lines = tail_lines
elsif conflict_before elsif conflict_before
# We're at the end of the file (no conflicts after), so just remove extra # We're at the end of the file (no conflicts after)
# trailing lines. number_of_trailing_lines = lines.size
# Remove extra trailing lines
lines = lines.first(CONTEXT_LINES) lines = lines.first(CONTEXT_LINES)
if number_of_trailing_lines > CONTEXT_LINES
lines << create_match_line(lines.last)
end
end end
end end
@ -117,6 +156,10 @@ module Gitlab
Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos) Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos)
end end
def create_separator_line(line)
Gitlab::Diff::Line.new('', 'conflict_marker', line.index, nil, nil)
end
# Any line beginning with a letter, an underscore, or a dollar can be used in a # Any line beginning with a letter, an underscore, or a dollar can be used in a
# match line header. Only context sections can contain match lines, as match lines # match line header. Only context sections can contain match lines, as match lines
# have to exist in both versions of the file. # have to exist in both versions of the file.

View File

@ -8,9 +8,9 @@ module Gitlab
# #
SERIALIZE_KEYS = %i(line_code rich_text text type index old_pos new_pos).freeze SERIALIZE_KEYS = %i(line_code rich_text text type index old_pos new_pos).freeze
attr_reader :line_code, :type, :old_pos, :new_pos attr_reader :line_code, :old_pos, :new_pos
attr_writer :rich_text attr_writer :rich_text
attr_accessor :text, :index attr_accessor :text, :index, :type
def initialize(text, type, index, old_pos, new_pos, parent_file: nil, line_code: nil, rich_text: nil) def initialize(text, type, index, old_pos, new_pos, parent_file: nil, line_code: nil, rich_text: nil)
@text, @type, @index = text, type, index @text, @type, @index = text, type, index

View File

@ -3,6 +3,14 @@
module Gitlab module Gitlab
module EtagCaching module EtagCaching
class Middleware class Middleware
SKIP_HEADER_KEY = 'X-Gitlab-Skip-Etag'
class << self
def skip!(response)
response.set_header(SKIP_HEADER_KEY, '1')
end
end
def initialize(app) def initialize(app)
@app = app @app = app
end end
@ -22,9 +30,7 @@ module Gitlab
else else
track_cache_miss(if_none_match, cached_value_present, route) track_cache_miss(if_none_match, cached_value_present, route)
status, headers, body = @app.call(env) maybe_apply_etag(etag, *@app.call(env))
headers['ETag'] = etag
[status, headers, body]
end end
end end
@ -43,6 +49,13 @@ module Gitlab
[weak_etag_format(current_value), cached_value_present] [weak_etag_format(current_value), cached_value_present]
end end
def maybe_apply_etag(etag, status, headers, body)
headers['ETag'] = etag unless
Gitlab::Utils.to_boolean(headers.delete(SKIP_HEADER_KEY))
[status, headers, body]
end
def weak_etag_format(value) def weak_etag_format(value)
%Q{W/"#{value}"} %Q{W/"#{value}"}
end end

View File

@ -4,6 +4,8 @@ module Gitlab
module Graphql module Graphql
module Present module Present
class Instrumentation class Instrumentation
SAFE_CONTEXT_KEYS = %i[current_user].freeze
def instrument(type, field) def instrument(type, field)
return field unless field.metadata[:type_class] return field unless field.metadata[:type_class]
@ -22,7 +24,8 @@ module Gitlab
next old_resolver.call(presented_type, args, context) next old_resolver.call(presented_type, args, context)
end end
presenter = presented_in.presenter_class.new(object, **context.to_h) attrs = safe_context_values(context)
presenter = presented_in.presenter_class.new(object, **attrs)
# we have to use the new `authorized_new` method, as `new` is protected # we have to use the new `authorized_new` method, as `new` is protected
wrapped = presented_type.class.authorized_new(presenter, context) wrapped = presented_type.class.authorized_new(presenter, context)
@ -34,6 +37,12 @@ module Gitlab
resolve(resolve_with_presenter) resolve(resolve_with_presenter)
end end
end end
private
def safe_context_values(context)
context.to_h.slice(*SAFE_CONTEXT_KEYS)
end
end end
end end
end end

View File

@ -32,20 +32,20 @@ module Gitlab
end end
def self.http_requests_total def self.http_requests_total
@http_requests_total ||= ::Gitlab::Metrics.counter(:http_requests_total, 'Request count') ::Gitlab::Metrics.counter(:http_requests_total, 'Request count')
end end
def self.rack_uncaught_errors_count def self.rack_uncaught_errors_count
@rack_uncaught_errors_count ||= ::Gitlab::Metrics.counter(:rack_uncaught_errors_total, 'Request handling uncaught errors count') ::Gitlab::Metrics.counter(:rack_uncaught_errors_total, 'Request handling uncaught errors count')
end end
def self.http_request_duration_seconds def self.http_request_duration_seconds
@http_request_duration_seconds ||= ::Gitlab::Metrics.histogram(:http_request_duration_seconds, 'Request handling execution time', ::Gitlab::Metrics.histogram(:http_request_duration_seconds, 'Request handling execution time',
{}, [0.05, 0.1, 0.25, 0.5, 0.7, 1, 2.5, 5, 10, 25]) {}, [0.05, 0.1, 0.25, 0.5, 0.7, 1, 2.5, 5, 10, 25])
end end
def self.http_health_requests_total def self.http_health_requests_total
@http_health_requests_total ||= ::Gitlab::Metrics.counter(:http_health_requests_total, 'Health endpoint request count') ::Gitlab::Metrics.counter(:http_health_requests_total, 'Health endpoint request count')
end end
def self.initialize_metrics def self.initialize_metrics

View File

@ -16,7 +16,7 @@ module Gitlab
# Add process id params # Add process id params
job['pid'] = ::Process.pid job['pid'] = ::Process.pid
job.delete('args') unless ENV['SIDEKIQ_LOG_ARGUMENTS'] job.delete('args') unless SidekiqLogArguments.enabled?
job job
end end

View File

@ -1,42 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Gitlab::UsageDataCounters module Gitlab::UsageDataCounters
class DesignsCounter class DesignsCounter < BaseCounter
extend Gitlab::UsageDataCounters::RedisCounter
KNOWN_EVENTS = %w[create update delete].freeze KNOWN_EVENTS = %w[create update delete].freeze
PREFIX = 'design_management_designs'
UnknownEvent = Class.new(StandardError)
class << self
# Each event gets a unique Redis key
def redis_key(event)
raise UnknownEvent, event unless KNOWN_EVENTS.include?(event.to_s)
"USAGE_DESIGN_MANAGEMENT_DESIGNS_#{event}".upcase
end
def count(event)
increment(redis_key(event))
end
def read(event)
total_count(redis_key(event))
end
def totals
KNOWN_EVENTS.map { |event| [counter_key(event), read(event)] }.to_h
end
def fallback_totals
KNOWN_EVENTS.map { |event| [counter_key(event), -1] }.to_h
end
private
def counter_key(event)
"design_management_designs_#{event}".to_sym
end
end
end end
end end

View File

@ -2,54 +2,43 @@
module Gitlab module Gitlab
module UsageDataCounters module UsageDataCounters
class WebIdeCounter class WebIdeCounter < BaseCounter
extend RedisCounter KNOWN_EVENTS = %w[commits views merge_requests previews terminals pipelines].freeze
KNOWN_EVENTS = %i[commits views merge_requests previews terminals pipelines].freeze
PREFIX = 'web_ide' PREFIX = 'web_ide'
class << self class << self
def increment_commits_count def increment_commits_count
increment(redis_key('commits')) count('commits')
end end
def increment_merge_requests_count def increment_merge_requests_count
increment(redis_key('merge_requests')) count('merge_requests')
end end
def increment_views_count def increment_views_count
increment(redis_key('views')) count('views')
end end
def increment_terminals_count def increment_terminals_count
increment(redis_key('terminals')) count('terminals')
end end
def increment_pipelines_count def increment_pipelines_count
increment(redis_key('pipelines')) count('pipelines')
end end
def increment_previews_count def increment_previews_count
return unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? return unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
increment(redis_key('previews')) count('previews')
end
def totals
KNOWN_EVENTS.map { |event| [counter_key(event), total_count(redis_key(event))] }.to_h
end
def fallback_totals
KNOWN_EVENTS.map { |event| [counter_key(event), -1] }.to_h
end end
private private
def redis_key(event) def redis_key(event)
"#{PREFIX}_#{event}_count".upcase require_known_event(event)
end
def counter_key(event) "#{prefix}_#{event}_count".upcase
"#{PREFIX}_#{event}".to_sym
end end
end end
end end

View File

@ -4696,9 +4696,6 @@ msgstr ""
msgid "By %{user_name}" msgid "By %{user_name}"
msgstr "" msgstr ""
msgid "By URL"
msgstr ""
msgid "By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}" msgid "By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}"
msgstr "" msgstr ""
@ -5053,9 +5050,6 @@ msgstr ""
msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}." msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}."
msgstr "" msgstr ""
msgid "Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "Changing group URL can have unintended side effects." msgid "Changing group URL can have unintended side effects."
msgstr "" msgstr ""
@ -6987,9 +6981,6 @@ msgstr ""
msgid "Compliance framework (optional)" msgid "Compliance framework (optional)"
msgstr "" msgstr ""
msgid "Compliance frameworks"
msgstr ""
msgid "ComplianceDashboard|created by:" msgid "ComplianceDashboard|created by:"
msgstr "" msgstr ""
@ -10113,7 +10104,7 @@ msgstr ""
msgid "Enable" msgid "Enable"
msgstr "" msgstr ""
msgid "Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab." msgid "Enable %{linkStart}Gitpod%{linkEnd} integration to launch a development environment in your browser directly from GitLab."
msgstr "" msgstr ""
msgid "Enable Auto DevOps" msgid "Enable Auto DevOps"
@ -16149,6 +16140,9 @@ msgstr ""
msgid "Link title is required" msgid "Link title is required"
msgstr "" msgstr ""
msgid "Link to an image"
msgstr ""
msgid "Link to go to GitLab pipeline documentation" msgid "Link to go to GitLab pipeline documentation"
msgstr "" msgstr ""
@ -20313,18 +20307,12 @@ msgstr ""
msgid "Preferences|Choose what content you want to see on your homepage." msgid "Preferences|Choose what content you want to see on your homepage."
msgstr "" msgstr ""
msgid "Preferences|Customize integrations with third party services."
msgstr ""
msgid "Preferences|Customize the appearance of the application header and navigation sidebar." msgid "Preferences|Customize the appearance of the application header and navigation sidebar."
msgstr "" msgstr ""
msgid "Preferences|Display time in 24-hour format" msgid "Preferences|Display time in 24-hour format"
msgstr "" msgstr ""
msgid "Preferences|Enable integrated code intelligence on code views"
msgstr ""
msgid "Preferences|For example: 30 mins ago." msgid "Preferences|For example: 30 mins ago."
msgstr "" msgstr ""
@ -20334,9 +20322,6 @@ msgstr ""
msgid "Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser." msgid "Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser."
msgstr "" msgstr ""
msgid "Preferences|Integrations"
msgstr ""
msgid "Preferences|Layout width" msgid "Preferences|Layout width"
msgstr "" msgstr ""
@ -20358,9 +20343,6 @@ msgstr ""
msgid "Preferences|Show whitespace changes in diffs" msgid "Preferences|Show whitespace changes in diffs"
msgstr "" msgstr ""
msgid "Preferences|Sourcegraph"
msgstr ""
msgid "Preferences|Syntax highlighting theme" msgid "Preferences|Syntax highlighting theme"
msgstr "" msgstr ""
@ -20415,6 +20397,9 @@ msgstr ""
msgid "Prevent users from changing their profile name" msgid "Prevent users from changing their profile name"
msgstr "" msgstr ""
msgid "Prevent users from modifying merge request approvers list"
msgstr ""
msgid "Prevent users from performing write operations on GitLab while performing maintenance." msgid "Prevent users from performing write operations on GitLab while performing maintenance."
msgstr "" msgstr ""
@ -20544,6 +20529,24 @@ msgstr ""
msgid "Profile Settings" msgid "Profile Settings"
msgstr "" msgstr ""
msgid "ProfilePreferences|Customize integrations with third party services."
msgstr ""
msgid "ProfilePreferences|Enable Gitpod integration"
msgstr ""
msgid "ProfilePreferences|Enable integrated code intelligence on code views"
msgstr ""
msgid "ProfilePreferences|Gitpod"
msgstr ""
msgid "ProfilePreferences|Integrations"
msgstr ""
msgid "ProfilePreferences|Sourcegraph"
msgstr ""
msgid "ProfileSession|on" msgid "ProfileSession|on"
msgstr "" msgstr ""
@ -22331,9 +22334,6 @@ msgstr ""
msgid "Registry setup" msgid "Registry setup"
msgstr "" msgstr ""
msgid "Regulate approvals by authors/committers, based on compliance frameworks. Can be changed only at the instance level."
msgstr ""
msgid "Reindexing status" msgid "Reindexing status"
msgstr "" msgstr ""
@ -25593,10 +25593,10 @@ msgstr ""
msgid "SourcegraphPreferences|This feature is experimental." msgid "SourcegraphPreferences|This feature is experimental."
msgstr "" msgstr ""
msgid "SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}." msgid "SourcegraphPreferences|Uses %{linkStart}Sourcegraph.com%{linkEnd}."
msgstr "" msgstr ""
msgid "SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}." msgid "SourcegraphPreferences|Uses a custom %{linkStart}Sourcegraph instance%{linkEnd}."
msgstr "" msgstr ""
msgid "Spam Logs" msgid "Spam Logs"
@ -26765,9 +26765,6 @@ msgstr ""
msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS." msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
msgstr "" msgstr ""
msgid "The above settings apply to all projects with the selected compliance framework(s)."
msgstr ""
msgid "The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential." msgid "The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential."
msgstr "" msgstr ""
@ -27085,6 +27082,9 @@ msgstr ""
msgid "The status of the table below only applies to the default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. Once you've enabled a scan for the default branch, any subsequent feature branch you create will include the scan." msgid "The status of the table below only applies to the default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. Once you've enabled a scan for the default branch, any subsequent feature branch you create will include the scan."
msgstr "" msgstr ""
msgid "The tag name can't be changed for an existing release."
msgstr ""
msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
msgstr "" msgstr ""
@ -29180,6 +29180,9 @@ msgstr ""
msgid "Upload a private key for your certificate" msgid "Upload a private key for your certificate"
msgstr "" msgstr ""
msgid "Upload an image"
msgstr ""
msgid "Upload file" msgid "Upload file"
msgstr "" msgstr ""

View File

@ -383,6 +383,7 @@ RSpec.describe Projects::MergeRequests::DiffsController do
environment: nil, environment: nil,
merge_request: merge_request, merge_request: merge_request,
diff_view: :inline, diff_view: :inline,
merge_ref_head_diff: nil,
pagination_data: { pagination_data: {
current_page: nil, current_page: nil,
next_page: nil, next_page: nil,

View File

@ -113,6 +113,8 @@ RSpec.describe Projects::NotesController do
end end
it 'returns the first page of notes' do it 'returns the first page of notes' do
expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
get :index, params: request_params get :index, params: request_params
expect(json_response['notes'].count).to eq(page_1.count) expect(json_response['notes'].count).to eq(page_1.count)
@ -122,6 +124,8 @@ RSpec.describe Projects::NotesController do
end end
it 'returns the second page of notes' do it 'returns the second page of notes' do
expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
request.headers['X-Last-Fetched-At'] = page_1_boundary request.headers['X-Last-Fetched-At'] = page_1_boundary
get :index, params: request_params get :index, params: request_params
@ -133,6 +137,8 @@ RSpec.describe Projects::NotesController do
end end
it 'returns the final page of notes' do it 'returns the final page of notes' do
expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
request.headers['X-Last-Fetched-At'] = page_2_boundary request.headers['X-Last-Fetched-At'] = page_2_boundary
get :index, params: request_params get :index, params: request_params
@ -142,6 +148,19 @@ RSpec.describe Projects::NotesController do
expect(json_response['last_fetched_at']).to eq(microseconds(Time.zone.now)) expect(json_response['last_fetched_at']).to eq(microseconds(Time.zone.now))
expect(response.headers['Poll-Interval'].to_i).to be > 1 expect(response.headers['Poll-Interval'].to_i).to be > 1
end end
it 'returns an empty page of notes' do
expect(Gitlab::EtagCaching::Middleware).not_to receive(:skip!)
request.headers['X-Last-Fetched-At'] = microseconds(Time.zone.now)
get :index, params: request_params
expect(json_response['notes']).to be_empty
expect(json_response['more']).to be_falsy
expect(json_response['last_fetched_at']).to eq(microseconds(Time.zone.now))
expect(response.headers['Poll-Interval'].to_i).to be > 1
end
end end
context 'feature flag disabled' do context 'feature flag disabled' do

View File

@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IntegrationView component should render IntegrationView properly 1`] = `
<div
name="sourcegraph"
>
<label
class="label-bold"
>
Foo
</label>
<gl-link-stub
class="has-tooltip"
href="http://foo.com/help"
title="More information"
>
<gl-icon-stub
class="vertical-align-middle"
name="question-o"
size="16"
/>
</gl-link-stub>
<div
class="form-group form-check"
data-testid="profile-preferences-integration-form-group"
>
<input
data-testid="profile-preferences-integration-hidden-field"
name="user[foo_enabled]"
type="hidden"
value="0"
/>
<input
class="form-check-input"
data-testid="profile-preferences-integration-checkbox"
id="user_foo_enabled"
name="user[foo_enabled]"
type="checkbox"
value="1"
/>
<label
class="form-check-label"
for="user_foo_enabled"
>
Enable foo
</label>
<gl-form-text-stub
tag="div"
textvariant="muted"
>
<integration-help-text-stub
message="Click %{linkStart}Foo%{linkEnd}!"
messageurl="http://foo.com"
/>
</gl-form-text-stub>
</div>
</div>
`;

View File

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProfilePreferences component should render ProfilePreferences properly 1`] = `
<div
class="row gl-mt-3 js-preferences-form"
>
<div
class="col-sm-12"
>
<hr
data-testid="profile-preferences-integrations-rule"
/>
</div>
<div
class="col-lg-4 profile-settings-sidebar"
>
<h4
class="gl-mt-0"
data-testid="profile-preferences-integrations-heading"
>
Integrations
</h4>
<p>
Customize integrations with third party services.
</p>
</div>
<div
class="col-lg-8"
>
<integration-view-stub
config="[object Object]"
helplink="http://foo.com/help"
message="Click %{linkStart}Foo%{linkEnd}!"
messageurl="http://foo.com"
/>
<integration-view-stub
config="[object Object]"
helplink="http://bar.com/help"
message="Click %{linkStart}Bar%{linkEnd}!"
messageurl="http://bar.com"
/>
</div>
</div>
`;

View File

@ -0,0 +1,124 @@
import { shallowMount } from '@vue/test-utils';
import { GlFormText } from '@gitlab/ui';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { integrationViews, userFields } from '../mock_data';
const viewProps = convertObjectPropsToCamelCase(integrationViews[0]);
describe('IntegrationView component', () => {
let wrapper;
const defaultProps = {
config: {
title: 'Foo',
label: 'Enable foo',
formName: 'foo_enabled',
},
...viewProps,
};
function createComponent(options = {}) {
const { props = {}, provide = {} } = options;
return shallowMount(IntegrationView, {
provide: {
userFields,
...provide,
},
propsData: {
...defaultProps,
...props,
},
});
}
function findCheckbox() {
return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]');
}
function findFormGroup() {
return wrapper.find('[data-testid="profile-preferences-integration-form-group"]');
}
function findHiddenField() {
return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]');
}
function findFormGroupLabel() {
return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label');
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('should render the title correctly', () => {
wrapper = createComponent();
expect(wrapper.find('label.label-bold').text()).toBe('Foo');
});
it('should render the form correctly', () => {
wrapper = createComponent();
expect(findFormGroup().exists()).toBe(true);
expect(findHiddenField().exists()).toBe(true);
expect(findCheckbox().exists()).toBe(true);
expect(findCheckbox().attributes('id')).toBe('user_foo_enabled');
expect(findCheckbox().attributes('name')).toBe('user[foo_enabled]');
});
it('should have the checkbox value to be set to 1', () => {
wrapper = createComponent();
expect(findCheckbox().attributes('value')).toBe('1');
});
it('should have the hidden value to be set to 0', () => {
wrapper = createComponent();
expect(findHiddenField().attributes('value')).toBe('0');
});
it('should set the checkbox value to be true', () => {
wrapper = createComponent();
expect(findCheckbox().element.checked).toBe(true);
});
it('should set the checkbox value to be false when false is provided', () => {
wrapper = createComponent({
provide: {
userFields: {
foo_enabled: false,
},
},
});
expect(findCheckbox().element.checked).toBe(false);
});
it('should set the checkbox value to be false when not provided', () => {
wrapper = createComponent({ provide: { userFields: {} } });
expect(findCheckbox().element.checked).toBe(false);
});
it('should render the help text', () => {
wrapper = createComponent();
expect(wrapper.find(GlFormText).exists()).toBe(true);
expect(wrapper.find(IntegrationHelpText).exists()).toBe(true);
});
it('should render the label correctly', () => {
wrapper = createComponent();
expect(findFormGroupLabel().text()).toBe('Enable foo');
});
it('should render IntegrationView properly', () => {
wrapper = createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});

View File

@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import { integrationViews, userFields } from '../mock_data';
describe('ProfilePreferences component', () => {
let wrapper;
const defaultProvide = {
integrationViews: [],
userFields,
};
function createComponent(options = {}) {
const { props = {}, provide = {} } = options;
return shallowMount(ProfilePreferences, {
provide: {
...defaultProvide,
...provide,
},
propsData: props,
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('should not render Integrations section', () => {
wrapper = createComponent();
const views = wrapper.findAll(IntegrationView);
const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
expect(divider.exists()).toBe(false);
expect(heading.exists()).toBe(false);
expect(views).toHaveLength(0);
});
it('should render Integration section', () => {
wrapper = createComponent({ provide: { integrationViews } });
const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
const views = wrapper.findAll(IntegrationView);
expect(divider.exists()).toBe(true);
expect(heading.exists()).toBe(true);
expect(views).toHaveLength(integrationViews.length);
});
it('should render ProfilePreferences properly', () => {
wrapper = createComponent({ provide: { integrationViews } });
expect(wrapper.element).toMatchSnapshot();
});
});

View File

@ -0,0 +1,18 @@
export const integrationViews = [
{
name: 'sourcegraph',
help_link: 'http://foo.com/help',
message: 'Click %{linkStart}Foo%{linkEnd}!',
message_url: 'http://foo.com',
},
{
name: 'gitpod',
help_link: 'http://bar.com/help',
message: 'Click %{linkStart}Bar%{linkEnd}!',
message_url: 'http://bar.com',
},
];
export const userFields = {
foo_enabled: true,
};

View File

@ -24,7 +24,6 @@ describe('Release edit/new component', () => {
state = { state = {
release, release,
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page', releasesPagePath: 'path/to/releases/page',
projectId: '8', projectId: '8',
groupId: '42', groupId: '42',

View File

@ -6,7 +6,6 @@ import createStore from '~/releases/stores';
import createDetailModule from '~/releases/stores/modules/detail'; import createDetailModule from '~/releases/stores/modules/detail';
const TEST_TAG_NAME = 'test-tag-name'; const TEST_TAG_NAME = 'test-tag-name';
const TEST_DOCS_PATH = '/help/test/docs/path';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
@ -24,21 +23,11 @@ describe('releases/components/tag_field_existing', () => {
const findInput = () => wrapper.find(GlFormInput); const findInput = () => wrapper.find(GlFormInput);
const findHelp = () => wrapper.find('[data-testid="tag-name-help"]'); const findHelp = () => wrapper.find('[data-testid="tag-name-help"]');
const findHelpLink = () => {
const link = findHelp().find('a');
return {
text: link.text(),
href: link.attributes('href'),
target: link.attributes('target'),
};
};
beforeEach(() => { beforeEach(() => {
store = createStore({ store = createStore({
modules: { modules: {
detail: createDetailModule({ detail: createDetailModule({
updateReleaseApiDocsPath: TEST_DOCS_PATH,
tagName: TEST_TAG_NAME, tagName: TEST_TAG_NAME,
}), }),
}, },
@ -68,16 +57,8 @@ describe('releases/components/tag_field_existing', () => {
createComponent(mount); createComponent(mount);
expect(findHelp().text()).toMatchInterpolatedText( expect(findHelp().text()).toMatchInterpolatedText(
'Changing a Release tag is only supported via Releases API. More information', "The tag name can't be changed for an existing release.",
); );
const helpLink = findHelpLink();
expect(helpLink).toEqual({
text: 'More information',
href: TEST_DOCS_PATH,
target: '_blank',
});
}); });
}); });
}); });

View File

@ -47,7 +47,6 @@ describe('Release detail actions', () => {
releasesPagePath: 'path/to/releases/page', releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview', markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
}), }),
...getters, ...getters,
...rootState, ...rootState,

View File

@ -18,7 +18,6 @@ describe('Release detail mutations', () => {
releasesPagePath: 'path/to/releases/page', releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview', markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
}); });
release = convertObjectPropsToCamelCase(originalRelease); release = convertObjectPropsToCamelCase(originalRelease);
}); });

View File

@ -3,9 +3,11 @@ import { mounts, project, branch, baseUrl } from '../../mock_data';
describe('rich_content_editor/renderers/render_image', () => { describe('rich_content_editor/renderers/render_image', () => {
let renderer; let renderer;
let imageRepository;
beforeEach(() => { beforeEach(() => {
renderer = imageRenderer.build(mounts, project, branch, baseUrl); renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
imageRepository = { get: () => null };
}); });
describe('build', () => { describe('build', () => {
@ -27,6 +29,21 @@ describe('rich_content_editor/renderers/render_image', () => {
}); });
describe('render', () => { describe('render', () => {
let skipChildren;
let context;
let node;
beforeEach(() => {
skipChildren = jest.fn();
context = { skipChildren };
node = {
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
});
it.each` it.each`
destination | isAbsolute | src destination | isAbsolute | src
${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'}
@ -36,15 +53,8 @@ describe('rich_content_editor/renderers/render_image', () => {
${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'} ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'}
${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'} ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'}
`('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => {
const skipChildren = jest.fn(); node.destination = destination;
const context = { skipChildren };
const node = {
destination,
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
const result = renderer.render(node, context); const result = renderer.render(node, context);
expect(result).toEqual({ expect(result).toEqual({
@ -60,5 +70,27 @@ describe('rich_content_editor/renderers/render_image', () => {
expect(skipChildren).toHaveBeenCalled(); expect(skipChildren).toHaveBeenCalled();
}); });
it('renders an image if a cached image is found in the repository, use the base64 content as the source', () => {
const imageContent = 'some-content';
const originalSrc = 'path/to/image.png';
imageRepository.get = () => imageContent;
renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
node.destination = originalSrc;
const result = renderer.render(node, context);
expect(result).toEqual({
type: 'openTag',
tagName: 'img',
selfClose: true,
attributes: {
'data-original-src': originalSrc,
src: `data:image;base64,${imageContent}`,
alt: 'Some Image',
},
});
});
}); });
}); });

View File

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IntegrationHelpText component should not render the link when start and end is not provided 1`] = `
<span>
Click nowhere!
</span>
`;
exports[`IntegrationHelpText component should render the help text 1`] = `
<span>
Click
<gl-link-stub
href="http://bar.com"
target="_blank"
>
Bar
<gl-icon-stub
class="gl-vertical-align-middle"
name="external-link"
size="12"
/>
</gl-link-stub>
!
</span>
`;

View File

@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
describe('IntegrationHelpText component', () => {
let wrapper;
const defaultProps = {
message: 'Click %{linkStart}Bar%{linkEnd}!',
messageUrl: 'http://bar.com',
};
function createComponent(props = {}) {
return shallowMount(IntegrationHelpText, {
propsData: {
...defaultProps,
...props,
},
stubs: {
GlSprintf,
},
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('should use the gl components', () => {
wrapper = createComponent();
expect(wrapper.find(GlSprintf).exists()).toBe(true);
expect(wrapper.find(GlIcon).exists()).toBe(true);
expect(wrapper.find(GlLink).exists()).toBe(true);
});
it('should render the help text', () => {
wrapper = createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('should not use the gl-link and gl-icon components', () => {
wrapper = createComponent({ message: 'Click nowhere!' });
expect(wrapper.find(GlSprintf).exists()).toBe(true);
expect(wrapper.find(GlIcon).exists()).toBe(false);
expect(wrapper.find(GlLink).exists()).toBe(false);
});
it('should not render the link when start and end is not provided', () => {
wrapper = createComponent({ message: 'Click nowhere!' });
expect(wrapper.element).toMatchSnapshot();
});
});

View File

@ -91,12 +91,25 @@ describe('Editor Service', () => {
}); });
describe('addImage', () => { describe('addImage', () => {
it('calls the exec method on the instance', () => { const file = new File([], 'some-file.jpg');
const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' };
addImage(mockInstance, mockImage); it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => {
jest.spyOn(URL, 'createObjectURL');
mockInstance.editor.isWysiwygMode.mockReturnValue(true);
mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() });
expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage); addImage(mockInstance, mockImage, file);
expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled();
expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file);
});
it('calls the insertText method on the instance when in Markdown mode', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(false);
addImage(mockInstance, mockImage, file);
expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)');
}); });
}); });

View File

@ -15,10 +15,7 @@ describe('Add Image Modal', () => {
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(AddImageModal, { wrapper = shallowMount(AddImageModal, { propsData });
provide: { glFeatures: { sseImageUploads: true } },
propsData,
});
}); });
describe('when content is loaded', () => { describe('when content is loaded', () => {

View File

@ -180,7 +180,7 @@ describe('Rich Content Editor', () => {
wrapper.vm.$refs.editor = mockInstance; wrapper.vm.$refs.editor = mockInstance;
findAddImageModal().vm.$emit('addImage', mockImage); findAddImageModal().vm.$emit('addImage', mockImage);
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined);
}); });
}); });

View File

@ -0,0 +1,208 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::CachingArrayResolver do
include GraphqlHelpers
let_it_be(:non_admins) { create_list(:user, 4, admin: false) }
let(:query_context) { {} }
let(:max_page_size) { 10 }
let(:field) { double('Field', max_page_size: max_page_size) }
let(:schema) { double('Schema', default_max_page_size: 3) }
let_it_be(:caching_resolver) do
mod = described_class
Class.new(::Resolvers::BaseResolver) do
include mod
def query_input(is_admin:)
is_admin
end
def query_for(is_admin)
if is_admin.nil?
model_class.all
else
model_class.where(admin: is_admin)
end
end
def model_class
User # Happens to include FromUnion, and is cheap-ish to create
end
end
end
describe '#resolve' do
context 'there are more than MAX_UNION_SIZE queries' do
let_it_be(:max_union) { 3 }
let_it_be(:resolver) do
mod = described_class
max = max_union
Class.new(::Resolvers::BaseResolver) do
include mod
def query_input(username:)
username
end
def query_for(username)
if username.nil?
model_class.all
else
model_class.where(username: username)
end
end
def model_class
User # Happens to include FromUnion, and is cheap-ish to create
end
define_method :max_union_size do
max
end
end
end
it 'executes the queries in multiple batches' do
users = create_list(:user, (max_union * 2) + 1)
expect(User).to receive(:from_union).twice.and_call_original
results = users.in_groups_of(2, false).map do |users|
resolve(resolver, args: { username: users.map(&:username) }, field: field, schema: schema)
end
expect(results.flat_map(&method(:force))).to match_array(users)
end
end
context 'all queries return results' do
let_it_be(:admins) { create_list(:admin, 3) }
it 'batches the queries' do
expect do
[resolve_users(true), resolve_users(false)].each(&method(:force))
end.to issue_same_number_of_queries_as { force(resolve_users(nil)) }
end
it 'finds the correct values' do
found_admins = resolve_users(true)
found_others = resolve_users(false)
admins_again = resolve_users(true)
found_all = resolve_users(nil)
expect(force(found_admins)).to match_array(admins)
expect(force(found_others)).to match_array(non_admins)
expect(force(admins_again)).to match_array(admins)
expect(force(found_all)).to match_array(admins + non_admins)
end
end
it 'does not perform a union of a query with itself' do
expect(User).to receive(:where).once.and_call_original
[resolve_users(false), resolve_users(false)].each(&method(:force))
end
context 'one of the queries returns no results' do
it 'finds the correct values' do
found_admins = resolve_users(true)
found_others = resolve_users(false)
found_all = resolve_users(nil)
expect(force(found_admins)).to be_empty
expect(force(found_others)).to match_array(non_admins)
expect(force(found_all)).to match_array(non_admins)
end
end
context 'one of the queries has already been cached' do
before do
force(resolve_users(nil))
end
it 'avoids further queries' do
expect do
repeated_find = resolve_users(nil)
expect(force(repeated_find)).to match_array(non_admins)
end.not_to exceed_query_limit(0)
end
end
context 'the resolver overrides item_found' do
let_it_be(:admins) { create_list(:admin, 2) }
let(:query_context) do
{
found: { true => [], false => [], nil => [] }
}
end
let_it_be(:with_item_found) do
Class.new(caching_resolver) do
def item_found(key, item)
context[:found][key] << item
end
end
end
it 'receives item_found for each key the item mapped to' do
found_admins = resolve_users(true, with_item_found)
found_all = resolve_users(nil, with_item_found)
[found_admins, found_all].each(&method(:force))
expect(query_context[:found]).to match({
false => be_empty,
true => match_array(admins),
nil => match_array(admins + non_admins)
})
end
end
context 'the max_page_size is lower than the total result size' do
let(:max_page_size) { 2 }
it 'respects the max_page_size, on a per subset basis' do
found_all = resolve_users(nil)
found_others = resolve_users(false)
expect(force(found_all).size).to eq(2)
expect(force(found_others).size).to eq(2)
end
end
context 'the field does not declare max_page_size' do
let(:max_page_size) { nil }
it 'takes the page size from schema.default_max_page_size' do
found_all = resolve_users(nil)
found_others = resolve_users(false)
expect(force(found_all).size).to eq(schema.default_max_page_size)
expect(force(found_others).size).to eq(schema.default_max_page_size)
end
end
specify 'force . resolve === to_a . query_for . query_input' do
r = resolver_instance(caching_resolver)
args = { is_admin: false }
naive = r.query_for(r.query_input(**args)).to_a
expect(force(r.resolve(**args))).to eq(naive)
end
end
def resolve_users(is_admin, resolver = caching_resolver)
args = { is_admin: is_admin }
resolve(resolver, args: args, field: field, ctx: query_context, schema: schema)
end
def force(lazy)
::Gitlab::Graphql::Lazy.force(lazy)
end
end

View File

@ -71,7 +71,6 @@ RSpec.describe ReleasesHelper do
markdown_preview_path markdown_preview_path
markdown_docs_path markdown_docs_path
releases_page_path releases_page_path
update_release_api_docs_path
release_assets_docs_path release_assets_docs_path
manage_milestones_path manage_milestones_path
new_milestone_path) new_milestone_path)
@ -89,7 +88,6 @@ RSpec.describe ReleasesHelper do
releases_page_path releases_page_path
markdown_preview_path markdown_preview_path
markdown_docs_path markdown_docs_path
update_release_api_docs_path
release_assets_docs_path release_assets_docs_path
manage_milestones_path manage_milestones_path
new_milestone_path new_milestone_path

View File

@ -5,60 +5,43 @@ require 'spec_helper'
RSpec.describe SourcegraphHelper do RSpec.describe SourcegraphHelper do
describe '#sourcegraph_url_message' do describe '#sourcegraph_url_message' do
let(:sourcegraph_url) { 'http://sourcegraph.example.com' } let(:sourcegraph_url) { 'http://sourcegraph.example.com' }
let(:feature_conditional) { false }
let(:public_only) { false }
let(:is_com) { true }
before do before do
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url).and_return(sourcegraph_url) allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url).and_return(sourcegraph_url)
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url_is_com?).and_return(is_com) allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url_is_com?).and_return(is_com)
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_public_only).and_return(public_only)
allow(Gitlab::Sourcegraph).to receive(:feature_conditional?).and_return(feature_conditional)
end end
subject { helper.sourcegraph_url_message } subject { helper.sourcegraph_url_message }
context 'with .com sourcegraph url' do context 'with .com sourcegraph url' do
let(:is_com) { true } it { is_expected.to have_text('Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental.') }
it { is_expected.to have_text('Uses Sourcegraph.com') }
it { is_expected.to have_link('Sourcegraph.com', href: sourcegraph_url) }
end end
context 'with custom sourcegraph url' do context 'with custom sourcegraph url' do
let(:is_com) { false } let(:is_com) { false }
it { is_expected.to have_text('Uses a custom Sourcegraph instance') } it { is_expected.to have_text('Uses a custom %{linkStart}Sourcegraph instance%{linkEnd}. This feature is experimental.') }
it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) }
context 'with unsafe url' do
let(:sourcegraph_url) { '\" onload=\"alert(1);\"' }
it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) }
end
end end
end
describe '#sourcegraph_experimental_message' do
let(:feature_conditional) { false }
let(:public_only) { false }
before do
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_public_only).and_return(public_only)
allow(Gitlab::Sourcegraph).to receive(:feature_conditional?).and_return(feature_conditional)
end
subject { helper.sourcegraph_experimental_message }
context 'when not limited by feature or public only' do context 'when not limited by feature or public only' do
it { is_expected.to eq "This feature is experimental." } it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental.' }
end end
context 'when limited by feature' do context 'when limited by feature' do
let(:feature_conditional) { true } let(:feature_conditional) { true }
it { is_expected.to eq "This feature is experimental and currently limited to certain projects." } it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental and currently limited to certain projects.' }
end end
context 'when limited by public only' do context 'when limited by public only' do
let(:public_only) { true } let(:public_only) { true }
it { is_expected.to eq "This feature is experimental and limited to public projects." } it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental and limited to public projects.' }
end end
end end
end end

View File

@ -93,6 +93,51 @@ RSpec.describe Gitlab::Conflict::File do
end end
end end
describe '#diff_lines_for_serializer' do
let(:diff_line_types) { conflict_file.diff_lines_for_serializer.map(&:type) }
it 'assigns conflict types to the diff lines' do
expect(diff_line_types[4]).to eq('conflict_marker')
expect(diff_line_types[5..10]).to eq(['conflict_marker_our'] * 6)
expect(diff_line_types[11]).to eq('conflict_marker')
expect(diff_line_types[12..17]).to eq(['conflict_marker_their'] * 6)
expect(diff_line_types[18]).to eq('conflict_marker')
expect(diff_line_types[19..24]).to eq([nil] * 6)
expect(diff_line_types[25]).to eq('conflict_marker')
expect(diff_line_types[26..27]).to eq(['conflict_marker_our'] * 2)
expect(diff_line_types[28]).to eq('conflict_marker')
expect(diff_line_types[29..30]).to eq(['conflict_marker_their'] * 2)
expect(diff_line_types[31]).to eq('conflict_marker')
end
it 'does not add a match line to the end of the section' do
expect(diff_line_types.last).to eq(nil)
end
context 'when there are unchanged trailing lines' do
let(:rugged_conflict) { index.conflicts.first }
let(:raw_conflict_content) { index.merge_file('files/ruby/popen.rb')[:data] }
it 'assign conflict types and adds match line to the end of the section' do
expect(diff_line_types).to eq([
'match',
nil, nil, nil,
"conflict_marker",
"conflict_marker_our",
"conflict_marker",
"conflict_marker_their",
"conflict_marker_their",
"conflict_marker_their",
"conflict_marker",
nil, nil, nil,
"match"
])
end
end
end
describe '#sections' do describe '#sections' do
it 'only inserts match lines when there is a gap between sections' do it 'only inserts match lines when there is a gap between sections' do
conflict_file.sections.each_with_index do |section, i| conflict_file.sections.each_with_index do |section, i|

View File

@ -10,6 +10,17 @@ RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state
let(:enabled_path) { '/gitlab-org/gitlab-foss/noteable/issue/1/notes' } let(:enabled_path) { '/gitlab-org/gitlab-foss/noteable/issue/1/notes' }
let(:endpoint) { 'issue_notes' } let(:endpoint) { 'issue_notes' }
describe '.skip!' do
it 'sets the skip header on the response' do
rsp = ActionDispatch::Response.new
rsp.set_header('Anything', 'Else')
described_class.skip!(rsp)
expect(rsp.headers.to_h).to eq(described_class::SKIP_HEADER_KEY => '1', 'Anything' => 'Else')
end
end
context 'when ETag caching is not enabled for current route' do context 'when ETag caching is not enabled for current route' do
let(:path) { '/gitlab-org/gitlab-foss/tree/master/noteable/issue/1/notes' } let(:path) { '/gitlab-org/gitlab-foss/tree/master/noteable/issue/1/notes' }
@ -77,6 +88,28 @@ RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state
end end
end end
context 'when the matching route requests that the ETag is skipped' do
let(:path) { enabled_path }
let(:app) do
proc do |_env|
response = ActionDispatch::Response.new
described_class.skip!(response)
[200, response.headers.to_h, '']
end
end
it 'returns the correct headers' do
expect(app).to receive(:call).and_call_original
_, headers, _ = middleware.call(build_request(path, if_none_match))
expect(headers).not_to have_key('ETag')
expect(headers).not_to have_key(described_class::SKIP_HEADER_KEY)
end
end
shared_examples 'sends a process_action.action_controller notification' do |status_code| shared_examples 'sends a process_action.action_controller notification' do |status_code|
let(:expected_items) do let(:expected_items) do
{ {

View File

@ -127,7 +127,7 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do
end end
end end
describe '.initialize_metrics', :prometheus, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/281164', type: :investigating } do describe '.initialize_metrics', :prometheus do
it "sets labels for http_requests_total" do it "sets labels for http_requests_total" do
expected_labels = [] expected_labels = []

View File

@ -119,6 +119,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end end
context 'with SIDEKIQ_LOG_ARGUMENTS disabled' do context 'with SIDEKIQ_LOG_ARGUMENTS disabled' do
before do
stub_env('SIDEKIQ_LOG_ARGUMENTS', '0')
end
it 'logs start and end of job without args' do it 'logs start and end of job without args' do
Timecop.freeze(timestamp) do Timecop.freeze(timestamp) do
expect(logger).to receive(:info).with(start_payload.except('args')).ordered expect(logger).to receive(:info).with(start_payload.except('args')).ordered
@ -150,8 +154,8 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
it 'logs with scheduling latency' do it 'logs with scheduling latency' do
Timecop.freeze(timestamp) do Timecop.freeze(timestamp) do
expect(logger).to receive(:info).with(start_payload.except('args')).ordered expect(logger).to receive(:info).with(start_payload).ordered
expect(logger).to receive(:info).with(end_payload.except('args')).ordered expect(logger).to receive(:info).with(end_payload).ordered
expect(subject).to receive(:log_job_start).and_call_original expect(subject).to receive(:log_job_start).and_call_original
expect(subject).to receive(:log_job_done).and_call_original expect(subject).to receive(:log_job_done).and_call_original
@ -173,12 +177,12 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end end
let(:expected_end_payload) do let(:expected_end_payload) do
end_payload.except('args').merge(timing_data) end_payload.merge(timing_data)
end end
it 'logs with Gitaly and Rugged timing data' do it 'logs with Gitaly and Rugged timing data' do
Timecop.freeze(timestamp) do Timecop.freeze(timestamp) do
expect(logger).to receive(:info).with(start_payload.except('args')).ordered expect(logger).to receive(:info).with(start_payload).ordered
expect(logger).to receive(:info).with(expected_end_payload).ordered expect(logger).to receive(:info).with(expected_end_payload).ordered
subject.call(job, 'test_queue') do subject.call(job, 'test_queue') do
@ -194,10 +198,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
allow(Process).to receive(:clock_gettime).and_call_original allow(Process).to receive(:clock_gettime).and_call_original
end end
let(:expected_start_payload) { start_payload.except('args') } let(:expected_start_payload) { start_payload }
let(:expected_end_payload) do let(:expected_end_payload) do
end_payload.except('args').merge('cpu_s' => a_value >= 0) end_payload.merge('cpu_s' => a_value >= 0)
end end
let(:expected_end_payload_with_db) do let(:expected_end_payload_with_db) do
@ -228,10 +232,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end end
context 'when there is extra metadata set for the done log' do context 'when there is extra metadata set for the done log' do
let(:expected_start_payload) { start_payload.except('args') } let(:expected_start_payload) { start_payload }
let(:expected_end_payload) do let(:expected_end_payload) do
end_payload.except('args').merge("#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1" => 15, "#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2" => 16) end_payload.merge("#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1" => 15, "#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2" => 16)
end end
it 'logs it in the done log' do it 'logs it in the done log' do

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Project noteable notes' do
describe '#index' do
let_it_be(:merge_request) { create(:merge_request) }
let(:etag_store) { Gitlab::EtagCaching::Store.new }
let(:notes_path) { project_noteable_notes_path(project, target_type: merge_request.class.name.underscore, target_id: merge_request.id) }
let(:project) { merge_request.project }
let(:user) { project.owner }
let(:response_etag) { response.headers['ETag'] }
let(:stored_etag) { "W/\"#{etag_store.get(notes_path)}\"" }
before do
login_as(user)
end
it 'does not set a Gitlab::EtagCaching ETag if there is a note' do
create(:note_on_merge_request, noteable: merge_request, project: merge_request.project)
get notes_path
expect(response).to have_gitlab_http_status(:ok)
# Rack::ETag will set an etag based on the body digest, but that doesn't
# interfere with notes pagination
expect(response_etag).not_to eq(stored_etag)
end
it 'sets a Gitlab::EtagCaching ETag if there is no note' do
get notes_path
expect(response).to have_gitlab_http_status(:ok)
expect(response_etag).to eq(stored_etag)
end
end
end

View File

@ -69,4 +69,15 @@ RSpec.describe DiffFileEntity do
end end
end end
end end
describe '#is_fully_expanded' do
context 'file with a conflict' do
let(:options) { { conflicts: { diff_file.new_path => double(diff_lines_for_serializer: []) } } }
it 'returns false' do
expect(diff_file).not_to receive(:fully_expanded?)
expect(subject[:is_fully_expanded]).to eq(false)
end
end
end
end end

View File

@ -8,9 +8,12 @@ RSpec.describe DiffsEntity do
let(:request) { EntityRequest.new(project: project, current_user: user) } let(:request) { EntityRequest.new(project: project, current_user: user) }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_diffs) { merge_request.merge_request_diffs } let(:merge_request_diffs) { merge_request.merge_request_diffs }
let(:options) do
{ request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs }
end
let(:entity) do let(:entity) do
described_class.new(merge_request_diffs.first.diffs, request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs) described_class.new(merge_request_diffs.first.diffs, options)
end end
context 'as json' do context 'as json' do
@ -68,5 +71,50 @@ RSpec.describe DiffsEntity do
end end
end end
end end
context 'when there are conflicts' do
let(:diff_files) { merge_request_diffs.first.diffs.diff_files }
let(:diff_file_with_conflict) { diff_files.to_a.last }
let(:diff_file_without_conflict) { diff_files.to_a[-2] }
let(:resolvable_conflicts) { true }
let(:conflict_file) { double(our_path: diff_file_with_conflict.new_path) }
let(:conflicts) { double(conflicts: double(files: [conflict_file]), can_be_resolved_in_ui?: resolvable_conflicts) }
let(:merge_ref_head_diff) { true }
let(:options) { super().merge(merge_ref_head_diff: merge_ref_head_diff) }
before do
allow(MergeRequests::Conflicts::ListService).to receive(:new).and_return(conflicts)
end
it 'conflicts are highlighted' do
expect(conflict_file).to receive(:diff_lines_for_serializer)
expect(diff_file_with_conflict).not_to receive(:diff_lines_for_serializer)
expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
subject
end
context 'merge ref head diff is not chosen to be displayed' do
let(:merge_ref_head_diff) { false }
it 'conflicts are not calculated' do
expect(MergeRequests::Conflicts::ListService).not_to receive(:new)
end
end
context 'when conflicts cannot be resolved' do
let(:resolvable_conflicts) { false }
it 'conflicts are not highlighted' do
expect(conflict_file).not_to receive(:diff_lines_for_serializer)
expect(diff_file_with_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
subject
end
end
end
end end
end end

Some files were not shown because too many files have changed in this diff Show More