Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-08-06 21:07:47 +00:00
parent 6495a4fdc3
commit 9bef5f3d98
73 changed files with 1891 additions and 262 deletions

View File

@ -162,9 +162,7 @@ retrieve-frontend-fixtures:
echoinfo "INFO: Reusing frontend fixtures from 'retrieve-frontend-fixtures'."
exit 0
fi
- run_timed_command "gem install knapsack --no-document"
- section_start "gitaly-test-spawn" "Spawning Gitaly"; scripts/gitaly-test-spawn; section_end "gitaly-test-spawn"; # Do not use 'bundle exec' here
- source ./scripts/rspec_helpers.sh
- !reference [.base-script, script]
- rspec_parallelized_job
artifacts:
name: frontend-fixtures

View File

@ -240,12 +240,14 @@
- ".gitlab/ci/review-apps/qa.gitlab-ci.yml"
- ".gitlab/ci/review-apps/rules.gitlab-ci.yml"
- ".gitlab/ci/test-on-gdk/*.yml"
- ".gitlab/ci/version.yml"
.gitaly-patterns: &gitaly-patterns
- "GITALY_SERVER_VERSION"
- "lib/gitlab/setup_helper.rb"
.workhorse-patterns: &workhorse-patterns
- ".gitlab/ci/version.yml"
- ".gitlab/ci/workhorse.gitlab-ci.yml"
- "GITLAB_WORKHORSE_VERSION"
- "workhorse/**/*"
@ -713,7 +715,8 @@
- "ee/{lib/,spec/}tasks/gitlab/custom_roles/*"
.cng-orchestrator-patterns: &cng-orchestrator-patterns
- qa/gems/gitlab-cng/**/*.rb
- "qa/gems/gitlab-cng/**/*.rb"
- "qa/gems/gitlab-cng/{Gemfile,Gemfile.lock}"
##################
# Conditions set #

View File

@ -228,7 +228,7 @@ gem 'elasticsearch-rails', '~> 7.2', require: 'elasticsearch/rails/instrumentati
gem 'elasticsearch-api', '7.17.11', feature_category: :global_search
gem 'aws-sdk-core', '~> 3.201.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'aws-sdk-cloudformation', '~> 1' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'aws-sdk-s3', '~> 1.156.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'aws-sdk-s3', '~> 1.157.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'faraday-typhoeus', '~> 1.1', feature_category: :global_search
gem 'faraday_middleware-aws-sigv4', '~> 1.0.1', feature_category: :global_search
# Used with Elasticsearch to support http keep-alive connections

View File

@ -39,7 +39,7 @@
{"name":"aws-sdk-cloudformation","version":"1.41.0","platform":"ruby","checksum":"31e47539719734413671edf9b1a31f8673fbf9688549f50c41affabbcb1c6b26"},
{"name":"aws-sdk-core","version":"3.201.3","platform":"ruby","checksum":"c045a7ff37b4a6f1de5742e64def0841bdf70d215cb17d3875b2c5bdd9e99d52"},
{"name":"aws-sdk-kms","version":"1.76.0","platform":"ruby","checksum":"e7f75013cba9ba357144f66bbc600631c192e2cda9dd572794be239654e2cf49"},
{"name":"aws-sdk-s3","version":"1.156.0","platform":"ruby","checksum":"9302da1d1a70363308854d5065035f6c72cf8b8af895d8789487cd5c6b076a46"},
{"name":"aws-sdk-s3","version":"1.157.0","platform":"ruby","checksum":"e1e0c7a268e710a7ccf4a0f9d2c33e3ca685b06968c3048d907e3a792580e990"},
{"name":"aws-sigv4","version":"1.8.0","platform":"ruby","checksum":"84dd99768b91b93b63d1d8e53ee837cfd06ab402812772a7899a78f9f9117cbc"},
{"name":"axe-core-api","version":"4.9.1","platform":"ruby","checksum":"9ea7ac16bfee1cb3545345d210878aa8cccfb41b493e00fe1faab79af4d9fed8"},
{"name":"axe-core-rspec","version":"4.9.1","platform":"ruby","checksum":"31ef067bee36d6efb3f156a83aa2fb6ac721270a53fb9473f0268e325a3e6efd"},

View File

@ -330,7 +330,7 @@ GEM
aws-sdk-kms (1.76.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.156.0)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@ -1968,7 +1968,7 @@ DEPENDENCIES
awesome_print
aws-sdk-cloudformation (~> 1)
aws-sdk-core (~> 3.201.0)
aws-sdk-s3 (~> 1.156.0)
aws-sdk-s3 (~> 1.157.0)
axe-core-rspec (~> 4.9.0)
babosa (~> 2.0)
base32 (~> 0.3.0)

View File

@ -1,41 +1,38 @@
<script>
import { GlButton, GlSprintf, GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
import { GlButton, GlSprintf, GlDisclosureDropdown } from '@gitlab/ui';
import GITLAB_LOGO_SVG_URL from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg?url';
import { s__ } from '~/locale';
import { logError } from '~/lib/logger';
import { joinPaths, stripRelativeUrlRootFromPath } from '~/lib/utils/url_utility';
export default {
name: 'OAuthDomainMismatchError',
components: {
GlButton,
GlSprintf,
GlCollapsibleListbox,
GlIcon,
GlDisclosureDropdown,
},
props: {
callbackUrlOrigins: {
expectedCallbackUrl: {
type: String,
required: true,
},
callbackUrls: {
type: Array,
required: true,
},
},
computed: {
dropdownItems() {
return this.callbackUrlOrigins.map((domain) => {
return {
value: domain,
text: domain,
};
});
},
},
methods: {
reloadPage(urlDomain) {
try {
const current = new URL(urlDomain + window.location.pathname);
window.location.replace(current.toString());
} catch (e) {
logError(s__('IDE|Error reloading page'), e);
}
const currentOrigin = window.location.origin;
return this.callbackUrls
.filter(({ base }) => new URL(base).origin !== currentOrigin)
.map(({ base }) => {
return {
href: joinPaths(base, stripRelativeUrlRootFromPath(window.location.pathname)),
text: base,
};
});
},
},
gitlabLogo: GITLAB_LOGO_SVG_URL,
@ -50,6 +47,7 @@ export default {
description: s__(
"IDE|The URL you're using to access the Web IDE and the configured OAuth callback URL do not match. This issue often occurs when you're using a proxy.",
),
expected: s__('IDE|Could not find a callback URL entry for %{expectedCallbackUrl}.'),
contact: s__(
'IDE|Contact your administrator or try to open the Web IDE again with another domain.',
),
@ -64,27 +62,28 @@ export default {
<p>
{{ $options.i18n.description }}
</p>
<gl-sprintf :message="$options.i18n.expected">
<template #expectedCallbackUrl>
<code>{{ expectedCallbackUrl }}</code>
</template>
</gl-sprintf>
<p>
{{ $options.i18n.contact }}
</p>
<div class="gl-mt-6">
<gl-collapsible-listbox
v-if="callbackUrlOrigins.length > 1"
<gl-disclosure-dropdown
v-if="dropdownItems.length > 1"
:items="dropdownItems"
:header-text="$options.i18n.dropdownHeader"
@select="reloadPage"
:toggle-text="$options.i18n.buttonText.domains"
/>
<gl-button
v-else-if="dropdownItems.length === 1"
variant="confirm"
:href="dropdownItems[0].href"
>
<template #toggle>
<gl-button variant="confirm" class="self-center">
{{ $options.i18n.buttonText.domains }}
<gl-icon class="dropdown-chevron gl-ml-2" name="chevron-down" />
</gl-button>
</template>
</gl-collapsible-listbox>
<gl-button v-else variant="confirm" @click="reloadPage(callbackUrlOrigins[0])">
<gl-sprintf :message="$options.i18n.buttonText.singleDomain">
<template #domain>
{{ callbackUrlOrigins[0] }}
{{ dropdownItems[0].text }}
</template>
</gl-sprintf>
</gl-button>

View File

@ -112,3 +112,7 @@ export const DEFAULT_BRANCH = 'main';
export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367';
export const IDE_ELEMENT_ID = 'ide';
// note: This path comes from `config/routes.rb`
export const IDE_PATH = '/-/ide';
export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect';

View File

@ -24,24 +24,20 @@ export async function startIde(options) {
return;
}
const oAuthCallbackDomainMismatchApp = new OAuthCallbackDomainMismatchErrorApp(
ideElement,
ideElement.dataset.callbackUrls,
);
if (oAuthCallbackDomainMismatchApp.isVisitingFromNonRegisteredOrigin()) {
oAuthCallbackDomainMismatchApp.renderError();
return;
}
const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde);
if (useNewWebIde) {
const { initGitlabWebIDE } = await import('./init_gitlab_web_ide');
initGitlabWebIDE(ideElement);
} else {
if (!useNewWebIde) {
resetServiceWorkersPublicPath();
const { initLegacyWebIDE } = await import('./init_legacy_web_ide');
initLegacyWebIDE(ideElement, options);
}
const oAuthCallbackDomainMismatchApp = new OAuthCallbackDomainMismatchErrorApp(ideElement);
if (oAuthCallbackDomainMismatchApp.shouldRenderError()) {
oAuthCallbackDomainMismatchApp.renderError();
return;
}
const { initGitlabWebIDE } = await import('./init_gitlab_web_ide');
initGitlabWebIDE(ideElement);
}

View File

@ -4,12 +4,12 @@ const getGitLabUrl = (gitlabPath = '') => {
const path = joinPaths('/', window.gon.relative_url_root || '', gitlabPath);
const baseUrlObj = new URL(path, window.location.origin);
return cleanEndingSeparator(baseUrlObj.href);
return baseUrlObj.href;
};
export const getBaseConfig = () => ({
// baseUrl - The URL which hosts the Web IDE static web assets
baseUrl: getGitLabUrl(process.env.GITLAB_WEB_IDE_PUBLIC_PATH),
// baseUrl - The URL for the GitLab instance
gitlabUrl: getGitLabUrl(''),
baseUrl: cleanEndingSeparator(getGitLabUrl(process.env.GITLAB_WEB_IDE_PUBLIC_PATH)),
// gitlabUrl - The URL for the GitLab instance. End with trailing slash so URL's are built properly in relative_url_root.
gitlabUrl: getGitLabUrl('/'),
});

View File

@ -1,4 +1,4 @@
export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect';
import { getOAuthCallbackUrl } from './oauth_callback_urls';
export const getOAuthConfig = ({ clientId }) => {
if (!clientId) {
@ -8,7 +8,7 @@ export const getOAuthConfig = ({ clientId }) => {
return {
type: 'oauth',
clientId,
callbackUrl: new URL(WEB_IDE_OAUTH_CALLBACK_URL_PATH, window.location.origin).toString(),
callbackUrl: getOAuthCallbackUrl(),
protectRefreshToken: true,
};
};

View File

@ -0,0 +1,75 @@
import { joinPaths } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { WEB_IDE_OAUTH_CALLBACK_URL_PATH, IDE_PATH } from '../../constants';
/**
* @returns callback URL constructed from current window url
*/
export function getOAuthCallbackUrl() {
const url = window.location.href;
// We don't rely on `gon.gitlab_url` and `gon.relative_url_root` here because these may not be configured correctly
// or we're visiting the instance through a proxy.
// Instead, we split on the `/-/ide` in the `href` and use the first part as the base URL.
const baseUrl = url.split(IDE_PATH, 2)[0];
const callbackUrl = joinPaths(baseUrl, WEB_IDE_OAUTH_CALLBACK_URL_PATH);
return callbackUrl;
}
const parseCallbackUrl = (urlStr) => {
let callbackUrl;
try {
callbackUrl = new URL(urlStr);
} catch {
// Not a valid URL. Nothing to do here.
return undefined;
}
// If we're an unexpected callback URL
if (!callbackUrl.pathname.endsWith(WEB_IDE_OAUTH_CALLBACK_URL_PATH)) {
return {
base: joinPaths(callbackUrl.origin, '/'),
url: urlStr,
};
}
// Else, trim the expected bit to get the origin + relative_url_root
const callbackRelativePath = callbackUrl.pathname.substring(
0,
callbackUrl.pathname.length - WEB_IDE_OAUTH_CALLBACK_URL_PATH.length,
);
const baseUrl = new URL(callbackUrl);
baseUrl.pathname = callbackRelativePath;
baseUrl.hash = '';
baseUrl.search = '';
return {
base: joinPaths(baseUrl.toString(), '/'),
url: urlStr,
};
};
export const parseCallbackUrls = (callbackUrlsJson) => {
if (!callbackUrlsJson) {
return [];
}
let urls;
try {
urls = JSON.parse(callbackUrlsJson);
} catch {
// why: We dont want to translate console errors
// eslint-disable-next-line @gitlab/require-i18n-strings
logError('Failed to parse callback URLs JSON');
return [];
}
if (!urls || !Array.isArray(urls)) {
return [];
}
return urls.map(parseCallbackUrl).filter(Boolean);
};

View File

@ -1,48 +1,43 @@
import Vue from 'vue';
import OAuthDomainMismatchError from './components/oauth_domain_mismatch_error.vue';
import { parseCallbackUrls, getOAuthCallbackUrl } from './lib/gitlab_web_ide/oauth_callback_urls';
export class OAuthCallbackDomainMismatchErrorApp {
#el;
#callbackUrlOrigins;
#callbackUrls;
#expectedCallbackUrl;
constructor(el, callbackUrls) {
constructor(el) {
this.#el = el;
this.#callbackUrlOrigins =
OAuthCallbackDomainMismatchErrorApp.#getCallbackUrlOrigins(callbackUrls);
this.#callbackUrls = parseCallbackUrls(el.dataset.callbackUrls);
this.#expectedCallbackUrl = getOAuthCallbackUrl();
}
isVisitingFromNonRegisteredOrigin() {
return (
this.#callbackUrlOrigins.length && !this.#callbackUrlOrigins.includes(window.location.origin)
);
shouldRenderError() {
if (!this.#callbackUrls.length) {
return false;
}
return this.#callbackUrls.every(({ url }) => url !== this.#expectedCallbackUrl);
}
renderError() {
const callbackUrlOrigins = this.#callbackUrlOrigins;
const callbackUrls = this.#callbackUrls;
const expectedCallbackUrl = this.#expectedCallbackUrl;
const el = this.#el;
if (!el) return null;
return new Vue({
el,
data() {
return {
callbackUrlOrigins,
};
},
render(createElement) {
return createElement(OAuthDomainMismatchError, {
props: {
callbackUrlOrigins,
expectedCallbackUrl,
callbackUrls,
},
});
},
});
}
static #getCallbackUrlOrigins(callbackUrls) {
if (!callbackUrls) return [];
return JSON.parse(callbackUrls).map((url) => new URL(url).origin);
}
}

View File

@ -790,3 +790,14 @@ export function buildURLwithRefType({ base = window.location.origin, path, refTy
}
return url.pathname + url.search;
}
export function stripRelativeUrlRootFromPath(path) {
const relativeUrlRoot = joinPaths(window.gon.relative_url_root, '/');
// If we have no relative url root or path doesn't start with it, just return the path
if (relativeUrlRoot === '/' || !path.startsWith(relativeUrlRoot)) {
return path;
}
return joinPaths('/', path.substring(relativeUrlRoot.length));
}

View File

@ -0,0 +1,59 @@
<script>
import BlobChunks from '~/search/results/components/blob_chunks.vue';
import { DEFAULT_SHOW_CHUNKS } from '~/search/results/constants';
import eventHub from '../event_hub';
export default {
name: 'ZoektBlobResultsChunks',
components: {
BlobChunks,
},
props: {
file: {
type: Object,
required: true,
},
},
data() {
return {
showMore: false,
};
},
computed: {
projectPathAndFilePath() {
return `${this.file.projectPath}:${this.file.path}`;
},
},
mounted() {
eventHub.$on('showMore', this.toggleShowMore);
},
destroyed() {
eventHub.$off('showMore', this.toggleShowMore);
},
methods: {
toggleShowMore({ id, state }) {
if (id === this.projectPathAndFilePath) {
this.showMore = state;
}
},
chunksToShow(file) {
if (this.showMore) {
return file.chunks;
}
return file.chunks.slice(0, DEFAULT_SHOW_CHUNKS);
},
},
};
</script>
<template>
<div>
<div
v-for="(chunk, index) in chunksToShow(file)"
:key="`chunk${index}`"
class="chunks-block gl-border-slate-400 gl-border-b last:gl-border-0"
>
<blob-chunks :chunk="chunk" :blame-link="file.blameUrl" :file-url="file.fileUrl" />
</div>
</div>
</template>

View File

@ -0,0 +1,81 @@
<script>
import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui';
import GlSafeHtmlDirective from '~/vue_shared/directives/safe_html';
export default {
name: 'BlobChunks',
components: {
GlIcon,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
props: {
chunk: {
type: Object,
required: true,
},
blameLink: {
type: String,
required: false,
default: '',
},
fileUrl: {
type: String,
required: false,
default: '',
},
},
computed: {
codeTheme() {
return gon.user_color_scheme || 'white';
},
},
methods: {
highlightedRichText(richText) {
return richText.replace('<b>', '<b class="hll">');
},
},
};
</script>
<template>
<div id="search-blob-content" class="file-content code" :class="codeTheme">
<div class="blob-content" data-blob-id="" data-path="" data-highlight-line="">
<div
v-for="line in chunk.lines"
:key="line.lineNumber"
class="line_holder code-search-line gl-display-flex"
data-testid="search-blob-line"
>
<div class="line-numbers" data-testid="search-blob-line-numbers">
<div class="gl-display-flex">
<span class="diff-line-num gl-pl-3">
<gl-link
v-gl-tooltip
:href="`${blameLink}#L${line.lineNumber}`"
:title="__('View blame')"
class="js-navigation-open"
><gl-icon name="git"
/></gl-link>
</span>
<span class="diff-line-num flex-grow-1 gl-pr-3">
<gl-link
v-gl-tooltip
:href="`${fileUrl}#L${line.lineNumber}`"
:title="__('View Line in repository')"
class="gl-display-flex! gl-align-items-center gl-justify-content-end"
>{{ line.lineNumber }}</gl-link
>
</span>
</div>
</div>
<pre class="code highlight flex-grow-1" data-testid="search-blob-line-code">
<span v-safe-html="highlightedRichText(line.richText)"></span>
</pre>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,111 @@
<script>
import { GlSprintf, GlButton, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import { DEFAULT_FETCH_CHUNKS, DEFAULT_SHOW_CHUNKS } from '~/search/results/constants';
import eventHub from '../event_hub';
export default {
name: 'BlobFooter',
components: {
GlSprintf,
GlButton,
GlLink,
},
i18n: {
showMore: s__('GlobalSearch|Show %{matches} more matches'),
showLess: s__('GlobalSearch|Show less'),
showMoreInFile: s__(
'GlobalSearch|%{lessButtonStart}Show less%{lessButtonEnd} - Too many matches found. Showing %{showingMatches} chunks out of %{fileMatches} results. %{fileLinkStart}Open the file to view all.%{fileLinkEnd}',
),
},
props: {
file: {
type: Object,
required: true,
},
},
data() {
return {
showMore: false,
filePath: this.file?.path,
projectPath: this.file?.projectPath,
fileLink: this.file.url,
fileMatchCountTotal: this.file.matchCountTotal,
};
},
computed: {
howMuchMore() {
if (this.matchesTotal <= this.showingMatches) {
return 0;
}
return this.matchesTotal - this.showingMatches;
},
chunksTotal() {
return this.file.chunks.length;
},
showingChunks() {
const maxChunksToShow =
this.chunksTotal < DEFAULT_FETCH_CHUNKS ? this.chunksTotal : DEFAULT_FETCH_CHUNKS;
return this.showMore ? maxChunksToShow : DEFAULT_SHOW_CHUNKS;
},
showingMatches() {
return this.file.chunks
.slice(0, this.showingChunks)
.reduce((acc, chunk) => acc + chunk.matchCountInChunk, 0);
},
matchesTotal() {
return this.file.chunks.reduce((acc, chunk) => acc + chunk.matchCountInChunk, 0);
},
hasMoreWeCanShow() {
return (
this.showingChunks >= DEFAULT_FETCH_CHUNKS && this.fileMatchCountTotal > this.matchesTotal
);
},
},
methods: {
toggleShowMore() {
eventHub.$emit('showMore', {
id: `${this.projectPath}:${this.filePath}`,
state: (this.showMore = !this.showMore),
});
},
},
DEFAULT_FETCH_CHUNKS,
DEFAULT_SHOW_CHUNKS,
};
</script>
<template>
<div v-if="!showMore" data-testid="showing-less">
<gl-button category="tertiary" size="medium" @click="toggleShowMore">
<gl-sprintf :message="$options.i18n.showMore">
<template #matches>
<span>{{ howMuchMore }}</span>
</template>
</gl-sprintf>
</gl-button>
</div>
<div v-else-if="hasMoreWeCanShow" data-testid="has-more-we-show">
<gl-sprintf :message="$options.i18n.showMoreInFile">
<template #fileMatches
><span>{{ fileMatchCountTotal }}</span></template
>
<template #showingMatches>{{ showingMatches }}</template>
<template #fileLink="{ content }"
><gl-link :href="fileLink" target="_blank" data-testid="file-link">{{
content
}}</gl-link></template
>
<template #lessButton="{ content }"
><gl-button category="tertiary" size="medium" @click="toggleShowMore">{{
content
}}</gl-button>
</template>
</gl-sprintf>
</div>
<div v-else data-testid="showing-all">
<gl-button category="tertiary" size="medium" @click="toggleShowMore">
{{ $options.i18n.showLess }}
</gl-button>
</div>
</template>

View File

@ -0,0 +1,59 @@
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { s__ } from '~/locale';
export default {
name: 'BlobHeader',
components: {
FileIcon,
ClipboardButton,
},
props: {
filePath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
fileUrl: {
type: String,
required: false,
default: '',
},
},
i18n: {
fileLink: s__('GlobalSearch|Open file in repository'),
},
computed: {
gfmCopyText() {
return `\`${this.filePath}\``;
},
},
};
</script>
<template>
<div class="file-header-content gl-flex gl-align-items-center gl-leading-1">
<file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-3" />
<a :href="fileUrl" :title="$options.i18n.fileLink">
<template v-if="projectPath">
<strong class="project-path-content" data-testid="project-path-content"
>{{ projectPath }}:
</strong>
</template>
<strong class="file-name-content" data-testid="file-name-content">{{ filePath }}</strong>
</a>
<clipboard-button
:text="filePath"
:gfm="gfmCopyText"
:title="__('Copy file path')"
category="tertiary"
css-class="gl-mr-2"
/>
</div>
</template>

View File

@ -0,0 +1,73 @@
<script>
import { GlEmptyState, GlSprintf } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import emptySearchSVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg';
import { s__ } from '~/locale';
import { SCOPE_NAVIGATION_MAP } from '~/search/store/constants';
export default {
name: 'GlobalSearchResultsEmpty',
i18n: {
title: s__('GlobalSearch|No results found'),
descriptionProject: s__(
"GlobalSearch|We couldn't find any %{scope} matching %{term} in project %{project}",
),
descriptionGroup: s__(
"GlobalSearch|We couldn't find any %{scope} matching %{term} in group %{group}",
),
descriptionSimple: s__("GlobalSearch|We couldn't find any %{scope} matching %{term}"),
},
components: {
GlEmptyState,
GlSprintf,
},
computed: {
...mapState(['query', 'groupInitialJson', 'projectInitialJson']),
...mapGetters(['currentScope']),
},
emptySearchSVG,
SCOPE_NAVIGATION_MAP,
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
:svg-path="$options.emptySearchSVG"
description="No results found"
>
<template #description>
<gl-sprintf v-if="query.project_id" :message="$options.i18n.descriptionProject">
<template #scope>
<strong>{{ $options.SCOPE_NAVIGATION_MAP[currentScope] }}</strong>
</template>
<template #term>
<strong>{{ query.search }}</strong>
</template>
<template #project>
<strong>{{ projectInitialJson.name }}</strong>
</template>
</gl-sprintf>
<gl-sprintf v-else-if="query.group_id" :message="$options.i18n.descriptionGroup">
<template #scope>
<strong>{{ $options.SCOPE_NAVIGATION_MAP[currentScope] }}</strong>
</template>
<template #term>
<strong>{{ query.search }}</strong>
</template>
<template #group>
<strong>{{ groupInitialJson.name }}</strong>
</template>
</gl-sprintf>
<gl-sprintf v-else :message="$options.i18n.descriptionSimple">
<template #scope>
<strong>{{ $options.SCOPE_NAVIGATION_MAP[currentScope] }}</strong>
</template>
<template #term>
<strong>{{ query.search }}</strong>
</template>
</gl-sprintf>
</template>
</gl-empty-state>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlCard, GlPagination } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { createAlert } from '~/alert';
@ -12,12 +12,23 @@ import {
PROJECT_GRAPHQL_ID_TYPE,
GROUP_GRAPHQL_ID_TYPE,
SEARCH_RESULTS_DEBOUNCE,
DEFAULT_SHOW_CHUNKS,
} from '~/search/results/constants';
import BlobHeader from '~/search/results/components/blob_header.vue';
import BlobFooter from '~/search/results/components/blob_footer.vue';
import BlobBody from '~/search/results/components/blob_body.vue';
import EmptyResult from '~/search/results/components/result_empty.vue';
export default {
name: 'ZoektBlobResults',
components: {
GlLoadingIcon,
GlCard,
BlobHeader,
BlobFooter,
BlobBody,
GlPagination,
EmptyResult,
},
i18n: {
headerText: __('Search results'),
@ -69,6 +80,25 @@ export default {
isLoading() {
return this.$apollo.queries.blobSearch.loading;
},
hasResults() {
return this.blobSearch?.files?.length > 0;
},
},
methods: {
hasMore(file) {
const showingMatches = file.chunks
.slice(0, DEFAULT_SHOW_CHUNKS)
.reduce((acc, chunk) => acc + chunk.matchCountInChunk, 0);
const matchesTotal = file.chunks.reduce((acc, chunk) => acc + chunk.matchCountInChunk, 0);
return file.matchCount !== 0 && matchesTotal > showingMatches;
},
hasCode(file) {
return file?.chunks.length > 0;
},
projectPathAndFilePath({ projectPath = '', path = '' }) {
return `${projectPath}:${path}`;
},
},
};
</script>
@ -76,5 +106,41 @@ export default {
<template>
<div class="gl-flex gl-justify-center gl-flex-col">
<gl-loading-icon v-if="isLoading" size="sm" />
<div v-if="hasResults && !isLoading && !hasError" class="gl-relative">
<gl-card
v-for="file in blobSearch.files"
:key="projectPathAndFilePath(file)"
class="file-result-holder gl-my-5 file-holder"
:header-class="{
'gl-border-b-0!': !hasCode(file),
'gl-new-card-header file-title': true,
}"
footer-class="gl-new-card-footer"
body-class="gl-p-0"
>
<template #header>
<blob-header
:file-path="file.path"
:project-path="file.projectPath"
:file-url="file.fileUrl"
/>
</template>
<blob-body v-if="hasCode(file)" :file="file" />
<template v-if="hasMore(file)" #footer>
<blob-footer :file="file" />
</template>
</gl-card>
</div>
<empty-result v-else-if="!hasResults && !isLoading" />
<template v-if="hasResults && !isLoading && !hasError">
<gl-pagination
v-model="query.page"
class="gl-mx-auto"
:per-page="blobSearch.perPage"
:total-items="blobSearch.fileCount"
/>
</template>
</div>
</template>

View File

@ -2,3 +2,4 @@ export const DEFAULT_FETCH_CHUNKS = 50;
export const PROJECT_GRAPHQL_ID_TYPE = 'Project';
export const GROUP_GRAPHQL_ID_TYPE = 'Group';
export const SEARCH_RESULTS_DEBOUNCE = 500;
export const DEFAULT_SHOW_CHUNKS = 3;

View File

@ -0,0 +1,3 @@
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();

View File

@ -4,6 +4,7 @@ import { languageFilterData } from '~/search/sidebar/components/language_filter/
import { LABEL_FILTER_PARAM } from '~/search/sidebar/components/label_filter/data';
import { archivedFilterData } from '~/search/sidebar/components/archived_filter/data';
import { INCLUDE_FORKED_FILTER_PARAM } from '~/search/sidebar/components/forks_filter/index.vue';
import { s__ } from '~/locale';
export const MAX_FREQUENT_ITEMS = 5;
@ -40,6 +41,20 @@ export const ICON_MAP = {
snippet_titles: 'snippet',
};
export const SCOPE_NAVIGATION_MAP = {
blobs: s__(`GlobalSearch|Code`),
issues: s__(`GlobalSearch|Issues`),
epics: s__(`GlobalSearch|'Epics`),
merge_requests: s__(`GlobalSearch|Merge request`),
commits: s__(`GlobalSearch|Commits`),
notes: s__(`GlobalSearch|Comments`),
milestones: s__(`GlobalSearch|Milestones`),
users: s__(`GlobalSearch|Users`),
projects: s__(`GlobalSearch|Projects`),
wiki_blobs: s__(`GlobalSearch|Wiki`),
snippet_titles: s__(`GlobalSearch|Snippets`),
};
export const ZOEKT_SEARCH_TYPE = 'zoekt';
export const ADVANCED_SEARCH_TYPE = 'advanced';
export const BASIC_SEARCH_TYPE = 'basic';

View File

@ -25,4 +25,5 @@ export const FAILURE_REASONS = {
requested_changes: __('The change requests must be completed or resolved.'),
approvals_syncing: __('The merge request approvals are currently syncing.'),
locked_lfs_files: __('All LFS files must be unlocked.'),
security_policy_evaluation: __('All security policies must be evaluated.'),
};

View File

@ -0,0 +1,103 @@
<script>
import { GlForm, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { s__, __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import { CATEGORY_OPTIONS } from '~/abuse_reports/components/constants';
export default {
name: 'WorkItemAbuseModal',
csrf,
i18n: {
title: __('Report abuse to administrator'),
label: s__('ReportAbuse|Why are you reporting this user?'),
},
modal: {
id: uniqueId('work-item-abuse-modal-'),
actionPrimary: {
text: __('Next'),
attributes: {
variant: 'confirm',
},
},
actionSecondary: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
CATEGORY_OPTIONS,
components: {
GlForm,
GlFormGroup,
GlFormRadioGroup,
GlModal,
},
inject: {
reportAbusePath: {
default: '',
},
},
props: {
reportedUserId: {
type: Number,
required: true,
},
reportedFromUrl: {
type: String,
required: false,
default: '',
},
showModal: {
type: Boolean,
required: true,
},
},
data() {
return {
selectedOption: CATEGORY_OPTIONS[0].value,
};
},
methods: {
closeModal() {
this.$emit('close-modal');
},
submitForm() {
this.$refs.form.$el.submit();
},
},
};
</script>
<template>
<gl-modal
size="sm"
:visible="showModal"
:modal-id="$options.modal.id"
:title="$options.i18n.title"
:action-primary="$options.modal.actionPrimary"
:action-secondary="$options.modal.actionSecondary"
@primary="submitForm"
@hide="closeModal"
>
<gl-form ref="form" :action="reportAbusePath" method="post">
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<input type="hidden" name="user_id" :value="reportedUserId" data-testid="input-user-id" />
<input
type="hidden"
name="abuse_report[reported_from_url]"
:value="reportedFromUrl"
data-testid="input-referer"
/>
<gl-form-group :label="$options.i18n.label">
<gl-form-radio-group
v-model="selectedOption"
:options="$options.CATEGORY_OPTIONS"
name="abuse_report[category]"
/>
</gl-form-group>
</gl-form>
</gl-modal>
</template>

View File

@ -37,6 +37,7 @@ import {
I18N_WORK_ITEM_ERROR_COPY_REFERENCE,
I18N_WORK_ITEM_ERROR_COPY_EMAIL,
TEST_ID_LOCK_ACTION,
TEST_ID_REPORT_ABUSE,
} from '../constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
@ -55,6 +56,7 @@ export default {
referenceCopied: __('Reference copied'),
emailAddressCopied: __('Email address copied'),
moreActions: __('More actions'),
reportAbuse: __('Report abuse'),
},
components: {
GlDisclosureDropdown,
@ -79,6 +81,7 @@ export default {
promoteActionTestId: TEST_ID_PROMOTE_ACTION,
lockDiscussionTestId: TEST_ID_LOCK_ACTION,
stateToggleTestId: TEST_ID_TOGGLE_ACTION,
reportAbuseActionTestId: TEST_ID_REPORT_ABUSE,
props: {
fullPath: {
type: String,
@ -164,6 +167,11 @@ export default {
required: false,
default: false,
},
workItemAuthorId: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
@ -228,6 +236,9 @@ export default {
showDropdownTooltip() {
return !this.isDropdownVisible ? this.$options.i18n.moreActions : '';
},
isAuthor() {
return this.workItemAuthorId === window.gon.current_user_id;
},
},
methods: {
copyToClipboard(text, message) {
@ -356,6 +367,10 @@ export default {
emitStateToggleError(error) {
this.$emit('error', error);
},
handleToggleReportAbuseModal() {
this.$emit('toggleReportAbuseModal', true);
this.closeDropdown();
},
},
};
</script>
@ -452,8 +467,16 @@ export default {
<template #list-item>{{ i18n.copyCreateNoteEmail }}</template>
</gl-disclosure-dropdown-item>
<gl-dropdown-divider />
<gl-disclosure-dropdown-item
v-if="!isAuthor"
:data-testid="$options.reportAbuseActionTestId"
@action="handleToggleReportAbuseModal"
>
<template #list-item>{{ $options.i18n.reportAbuse }}</template>
</gl-disclosure-dropdown-item>
<template v-if="canDelete">
<gl-dropdown-divider />
<gl-disclosure-dropdown-item
:data-testid="$options.deleteActionTestId"
variant="danger"

View File

@ -8,7 +8,6 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { WORKSPACE_PROJECT } from '~/issues/constants';
import {
i18n,
@ -50,6 +49,7 @@ import WorkItemStickyHeader from './work_item_sticky_header.vue';
import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemLoading from './work_item_loading.vue';
import WorkItemAbuseModal from './work_item_abuse_modal.vue';
import DesignWidget from './design_management/design_management_widget.vue';
export default {
@ -74,12 +74,12 @@ export default {
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
AbuseCategorySelector,
WorkItemRelationships,
WorkItemStickyHeader,
WorkItemAncestors,
WorkItemTitle,
WorkItemLoading,
WorkItemAbuseModal,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath', 'reportAbusePath', 'groupPath', 'hasSubepicsFeature'],
@ -114,7 +114,7 @@ export default {
modalWorkItemId: undefined,
modalWorkItemIid: getParameterByName('work_item_iid'),
modalWorkItemNamespaceFullPath: '',
isReportDrawerOpen: false,
isReportModalOpen: false,
reportedUrl: '',
reportedUserId: 0,
isStickyHeaderShowing: false,
@ -201,6 +201,9 @@ export default {
workItemTypeId() {
return this.workItem.workItemType?.id;
},
workItemAuthorId() {
return getIdFromGraphQLId(this.workItem.author?.id);
},
canUpdate() {
return this.workItem.userPermissions?.updateWorkItem;
},
@ -422,17 +425,17 @@ export default {
);
this.$refs.modal.show();
},
openReportAbuseDrawer(reply) {
openReportAbuseModal(reply) {
if (this.isModal) {
this.$emit('openReportAbuse', reply);
} else {
this.toggleReportAbuseDrawer(true, reply);
this.toggleReportAbuseModal(true, reply);
}
},
toggleReportAbuseDrawer(isOpen, reply = {}) {
this.isReportDrawerOpen = isOpen;
this.reportedUrl = reply.url || {};
this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
toggleReportAbuseModal(isOpen, workItem = this.workItem) {
this.isReportModalOpen = isOpen;
this.reportedUrl = workItem.webUrl || workItem.url || {};
this.reportedUserId = workItem.author ? getIdFromGraphQLId(workItem.author.id) : 0;
},
hideStickyHeader() {
this.isStickyHeaderShowing = false;
@ -499,6 +502,7 @@ export default {
:work-item="workItem"
:is-sticky-header-showing="isStickyHeaderShowing"
:work-item-notifications-subscribed="workItemNotificationsSubscribed"
:work-item-author-id="workItemAuthorId"
@hideStickyHeader="hideStickyHeader"
@showStickyHeader="showStickyHeader"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@ -507,6 +511,7 @@ export default {
@promotedToObjective="$emit('promotedToObjective', workItemIid)"
@toggleEditMode="enableEditMode"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="toggleReportAbuseModal"
/>
<section class="work-item-view">
<section v-if="updateError" class="flash-container flash-container-page sticky">
@ -592,11 +597,13 @@ export default {
:is-modal="isModal"
:work-item-state="workItem.state"
:has-children="hasChildren"
:work-item-author-id="workItemAuthorId"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
@promotedToObjective="$emit('promotedToObjective', workItemIid)"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="toggleReportAbuseModal"
/>
</div>
<gl-button
@ -714,7 +721,7 @@ export default {
:use-h2="!isModal"
@error="updateError = $event"
@has-notes="updateHasNotes"
@openReportAbuse="openReportAbuseDrawer"
@openReportAbuse="openReportAbuseModal"
/>
</div>
</div>
@ -728,14 +735,14 @@ export default {
:work-item-full-path="modalWorkItemNamespaceFullPath"
:show="true"
@close="updateUrl"
@openReportAbuse="toggleReportAbuseDrawer(true, $event)"
@openReportAbuse="toggleReportAbuseModal(true, $event)"
/>
<abuse-category-selector
v-if="isReportDrawerOpen"
<work-item-abuse-modal
v-if="isReportModalOpen"
:show-modal="isReportModalOpen"
:reported-user-id="reportedUserId"
:reported-from-url="reportedUrl"
:show-drawer="true"
@close-drawer="toggleReportAbuseDrawer(false)"
@close-modal="toggleReportAbuseModal(false)"
/>
</div>
</template>

View File

@ -97,8 +97,7 @@ export default {
updateHasNotes() {
this.hasNotes = true;
},
openReportAbuseDrawer(reply) {
this.hide();
openReportAbuseModal(reply) {
this.$emit('openReportAbuse', reply);
},
},
@ -132,7 +131,7 @@ export default {
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
@has-notes="updateHasNotes"
@openReportAbuse="openReportAbuseDrawer"
@openReportAbuse="openReportAbuseModal"
/>
</gl-modal>
</template>

View File

@ -14,7 +14,6 @@ import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
FORM_TYPES,
@ -30,6 +29,7 @@ import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
import WorkItemChildrenLoadMore from '../shared/work_item_children_load_more.vue';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
import WorkItemAbuseModal from '../work_item_abuse_modal.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
@ -42,7 +42,7 @@ export default {
WidgetWrapper,
WorkItemLinksForm,
WorkItemDetailModal,
AbuseCategorySelector,
WorkItemAbuseModal,
WorkItemChildrenWrapper,
WorkItemChildrenLoadMore,
GlToggle,
@ -109,7 +109,7 @@ export default {
parentIssue: null,
formType: null,
workItem: null,
isReportDrawerOpen: false,
isReportModalOpen: false,
reportedUserId: 0,
reportedUrl: '',
widgetName: TASKS_ANCHOR,
@ -206,13 +206,13 @@ export default {
updateWorkItemIdUrlQuery({ iid } = {}) {
updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true });
},
toggleReportAbuseDrawer(isOpen, reply = {}) {
this.isReportDrawerOpen = isOpen;
toggleReportAbuseModal(isOpen, reply = {}) {
this.isReportModalOpen = isOpen;
this.reportedUrl = reply.url;
this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
},
openReportAbuseDrawer(reply) {
this.toggleReportAbuseDrawer(true, reply);
openReportAbuseModal(reply) {
this.toggleReportAbuseModal(true, reply);
},
async fetchNextPage() {
if (this.hasNextPage && !this.fetchNextPageInProgress) {
@ -355,14 +355,14 @@ export default {
:work-item-full-path="activeChildNamespaceFullPath"
@close="closeModal"
@workItemDeleted="handleWorkItemDeleted(activeChild)"
@openReportAbuse="openReportAbuseDrawer"
@openReportAbuse="openReportAbuseModal"
/>
<abuse-category-selector
v-if="isReportDrawerOpen && reportAbusePath"
<work-item-abuse-modal
v-if="isReportModalOpen && reportAbusePath"
:show-modal="isReportModalOpen"
:reported-user-id="reportedUserId"
:reported-from-url="reportedUrl"
:show-drawer="isReportDrawerOpen"
@close-drawer="toggleReportAbuseDrawer(false)"
@close-modal="toggleReportAbuseModal(false)"
/>
</template>
</div>

View File

@ -62,6 +62,11 @@ export default {
required: false,
default: () => [],
},
workItemAuthorId: {
type: Number,
required: false,
default: 0,
},
},
computed: {
canUpdate() {
@ -153,6 +158,7 @@ export default {
:work-item-create-note-email="workItem.createNoteEmail"
:work-item-state="workItem.state"
:is-modal="isModal"
:work-item-author-id="workItemAuthorId"
@deleteWorkItem="$emit('deleteWorkItem')"
@toggleWorkItemConfidentiality="
$emit('toggleWorkItemConfidentiality', !workItem.confidential)
@ -160,6 +166,7 @@ export default {
@error="$emit('error')"
@promotedToObjective="$emit('promotedToObjective')"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="$emit('toggleReportAbuseModal', true)"
/>
</div>
</div>

View File

@ -277,6 +277,7 @@ export const TEST_ID_LOCK_ACTION = 'lock-action';
export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action';
export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action';
export const TEST_ID_TOGGLE_ACTION = 'state-toggle-action';
export const TEST_ID_REPORT_ABUSE = 'report-abuse-action';
export const TODO_ADD_ICON = 'todo-add';
export const TODO_DONE_ICON = 'todo-done';

View File

@ -60,6 +60,9 @@ module Types
value 'LOCKED_LFS_FILES',
value: :locked_lfs_files,
description: 'Merge request includes locked LFS files.'
value 'SECURITY_POLICIES_EVALUATING',
value: :security_policy_evaluation,
description: 'All security policies must be evaluated.'
end
end
end

View File

@ -1274,7 +1274,8 @@ class MergeRequest < ApplicationRecord
skip_external_status_check: merge_when_checks_pass_strat,
skip_requested_changes_check: merge_when_checks_pass_strat,
skip_jira_check: merge_when_checks_pass_strat,
skip_locked_lfs_files_check: merge_when_checks_pass_strat
skip_locked_lfs_files_check: merge_when_checks_pass_strat,
skip_security_policy_check: merge_when_checks_pass_strat
}
end

View File

@ -20,6 +20,7 @@ ActiveSupport::Inflector.inflections do |inflect|
dependency_proxy_blob_registry
design_management_repository_registry
dependency_proxy_manifest_registry
duo_enterprise
duo_pro
event_log
file_registry

View File

@ -145,6 +145,7 @@ InitializerConnections.raise_if_new_database_connection do
scope :ide, as: :ide, format: false do
get '/', to: 'ide#index'
get '/project', to: 'ide#index'
# note: This path has a hardcoded reference in the FE `app/assets/javascripts/ide/constants.js`
get '/oauth_redirect', to: 'ide#oauth_redirect'
scope path: 'project/:project_id', as: :project, constraints: { project_id: Gitlab::PathRegex.full_namespace_route_regex } do

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
require_relative '../../tooling/danger/settings_sections'
module Danger
class SettingsSections < ::Danger::Plugin
include Tooling::Danger::SettingsSections
end
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
settings_sections.check!

View File

@ -8,4 +8,4 @@ description: CI/CD variables available to all projects and groups in an instance
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30156
milestone: '13.0'
gitlab_schema: gitlab_ci
sharding_key_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/459039
exempt_from_sharding: true # table not used in .com nor Cells.

View File

@ -35344,6 +35344,7 @@ Detailed representation of whether a GitLab merge request can be merged.
| <a id="detailedmergestatusnot_open"></a>`NOT_OPEN` | Merge request must be open before merging. |
| <a id="detailedmergestatuspreparing"></a>`PREPARING` | Merge request diff is being created. |
| <a id="detailedmergestatusrequested_changes"></a>`REQUESTED_CHANGES` | Indicates a reviewer has requested changes. |
| <a id="detailedmergestatussecurity_policies_evaluating"></a>`SECURITY_POLICIES_EVALUATING` | All security policies must be evaluated. |
| <a id="detailedmergestatusunchecked"></a>`UNCHECKED` | Merge status has not been checked. |
### `DiffPositionType`
@ -36119,6 +36120,7 @@ Representation of mergeability check identifier.
| <a id="mergeabilitycheckidentifiernot_approved"></a>`NOT_APPROVED` | Checks whether the merge request is approved. |
| <a id="mergeabilitycheckidentifiernot_open"></a>`NOT_OPEN` | Checks whether the merge request is open. |
| <a id="mergeabilitycheckidentifierrequested_changes"></a>`REQUESTED_CHANGES` | Checks whether the merge request has changes requested. |
| <a id="mergeabilitycheckidentifiersecurity_policy_evaluation"></a>`SECURITY_POLICY_EVALUATION` | Checks whether the security policies are evaluated. |
| <a id="mergeabilitycheckidentifierstatus_checks_must_pass"></a>`STATUS_CHECKS_MUST_PASS` | Checks whether the external status checks pass. |
### `MergeabilityCheckStatus`

View File

@ -5,7 +5,7 @@ group: unassigned
description: 'Guidelines for deprecations and page removals'
---
## Deprecations and removals
# Deprecations and removals
When GitLab deprecates or removes a feature, use the following process to update the documentation.
This process requires temporarily changing content to be "deprecated" or "removed" before it's deleted.
@ -16,7 +16,7 @@ NOTE:
A separate process exists for [GraphQL docs](../../api_graphql_styleguide.md#deprecating-schema-items)
and [REST API docs](../restful_api_styleguide.md#deprecations).
### Deprecate a page or topic
## Deprecate a page or topic
To deprecate a page or topic:
@ -67,7 +67,7 @@ To deprecate a page or topic:
1. Open a merge request to add the word `(deprecated)` to the left nav, after the page title.
### Remove a page
## Remove a page
Mark content as removed during the release the feature was removed.
The title and a removed indicator remains until three months after the removal.
@ -107,7 +107,7 @@ To remove a page:
This content is removed from the documentation as part of the Technical Writing team's
[regularly scheduled tasks](https://handbook.gitlab.com/handbook/product/ux/technical-writing/#regularly-scheduled-tasks).
### Remove a topic
## Remove a topic
To remove a topic:
@ -136,7 +136,7 @@ To remove a topic:
This content is removed from the documentation as part of the Technical Writing team's
[regularly scheduled tasks](https://handbook.gitlab.com/handbook/product/ux/technical-writing/#regularly-scheduled-tasks).
### Removing version-specific upgrade pages
## Removing version-specific upgrade pages
Version-specific upgrade pages are in the `doc/update/versions/` directory.

View File

@ -4,7 +4,7 @@ group: Project Management
info: Any user with at least the Maintainer role can merge updates to this content. For details, see https://docs.gitlab.com/ee/development/development_processes.html#development-guidelines-review.
---
## Work items development
# Work items development
- Work item lists are only available at group level `http://gdk.test:3000/groups/flightjs/-/work_items`,
they are enabled with feature flags: `namespace_level_work_items` and `work_item_epics_rollout`.

View File

@ -95,12 +95,23 @@ Prerequisites:
- You must be an administrator.
In GitLab 15.7 and later, you can [use the application settings API to disable personal access tokens](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls).
Depending on your GitLab version, you can use either the application settings API
or the Admin UI to disable personal access tokens.
### Use the application settings API
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/384201) in GitLab 15.7.
In GitLab 15.7 and later, you can use the [`disable_personal_access_tokens` attribute in the application settings API](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls) to disable personal access tokens.
NOTE:
After you have used the API to disable personal access tokens, those tokens cannot be used in subsequent API calls to manage this setting. To re-enable personal access tokens, you must use the [GitLab Rails console](../../administration/operations/rails_console.md). You can also upgrade to GitLab 17.3 or later so you can use the Admin UI instead.
In GitLab 17.3 and later, you can disable personal access tokens in the Admin UI:
### Use the Admin UI
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/436991) in GitLab 17.3.
In GitLab 17.3 and later, you can use the Admin UI to disable personal access tokens:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > General**.

View File

@ -121,7 +121,7 @@ When you're ready to start coding:
1. Open relevant files, including configuration files, to provide better context.
1. Close any files you don't want to be used as context.
## View Multiple Code Suggestions
## View multiple code suggestions
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/issues/1325) in GitLab 17.1.
@ -130,10 +130,14 @@ might be available. To view all available suggestions:
1. Hover over the code completion suggestion.
1. Scroll through the alternatives. Either:
- Use keyboard shortcuts. Press <kbd>Option</kbd> + <kbd>`]`</kbd> to view the
next suggestion, and <kbd>Option</kbd> + <kbd>`[`</kbd> to view the previous
suggestions.
- Select the right or left arrow to see next or previous options.
- Use keyboard shortcuts:
- On a Mac, press <kbd>Option</kbd> + <kbd>]</kbd> to view the
next suggestion, and <kbd>Option</kbd> + <kbd>&#91;</kbd> to view the previous
suggestions.
- On Windows, press <kbd>Alt</kbd> + <kbd>]</kbd> to view the
next suggestion, and <kbd>Alt</kbd> + <kbd>&#91;</kbd> to view the previous
suggestions.
- On the dialog that's displayed, select the right or left arrow to see next or previous options.
1. Press <kbd>Tab</kbd> to apply the suggestion you prefer.
## Add additional languages for Code Suggestions

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
module Search
# Generates a list of all available setting sections of a group.
# This list is used by the command palette's search functionality.
class GroupSettings
include Rails.application.routes.url_helpers

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
module Search
# Generates a list of all available setting sections of a project.
# This list is used by the command palette's search functionality.
class ProjectSettings
include Rails.application.routes.url_helpers

View File

@ -1716,9 +1716,6 @@ msgstr ""
msgid "- Push code to the repository."
msgstr ""
msgid "- Select -"
msgstr ""
msgid "- User"
msgid_plural "- Users"
msgstr[0] ""
@ -5449,6 +5446,9 @@ msgstr ""
msgid "All required approvals must be given."
msgstr ""
msgid "All security policies must be evaluated."
msgstr ""
msgid "All threads resolved!"
msgstr ""
@ -13375,7 +13375,7 @@ msgstr ""
msgid "Company"
msgstr ""
msgid "Company Name"
msgid "Company name"
msgstr ""
msgid "Compare"
@ -15525,7 +15525,7 @@ msgstr ""
msgid "Couldn't reorder child due to an internal error."
msgstr ""
msgid "Country / Region"
msgid "Country or region"
msgstr ""
msgid "Counts"
@ -19665,6 +19665,18 @@ msgstr ""
msgid "DuoCodeReview|I have encountered some issues while I was reviewing. Please try again later."
msgstr ""
msgid "DuoEnterpriseTrial|Start your free Duo Enterprise trial"
msgstr ""
msgid "DuoEnterpriseTrial|Start your free GitLab Duo Enterprise trial"
msgstr ""
msgid "DuoEnterpriseTrial|Start your free GitLab Duo Enterprise trial on %{group_name}"
msgstr ""
msgid "DuoEnterpriseTrial|We just need some additional information to activate your trial."
msgstr ""
msgid "DuoProDiscover|Accelerate your path to market"
msgstr ""
@ -22826,9 +22838,6 @@ msgstr ""
msgid "Finished"
msgstr ""
msgid "First Name"
msgstr ""
msgid "First Seen"
msgstr ""
@ -24392,12 +24401,18 @@ msgstr ""
msgid "GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list."
msgstr ""
msgid "GlobalSearch|%{lessButtonStart}Show less%{lessButtonEnd} - Too many matches found. Showing %{showingMatches} chunks out of %{fileMatches} results. %{fileLinkStart}Open the file to view all.%{fileLinkEnd}"
msgstr ""
msgid "GlobalSearch|%{linkStart}Exact code search (powered by Zoekt)%{linkEnd} is disabled since %{ref_elem} is not the default branch. %{docs_link}"
msgstr ""
msgid "GlobalSearch|%{linkStart}Exact code search (powered by Zoekt)%{linkEnd} is enabled."
msgstr ""
msgid "GlobalSearch|'Epics"
msgstr ""
msgid "GlobalSearch|Aggregations load error."
msgstr ""
@ -24410,12 +24425,21 @@ msgstr ""
msgid "GlobalSearch|Change context %{kbdStart}↵%{kbdEnd}"
msgstr ""
msgid "GlobalSearch|Code"
msgstr ""
msgid "GlobalSearch|Command palette"
msgstr ""
msgid "GlobalSearch|Commands %{superKey} %{link2Start}k%{link2End}"
msgstr ""
msgid "GlobalSearch|Comments"
msgstr ""
msgid "GlobalSearch|Commits"
msgstr ""
msgid "GlobalSearch|Could not load search results. Please refresh the page to try again."
msgstr ""
@ -24500,6 +24524,9 @@ msgstr ""
msgid "GlobalSearch|Merge Requests"
msgstr ""
msgid "GlobalSearch|Merge request"
msgstr ""
msgid "GlobalSearch|Merge requests I've created"
msgstr ""
@ -24509,9 +24536,15 @@ msgstr ""
msgid "GlobalSearch|Merge requests that I'm a reviewer"
msgstr ""
msgid "GlobalSearch|Milestones"
msgstr ""
msgid "GlobalSearch|No labels found"
msgstr ""
msgid "GlobalSearch|No results found"
msgstr ""
msgid "GlobalSearch|No results found. Edit your search and try again."
msgstr ""
@ -24521,6 +24554,9 @@ msgstr ""
msgid "GlobalSearch|Only first %{max_shown} of not indexed projects is shown"
msgstr ""
msgid "GlobalSearch|Open file in repository"
msgstr ""
msgid "GlobalSearch|Pages or actions"
msgstr ""
@ -24587,12 +24623,21 @@ msgstr ""
msgid "GlobalSearch|Settings"
msgstr ""
msgid "GlobalSearch|Show %{matches} more matches"
msgstr ""
msgid "GlobalSearch|Show less"
msgstr ""
msgid "GlobalSearch|Show more"
msgstr ""
msgid "GlobalSearch|Showing top %{maxItems}"
msgstr ""
msgid "GlobalSearch|Snippets"
msgstr ""
msgid "GlobalSearch|The search term must be at least 3 characters long."
msgstr ""
@ -24629,9 +24674,21 @@ msgstr ""
msgid "GlobalSearch|View syntax options."
msgstr ""
msgid "GlobalSearch|We couldn't find any %{scope} matching %{term}"
msgstr ""
msgid "GlobalSearch|We couldn't find any %{scope} matching %{term} in group %{group}"
msgstr ""
msgid "GlobalSearch|We couldn't find any %{scope} matching %{term} in project %{project}"
msgstr ""
msgid "GlobalSearch|What are you searching for?"
msgstr ""
msgid "GlobalSearch|Wiki"
msgstr ""
msgid "GlobalSearch|Your work"
msgstr ""
@ -26750,15 +26807,15 @@ msgstr ""
msgid "IDE|Contact your administrator or try to open the Web IDE again with another domain."
msgstr ""
msgid "IDE|Could not find a callback URL entry for %{expectedCallbackUrl}."
msgstr ""
msgid "IDE|Edit"
msgstr ""
msgid "IDE|Editing this application might affect the functionality of the Web IDE. Ensure the configuration meets the following conditions:"
msgstr ""
msgid "IDE|Error reloading page"
msgstr ""
msgid "IDE|GitLab logo"
msgstr ""
@ -30876,9 +30933,6 @@ msgstr ""
msgid "Last GitLab activity"
msgstr ""
msgid "Last Name"
msgstr ""
msgid "Last Seen"
msgstr ""
@ -39888,9 +39942,6 @@ msgstr ""
msgid "Please select a Jira project"
msgstr ""
msgid "Please select a country / region"
msgstr ""
msgid "Please select a group"
msgstr ""
@ -49240,6 +49291,9 @@ msgstr ""
msgid "Select a comment template"
msgstr ""
msgid "Select a country or region"
msgstr ""
msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes."
msgstr ""
@ -56302,12 +56356,15 @@ msgstr ""
msgid "Trials|Your trial ends on %{boldStart}%{trialEndDate}%{boldEnd}. We hope youre enjoying the features of GitLab %{planName}. To keep those features after your trial ends, youll need to buy a subscription. (You can also choose GitLab Premium if it meets your needs.)"
msgstr ""
msgid "Trial| By selecting Continue or registering through a third party, you accept the %{gitlabSubscriptionAgreement} and acknowledge the %{privacyStatement} and %{cookiePolicy}"
msgid "Trial|Activate my trial"
msgstr ""
msgid "Trial|Allowed characters: +, 0-9, -, and spaces."
msgstr ""
msgid "Trial|By clicking \"%{buttonText}\" you accept the %{gitlabSubscriptionAgreement} and acknowledge the %{privacyStatement} and %{cookiePolicy}"
msgstr ""
msgid "Trial|Continue"
msgstr ""
@ -56317,16 +56374,19 @@ msgstr ""
msgid "Trial|GitLab Subscription Agreement"
msgstr ""
msgid "Trial|Please select"
msgid "Trial|Privacy Statement"
msgstr ""
msgid "Trial|Privacy Statement"
msgid "Trial|Select number of employees"
msgstr ""
msgid "Trial|Select state or province"
msgstr ""
msgid "Trial|Start free GitLab Ultimate trial"
msgstr ""
msgid "Trial|State/Province"
msgid "Trial|State or province"
msgstr ""
msgid "Trial|To activate your trial, we need additional details from you."
@ -58668,6 +58728,9 @@ msgstr ""
msgid "View File Metadata"
msgstr ""
msgid "View Line in repository"
msgstr ""
msgid "View Stage: %{title}"
msgstr ""
@ -59527,6 +59590,9 @@ msgstr ""
msgid "We're experiencing difficulties and this tab content is currently unavailable."
msgstr ""
msgid "We're sorry, your GitLab Duo Enterprise trial could not be created because our system did not respond successfully."
msgstr ""
msgid "We're sorry, your GitLab Duo Pro trial could not be created because our system did not respond successfully."
msgstr ""

View File

@ -71,7 +71,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/svgs": "3.109.0",
"@gitlab/ui": "87.8.0",
"@gitlab/ui": "88.0.0",
"@gitlab/web-ide": "^0.0.1-dev-20240613133550",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.0.8-4",

View File

@ -71,7 +71,7 @@ module QA
runner.remove_via_api!
end
it 'runs in project pipeline with correct inputs', :aggregate_failures,
it 'runs in project pipeline with correct inputs', :blocking, :aggregate_failures,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/451582' do
Flow::Pipeline.visit_latest_pipeline

View File

@ -1,87 +1,111 @@
import { GlButton, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { GlButton, GlDisclosureDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import OAuthDomainMismatchError from '~/ide/components/oauth_domain_mismatch_error.vue';
const MOCK_CALLBACK_URL_ORIGIN = 'https://example1.com';
const MOCK_PATH_NAME = '/path/to/ide';
const MOCK_CALLBACK_URLS = [
{
base: 'https://example1.com/',
},
{
base: 'https://example2.com/',
},
{
base: 'https://example3.com/relative-path/',
},
];
const MOCK_CALLBACK_URL = 'https://example.com';
const MOCK_PATH_NAME = 'path/to/ide';
const EXPECTED_DROPDOWN_ITEMS = MOCK_CALLBACK_URLS.map(({ base }) => ({
text: base,
href: `${base}${MOCK_PATH_NAME}`,
}));
describe('OAuthDomainMismatchError', () => {
useMockLocationHelper();
let wrapper;
let originalLocation;
const findButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
const findDropdownItems = () => wrapper.findAllComponents(GlListboxItem);
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const createWrapper = (props = {}) => {
wrapper = mount(OAuthDomainMismatchError, {
propsData: {
callbackUrlOrigins: [MOCK_CALLBACK_URL_ORIGIN],
expectedCallbackUrl: MOCK_CALLBACK_URL,
callbackUrls: MOCK_CALLBACK_URLS,
...props,
},
});
};
beforeEach(() => {
originalLocation = window.location;
window.location.pathname = MOCK_PATH_NAME;
});
afterEach(() => {
window.location = originalLocation;
setWindowLocation(`/${MOCK_PATH_NAME}`);
});
describe('single callback URL domain passed', () => {
beforeEach(() => {
createWrapper();
createWrapper({
callbackUrls: MOCK_CALLBACK_URLS.slice(0, 1),
});
});
it('renders expected callback URL message', () => {
expect(wrapper.text()).toContain(
`Could not find a callback URL entry for ${MOCK_CALLBACK_URL}.`,
);
});
it('does not render dropdown', () => {
expect(findDropdown().exists()).toBe(false);
});
it('reloads page with correct url on button click', async () => {
findButton().vm.$emit('click');
await nextTick();
expect(window.location.replace).toHaveBeenCalledTimes(1);
expect(window.location.replace).toHaveBeenCalledWith(
new URL(MOCK_CALLBACK_URL_ORIGIN + MOCK_PATH_NAME).toString(),
);
it('renders button with correct attributes', () => {
const button = findButton();
expect(button.exists()).toBe(true);
const baseUrl = MOCK_CALLBACK_URLS[0].base;
expect(button.text()).toContain(baseUrl);
expect(button.attributes('href')).toBe(`${baseUrl}${MOCK_PATH_NAME}`);
});
});
describe('multiple callback URL domains passed', () => {
const MOCK_CALLBACK_URL_ORIGINS = [MOCK_CALLBACK_URL_ORIGIN, 'https://example2.com'];
beforeEach(() => {
createWrapper({ callbackUrlOrigins: MOCK_CALLBACK_URL_ORIGINS });
createWrapper();
});
it('renders dropdown', () => {
expect(findDropdown().exists()).toBe(true);
it('renders dropdown with correct items', () => {
const dropdown = findDropdown();
expect(dropdown.exists()).toBe(true);
expect(dropdown.props('items')).toStrictEqual(EXPECTED_DROPDOWN_ITEMS);
});
});
describe('with erroneous callback from current origin', () => {
beforeEach(() => {
createWrapper({
callbackUrls: MOCK_CALLBACK_URLS.concat({
base: `${TEST_HOST}/foo`,
}),
});
});
it('renders dropdown items', () => {
const dropdownItems = findDropdownItems();
expect(dropdownItems.length).toBe(MOCK_CALLBACK_URL_ORIGINS.length);
expect(dropdownItems.at(0).text()).toBe(MOCK_CALLBACK_URL_ORIGINS[0]);
expect(dropdownItems.at(1).text()).toBe(MOCK_CALLBACK_URL_ORIGINS[1]);
it('filters out item with current origin', () => {
expect(findDropdown().props('items')).toStrictEqual(EXPECTED_DROPDOWN_ITEMS);
});
});
describe('when no callback URL passed', () => {
beforeEach(() => {
createWrapper({
callbackUrls: [],
});
});
it('reloads page with correct url on dropdown item click', async () => {
const dropdownItem = findDropdownItems().at(0);
dropdownItem.vm.$emit('select', MOCK_CALLBACK_URL_ORIGIN);
await nextTick();
expect(window.location.replace).toHaveBeenCalledTimes(1);
expect(window.location.replace).toHaveBeenCalledWith(
new URL(MOCK_CALLBACK_URL_ORIGIN + MOCK_PATH_NAME).toString(),
);
it('does not render dropdown or button', () => {
expect(findDropdown().exists()).toBe(false);
expect(findButton().exists()).toBe(false);
});
});
});

View File

@ -1,7 +1,6 @@
import * as pathUtils from 'path';
import { commitActionTypes } from '~/ide/constants';
import { WEB_IDE_OAUTH_CALLBACK_URL_PATH, commitActionTypes } from '~/ide/constants';
import { decorateData } from '~/ide/stores/utils';
import { WEB_IDE_OAUTH_CALLBACK_URL_PATH } from '~/ide/lib/gitlab_web_ide/get_oauth_config';
export const file = (name = 'name', id = name, type = '', parent = null) =>
decorateData({

View File

@ -2,12 +2,14 @@ import { startIde } from '~/ide/index';
import { IDE_ELEMENT_ID } from '~/ide/constants';
import { OAuthCallbackDomainMismatchErrorApp } from '~/ide/oauth_callback_domain_mismatch_error';
import { initGitlabWebIDE } from '~/ide/init_gitlab_web_ide';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
jest.mock('~/ide/init_gitlab_web_ide');
const MOCK_CALLBACK_URL = `${window.location.origin}/ide/redirect`;
const MOCK_MISMATCH_CALLBACK_URL = 'https://example.com/ide/redirect';
const MOCK_DATA_SET = {
callbackUrls: JSON.stringify([MOCK_CALLBACK_URL]),
callbackUrls: JSON.stringify([`${TEST_HOST}/-/ide/oauth_redirect`]),
useNewWebIde: true,
};
/**
@ -27,12 +29,20 @@ const setupMockIdeElement = (customData = MOCK_DATA_SET) => {
};
describe('startIde', () => {
let renderErrorSpy;
beforeEach(() => {
setWindowLocation(`${TEST_HOST}/-/ide/edit/gitlab-org/gitlab`);
renderErrorSpy = jest.spyOn(OAuthCallbackDomainMismatchErrorApp.prototype, 'renderError');
});
afterEach(() => {
document.getElementById(IDE_ELEMENT_ID).remove();
document.getElementById(IDE_ELEMENT_ID)?.remove();
});
describe('when useNewWebIde feature flag is true', () => {
let ideElement;
beforeEach(async () => {
ideElement = setupMockIdeElement();
@ -43,34 +53,14 @@ describe('startIde', () => {
expect(initGitlabWebIDE).toHaveBeenCalledTimes(1);
expect(initGitlabWebIDE).toHaveBeenCalledWith(ideElement);
});
it('does not render error page', () => {
expect(renderErrorSpy).not.toHaveBeenCalled();
});
});
describe('OAuth callback origin mismatch check', () => {
let renderErrorSpy;
beforeEach(() => {
renderErrorSpy = jest.spyOn(OAuthCallbackDomainMismatchErrorApp.prototype, 'renderError');
});
it('does not render error page if no callbackUrl provided', async () => {
setupMockIdeElement({ useNewWebIde: true });
await startIde();
expect(renderErrorSpy).not.toHaveBeenCalled();
expect(initGitlabWebIDE).toHaveBeenCalledTimes(1);
});
it('does not call renderOAuthDomainMismatchError if no mismatch detected', async () => {
setupMockIdeElement();
await startIde();
expect(renderErrorSpy).not.toHaveBeenCalled();
expect(initGitlabWebIDE).toHaveBeenCalledTimes(1);
});
it('renders error page if OAuth callback origin does not match window.location.origin', async () => {
const MOCK_MISMATCH_CALLBACK_URL = 'https://example.com/ide/redirect';
renderErrorSpy.mockImplementation(() => {});
describe('with mismatch callback url', () => {
it('renders error page', async () => {
setupMockIdeElement({
callbackUrls: JSON.stringify([MOCK_MISMATCH_CALLBACK_URL]),
useNewWebIde: true,
@ -82,4 +72,17 @@ describe('startIde', () => {
expect(initGitlabWebIDE).not.toHaveBeenCalled();
});
});
describe('with relative URL location and mismatch callback url', () => {
it('renders error page', async () => {
setWindowLocation(`${TEST_HOST}/relative-path/-/ide/edit/project`);
setupMockIdeElement();
await startIde();
expect(renderErrorSpy).toHaveBeenCalledTimes(1);
expect(initGitlabWebIDE).not.toHaveBeenCalled();
});
});
});

View File

@ -135,7 +135,7 @@ describe('ide/init_gitlab_web_ide', () => {
mrTargetProject: '',
forkInfo: null,
username: gon.current_username,
gitlabUrl: TEST_HOST,
gitlabUrl: `${TEST_HOST}/`,
nonce: TEST_NONCE,
httpHeaders: {
'mock-csrf-header': 'mock-csrf-token',

View File

@ -16,7 +16,7 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => {
expect(actual).toEqual({
baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
gitlabUrl: TEST_HOST,
gitlabUrl: `${TEST_HOST}/`,
});
});
@ -27,7 +27,7 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => {
expect(actual).toEqual({
baseUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
gitlabUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}`,
gitlabUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}/`,
});
});
});

View File

@ -0,0 +1,89 @@
import {
parseCallbackUrls,
getOAuthCallbackUrl,
} from '~/ide/lib/gitlab_web_ide/oauth_callback_urls';
import { logError } from '~/lib/logger';
import { joinPaths } from '~/lib/utils/url_utility';
import { IDE_PATH, WEB_IDE_OAUTH_CALLBACK_URL_PATH } from '~/ide/constants';
import setWindowLocation from 'helpers/set_window_location_helper';
jest.mock('~/lib/logger');
const MOCK_IDE_PATH = joinPaths(IDE_PATH, 'some/path');
describe('ide/lib/oauth_callback_urls', () => {
describe('getOAuthCallbackUrl', () => {
const mockPath = MOCK_IDE_PATH;
const MOCK_RELATIVE_PATH = 'relative-path';
const mockPathWithRelative = joinPaths(MOCK_RELATIVE_PATH, MOCK_IDE_PATH);
const originalHref = window.location.href;
afterEach(() => {
setWindowLocation(originalHref);
});
const expectedBaseUrlWithRelative = joinPaths(window.location.origin, MOCK_RELATIVE_PATH);
it.each`
path | expectedCallbackBaseUrl
${mockPath} | ${window.location.origin}
${mockPathWithRelative} | ${expectedBaseUrlWithRelative}
`(
'retrieves expected callback URL based on window url',
({ path, expectedCallbackBaseUrl }) => {
setWindowLocation(path);
const actual = getOAuthCallbackUrl();
const expected = joinPaths(expectedCallbackBaseUrl, WEB_IDE_OAUTH_CALLBACK_URL_PATH);
expect(actual).toEqual(expected);
},
);
});
describe('parseCallbackUrls', () => {
it('parses the given JSON URL array and returns some metadata for them', () => {
const actual = parseCallbackUrls(
JSON.stringify([
'https://gitlab.com/-/ide/oauth_redirect',
'not a url',
'https://gdk.test:3443/-/ide/oauth_redirect/',
'https://gdk.test:3443/gitlab/-/ide/oauth_redirect#1234?query=foo',
'https://example.com/not-a-real-one-/ide/oauth_redirectz',
]),
);
expect(actual).toEqual([
{
base: 'https://gitlab.com/',
url: 'https://gitlab.com/-/ide/oauth_redirect',
},
{
base: 'https://gdk.test:3443/',
url: 'https://gdk.test:3443/-/ide/oauth_redirect/',
},
{
base: 'https://gdk.test:3443/gitlab/',
url: 'https://gdk.test:3443/gitlab/-/ide/oauth_redirect#1234?query=foo',
},
{
base: 'https://example.com/',
url: 'https://example.com/not-a-real-one-/ide/oauth_redirectz',
},
]);
});
it('returns empty when given empty', () => {
expect(parseCallbackUrls('')).toEqual([]);
expect(logError).not.toHaveBeenCalled();
});
it('returns empty when not valid JSON', () => {
expect(parseCallbackUrls('babar')).toEqual([]);
expect(logError).toHaveBeenCalledWith('Failed to parse callback URLs JSON');
});
it('returns empty when not array JSON', () => {
expect(parseCallbackUrls('{}')).toEqual([]);
});
});
});

View File

@ -46,7 +46,7 @@ describe('~/ide/mount_oauth_callback', () => {
clientId: TEST_OAUTH_CLIENT_ID,
protectRefreshToken: true,
},
gitlabUrl: TEST_HOST,
gitlabUrl: `${TEST_HOST}/`,
baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
username: TEST_USERNAME,
});

View File

@ -1304,4 +1304,21 @@ describe('URL utility', () => {
expect(urlUtils.buildURLwithRefType({ base, path, refType })).toBe(output);
});
});
describe('stripRelativeUrlRootFromPath', () => {
it.each`
relativeUrlRoot | path | expectation
${''} | ${'/foo/bar'} | ${'/foo/bar'}
${'/'} | ${'/foo/bar'} | ${'/foo/bar'}
${'/foo'} | ${'/foo/bar'} | ${'/bar'}
${'/gitlab/'} | ${'/gitlab/-/ide/foo'} | ${'/-/ide/foo'}
`(
'with relative_url_root="$relativeUrlRoot", "$path" should return "$expectation"',
({ relativeUrlRoot, path, expectation }) => {
window.gon.relative_url_root = relativeUrlRoot;
expect(urlUtils.stripRelativeUrlRootFromPath(path)).toBe(expectation);
},
);
});
});

View File

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GlobalSearchResultsEmpty component basics renders all parts of header 1`] = `
<gl-empty-state-stub
contentclass=""
description="No results found"
invertindarkmode="true"
svgheight="144"
svgpath="file-mock"
title="No results found"
>
<gl-sprintf-stub
message="We couldn't find any %{scope} matching %{term}"
/>
</gl-empty-state-stub>
`;

View File

@ -3,5 +3,89 @@
exports[`ZoektBlobResults when component loads normally renders component properly 1`] = `
<div
class="gl-flex gl-flex-col gl-justify-center"
/>
>
<div
class="gl-relative"
>
<div
class="file-holder file-result-holder gl-card gl-my-5"
>
<div
class="file-title gl-card-header gl-new-card-header"
>
<blob-header-stub
filepath="test/test-main.js"
fileurl="http://127.0.0.1:3000/flightjs/Flight/-/blob/master/test/test-main.js"
projectpath="flightjs/Flight"
/>
</div>
<div
class="gl-card-body gl-p-0"
>
<blob-body-stub
file="[object Object]"
/>
</div>
</div>
<div
class="file-holder file-result-holder gl-card gl-my-5"
>
<div
class="file-title gl-card-header gl-new-card-header"
>
<blob-header-stub
filepath="test/spec/fn_spec.js"
fileurl="http://127.0.0.1:3000/flightjs/Flight/-/blob/master/test/spec/fn_spec.js"
projectpath="flightjs/Flight"
/>
</div>
<div
class="gl-card-body gl-p-0"
>
<blob-body-stub
file="[object Object]"
/>
</div>
<div
class="gl-card-footer gl-new-card-footer"
>
<blob-footer-stub
file="[object Object]"
/>
</div>
</div>
<div
class="file-holder file-result-holder gl-card gl-my-5"
>
<div
class="file-title gl-border-b-0! gl-card-header gl-new-card-header"
>
<blob-header-stub
filepath="test/spec/test_utils_spec.js"
fileurl="http://127.0.0.1:3000/flightjs/Flight/-/blob/master/test/spec/utils_spec.js"
projectpath="flightjs/Flight"
/>
</div>
<div
class="gl-card-body gl-p-0"
/>
</div>
</div>
<gl-pagination-stub
align="left"
class="gl-mx-auto"
ellipsistext="…"
labelfirstpage="Go to first page"
labellastpage="Go to last page"
labelnextpage="Go to next page"
labelpage="Go to page %{page}"
labelprevpage="Go to previous page"
limits="[object Object]"
nexttext="Next"
perpage="20"
prevtext="Previous"
totalitems="3"
value="1"
/>
</div>
`;

View File

@ -0,0 +1,45 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BlobChunks from '~/search/results/components/blob_chunks.vue';
import ZoektBlobResultsChunks from '~/search/results/components/blob_body.vue';
import eventHub from '~/search/results/event_hub';
import { mockDataForBlobBody } from '../../mock_data';
describe('BlobChunks', () => {
let wrapper;
const createComponent = (file = {}) => {
wrapper = shallowMountExtended(ZoektBlobResultsChunks, {
propsData: {
file,
},
});
};
const findBlobChunks = () => wrapper.findAllComponents(BlobChunks);
describe('component basics', () => {
beforeEach(() => {
createComponent(mockDataForBlobBody);
});
it(`renders default amount of chunks`, () => {
expect(findBlobChunks()).toHaveLength(3);
expect(findBlobChunks().at(0).props()).toMatchObject({
chunk: {
lines: expect.any(Array),
matchCountInChunk: expect.any(Number),
__typename: expect.any(String),
},
blameLink: 'blame/test.js',
fileUrl: 'https://gitlab.com/file/test.js',
});
});
it(`renders all chunks`, async () => {
eventHub.$emit('showMore', { id: 'Testjs/Test:file/test.js', state: true });
await nextTick();
expect(findBlobChunks()).toHaveLength(4);
});
});
});

View File

@ -0,0 +1,77 @@
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BlobChunks from '~/search/results/components/blob_chunks.vue';
describe('BlobChunks', () => {
let wrapper;
const createComponent = (props) => {
wrapper = shallowMountExtended(BlobChunks, {
propsData: {
...props,
},
stubs: {
GlLink,
},
});
};
const findGlIcon = () => wrapper.findAllComponents(GlIcon);
const findGlLink = () => wrapper.findAllComponents(GlLink);
const findLine = () => wrapper.findAllByTestId('search-blob-line');
const findLineNumbers = () => wrapper.findAllByTestId('search-blob-line-numbers');
const findLineCode = () => wrapper.findAllByTestId('search-blob-line-code');
const findRootElement = () => wrapper.find('#search-blob-content');
describe('component basics', () => {
beforeEach(() => {
createComponent({
chunk: {
lines: [
{
lineNumber: 1,
richText: '',
text: '',
__typename: 'SearchBlobLine',
},
{
lineNumber: 2,
richText: '<b>test1</b>',
text: 'test1',
__typename: 'SearchBlobLine',
},
{ lineNumber: 3, richText: '', text: '', __typename: 'SearchBlobLine' },
],
matchCountInChunk: 1,
__typename: 'SearchBlobChunk',
},
blameLink: 'https://gitlab.com/blame/test.js',
fileUrl: 'https://gitlab.com/file/test.js',
});
});
it(`renders default state`, () => {
expect(findLine()).toHaveLength(3);
expect(findLineNumbers()).toHaveLength(3);
expect(findLineCode()).toHaveLength(3);
expect(findGlLink()).toHaveLength(6);
expect(findGlIcon()).toHaveLength(3);
});
it(`renders proper colors`, () => {
expect(findRootElement().classes('white')).toBe(true);
expect(findLineCode().at(1).find('b').classes('hll')).toBe(true);
});
it(`renders links correctly`, () => {
expect(findGlLink().at(0).attributes('href')).toBe('https://gitlab.com/blame/test.js#L1');
expect(findGlLink().at(0).attributes('title')).toBe('View blame');
expect(findGlLink().at(0).findComponent(GlIcon).exists()).toBe(true);
expect(findGlLink().at(0).findComponent(GlIcon).props('name')).toBe('git');
expect(findGlLink().at(1).attributes('href')).toBe('https://gitlab.com/file/test.js#L1');
expect(findGlLink().at(1).attributes('title')).toBe('View Line in repository');
expect(findGlLink().at(1).text()).toBe('1');
});
});
});

View File

@ -0,0 +1,107 @@
import { nextTick } from 'vue';
import { GlSprintf, GlButton, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BlobFooter from '~/search/results/components/blob_footer.vue';
import eventHub from '~/search/results/event_hub';
import { mockDataForBlobBody } from '../../mock_data';
describe('BlobFooter', () => {
let wrapper;
let spy;
const createComponent = (props) => {
wrapper = shallowMountExtended(BlobFooter, {
propsData: {
...props,
},
stubs: {
GlSprintf,
GlLink,
},
});
};
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlLink = () => wrapper.findComponent(GlLink);
describe('component basics', () => {
beforeEach(() => {
createComponent({
file: mockDataForBlobBody,
});
spy = jest.spyOn(eventHub, '$emit');
});
it(`renders default closed state`, () => {
expect(findGlButton().exists()).toBe(true);
expect(wrapper.text()).toContain('Show 1 more matches');
});
it(`renders default open state`, async () => {
findGlButton().vm.$emit('click');
await nextTick();
expect(spy).toHaveBeenCalledWith('showMore', {
id: 'Testjs/Test:file/test.js',
state: true,
});
expect(wrapper.text()).toContain('Show less');
});
});
describe('component with too many results', () => {
beforeEach(() => {
createComponent({
// matchCountTotal: 100,
// matchCount: 100,
// filePath: 'test/file.js',
// projectPath: 'Testjs/Test',
// fileLink: 'https://gitlab.com/test/file.js',
file: {
...mockDataForBlobBody,
chunks: [
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
...mockDataForBlobBody.chunks,
],
matchCountTotal: 200,
},
});
});
it(`renders closed state`, () => {
expect(findGlButton().exists()).toBe(true);
expect(wrapper.text()).toContain('Show 97 more matches');
});
it(`renders open state`, async () => {
findGlButton().vm.$emit('click');
await nextTick();
expect(findGlLink().exists()).toBe(true);
expect(wrapper.text()).toContain(
'Show less - Too many matches found. Showing 50 chunks out of 200 results. Open the file to view all.',
);
});
});
});

View File

@ -0,0 +1,54 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import BlobHeader from '~/search/results/components/blob_header.vue';
describe('BlobHeader', () => {
let wrapper;
const createComponent = (props) => {
wrapper = shallowMountExtended(BlobHeader, {
propsData: {
...props,
},
});
};
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findFileIcon = () => wrapper.findComponent(FileIcon);
const findProjectPath = () => wrapper.findByTestId('project-path-content');
const findProjectName = () => wrapper.findByTestId('file-name-content');
describe('component basics', () => {
beforeEach(() => {
createComponent({
filePath: 'test/file.js',
projectPath: 'Testjs/Test',
fileUrl: 'https://gitlab.com/test/file.js',
});
});
it(`renders all parts of header`, () => {
expect(findClipboardButton().exists()).toBe(true);
expect(findFileIcon().exists()).toBe(true);
expect(findProjectPath().exists()).toBe(true);
expect(findProjectName().exists()).toBe(true);
});
});
describe('limited component', () => {
beforeEach(() => {
createComponent({
filePath: 'test/file.js',
fileUrl: 'https://gitlab.com/test/file.js',
});
});
it(`renders withough projectPath`, () => {
expect(findClipboardButton().exists()).toBe(true);
expect(findFileIcon().exists()).toBe(true);
expect(findProjectPath().exists()).toBe(false);
expect(findProjectName().exists()).toBe(true);
});
});
});

View File

@ -0,0 +1,48 @@
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GlobalSearchResultsEmpty from '~/search/results/components/result_empty.vue';
import { MOCK_QUERY } from '../../mock_data';
Vue.use(Vuex);
describe('GlobalSearchResultsEmpty', () => {
let wrapper;
const getterSpies = {
currentScope: jest.fn(() => 'blobs'),
};
const createComponent = (
props,
initialState = { query: { scope: 'blobs' }, searchType: 'zoekt' },
) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
getters: getterSpies,
});
wrapper = shallowMountExtended(GlobalSearchResultsEmpty, {
store,
propsData: {
...props,
},
});
};
describe('component basics', () => {
beforeEach(() => {
createComponent();
});
it(`renders all parts of header`, () => {
expect(wrapper.element).toMatchSnapshot();
});
});
});

View File

@ -10,6 +10,7 @@ import ZoektBlobResults from '~/search/results/components/zoekt_blob_results.vue
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import EmptyResult from '~/search/results/components/result_empty.vue';
import { MOCK_QUERY, mockGetBlobSearchQuery } from '../../mock_data';
jest.mock('~/alert');
@ -26,6 +27,7 @@ describe('ZoektBlobResults', () => {
const blobSearchHandler = jest.fn().mockResolvedValue(mockGetBlobSearchQuery);
const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {}));
const mockQueryEmpty = jest.fn().mockReturnValue({});
const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error'));
const createComponent = ({
@ -42,7 +44,7 @@ describe('ZoektBlobResults', () => {
},
getters: getterSpies,
});
// apolloMock = createMockApollo([[getBlobSearchQuery, blobSearchHandler]]);
wrapper = shallowMountExtended(ZoektBlobResults, {
apolloProvider,
store,
@ -53,6 +55,7 @@ describe('ZoektBlobResults', () => {
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyResult = () => wrapper.findComponent(EmptyResult);
describe('when loading results', () => {
beforeEach(async () => {
@ -81,6 +84,21 @@ describe('ZoektBlobResults', () => {
});
});
describe('when component has no results', () => {
beforeEach(async () => {
createComponent({
queryHandler: mockQueryEmpty,
});
jest.advanceTimersByTime(500);
await waitForPromises();
});
it(`renders component properly`, async () => {
await nextTick();
expect(findEmptyResult().exists()).toBe(true);
});
});
describe('when component has load error', () => {
beforeEach(async () => {
createComponent({ queryHandler: mockQueryError });

View File

@ -0,0 +1,101 @@
import { GlModal, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vue';
import { CATEGORY_OPTIONS } from '~/abuse_reports/components/constants';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('WorkItemAbuseModal', () => {
let wrapper;
const ACTION_PATH = '/abuse_reports/add_category';
const USER_ID = 1;
const REPORTED_FROM_URL = 'http://example.com';
const createComponent = (props) => {
wrapper = shallowMountExtended(WorkItemAbuseModal, {
propsData: {
reportedUserId: USER_ID,
reportedFromUrl: REPORTED_FROM_URL,
...props,
},
provide: {
reportAbusePath: ACTION_PATH,
},
});
};
beforeEach(() => {
createComponent({ showModal: true });
});
const findAbuseModal = () => wrapper.findComponent(GlModal);
const findForm = () => wrapper.findComponent(GlForm);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findCSRFToken = () => findForm().find('input[name="authenticity_token"]');
const findUserId = () => wrapper.findByTestId('input-user-id');
const findReferer = () => wrapper.findByTestId('input-referer');
describe('Modal', () => {
it('renders report abuse modal with the form', () => {
expect(findAbuseModal().exists()).toBe(true);
expect(findForm().exists()).toBe(true);
});
it('should set the modal title when the `title` prop is set', () => {
const title = 'Report abuse to administrator';
createComponent({ title, showModal: true });
expect(findAbuseModal().props().title).toBe(title);
});
it('should set modal size to `sm` by default', () => {
expect(findAbuseModal().props('size')).toBe('sm');
});
it('renders radio form group with the first option selected by default', () => {
const firstOption = CATEGORY_OPTIONS[0].value;
expect(findRadioGroup().attributes('checked')).toBe(firstOption);
});
});
describe('Select category form', () => {
it('renders POST form with path', () => {
expect(findForm().attributes()).toMatchObject({
method: 'post',
action: ACTION_PATH,
});
});
it('renders csrf token', () => {
expect(findCSRFToken().attributes('value')).toBe('mock-csrf-token');
});
it('renders label', () => {
expect(findFormGroup().attributes('label')).toBe('Why are you reporting this user?');
});
it('renders radio group', () => {
expect(findRadioGroup().props('options')).toEqual(CATEGORY_OPTIONS);
expect(findRadioGroup().attributes('name')).toBe('abuse_report[category]');
});
it('renders userId as a hidden fields', () => {
expect(findUserId().attributes()).toMatchObject({
type: 'hidden',
name: 'user_id',
value: USER_ID.toString(),
});
});
it('renders referer as a hidden fields', () => {
expect(findReferer().attributes()).toMatchObject({
type: 'hidden',
name: 'abuse_report[reported_from_url]',
value: REPORTED_FROM_URL,
});
});
});
});

View File

@ -5,7 +5,7 @@ import {
GlToggle,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import namespaceWorkItemTypesQueryResponse from 'test_fixtures/graphql/work_items/namespace_work_item_types.query.graphql.json';
@ -19,6 +19,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { isLoggedIn } from '~/lib/utils/common_utils';
import toast from '~/vue_shared/plugins/global_toast';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vue';
import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue';
import {
STATE_OPEN,
@ -30,6 +31,7 @@ import {
TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
TEST_ID_PROMOTE_ACTION,
TEST_ID_TOGGLE_ACTION,
TEST_ID_REPORT_ABUSE,
} from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql';
@ -64,6 +66,8 @@ describe('WorkItemActions component', () => {
const findWorkItemToggleOption = () => wrapper.findComponent(WorkItemStateToggle);
const findCopyCreateNoteEmailButton = () =>
wrapper.findByTestId(TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION);
const findReportAbuseButton = () => wrapper.findByTestId(TEST_ID_REPORT_ABUSE);
const findReportAbuseModal = () => wrapper.findComponent(WorkItemAbuseModal);
const findMoreDropdown = () => wrapper.findByTestId('work-item-actions-dropdown');
const findMoreDropdownTooltip = () => getBinding(findMoreDropdown().element, 'gl-tooltip');
const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *');
@ -217,6 +221,10 @@ describe('WorkItemActions component', () => {
{
divider: true,
},
{
testId: TEST_ID_REPORT_ABUSE,
text: 'Report abuse',
},
{
testId: TEST_ID_DELETE_ACTION,
text: 'Delete task',
@ -502,4 +510,22 @@ describe('WorkItemActions component', () => {
expect(findMoreDropdownTooltip().value).toBe('More actions');
});
});
describe('report abuse action', () => {
it('renders the report abuse button', () => {
createComponent();
expect(findReportAbuseButton().exists()).toBe(true);
expect(findReportAbuseModal().exists()).toBe(false);
});
it('opens the report abuse modal', async () => {
createComponent();
findReportAbuseButton().vm.$emit('action');
await nextTick();
expect(wrapper.emitted('toggleReportAbuseModal')).toEqual([[true]]);
});
});
});

View File

@ -20,7 +20,7 @@ import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vue';
import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
import DesignWidget from '~/work_items/components/design_management/design_management_widget.vue';
import { i18n } from '~/work_items/constants';
@ -83,7 +83,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemRelationships = () => wrapper.findComponent(WorkItemRelationships);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const findWorkItemAbuseModal = () => wrapper.findComponent(WorkItemAbuseModal);
const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos);
const findStickyHeader = () => wrapper.findComponent(WorkItemStickyHeader);
const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview');
@ -696,7 +696,7 @@ describe('WorkItemDetail component', () => {
});
it('should not be visible by default', () => {
expect(findAbuseCategorySelector().exists()).toBe(false);
expect(findWorkItemAbuseModal().exists()).toBe(false);
});
it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
@ -704,13 +704,25 @@ describe('WorkItemDetail component', () => {
await nextTick();
expect(findAbuseCategorySelector().exists()).toBe(true);
expect(findWorkItemAbuseModal().exists()).toBe(true);
findAbuseCategorySelector().vm.$emit('close-drawer');
findWorkItemAbuseModal().vm.$emit('close-modal');
await nextTick();
expect(findAbuseCategorySelector().exists()).toBe(false);
expect(findWorkItemAbuseModal().exists()).toBe(false);
});
it('should be visible when the work item actions button emits `toggleReportAbuseModal` event', async () => {
findWorkItemActions().vm.$emit('toggleReportAbuseModal', true);
await nextTick();
expect(findWorkItemAbuseModal().exists()).toBe(true);
findWorkItemAbuseModal().vm.$emit('close-modal');
await nextTick();
expect(findWorkItemAbuseModal().exists()).toBe(false);
});
});

View File

@ -16,7 +16,7 @@ import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue';
import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vue';
import { FORM_TYPES } from '~/work_items/constants';
import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql';
@ -94,7 +94,7 @@ describe('WorkItemLinks', () => {
const findAddLinksForm = () => wrapper.findByTestId('add-links-form');
const findChildrenCount = () => wrapper.findByTestId('children-count');
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const findAbuseCategoryModal = () => wrapper.findComponent(WorkItemAbuseModal);
const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
const findShowLabelsToggle = () => wrapper.findComponent(GlToggle);
@ -237,7 +237,7 @@ describe('WorkItemLinks', () => {
});
it('should not be visible by default', () => {
expect(findAbuseCategorySelector().exists()).toBe(false);
expect(findAbuseCategoryModal().exists()).toBe(false);
});
it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
@ -245,13 +245,13 @@ describe('WorkItemLinks', () => {
await nextTick();
expect(findAbuseCategorySelector().exists()).toBe(true);
expect(findAbuseCategoryModal().exists()).toBe(true);
findAbuseCategorySelector().vm.$emit('close-drawer');
findAbuseCategoryModal().vm.$emit('close-modal');
await nextTick();
expect(findAbuseCategorySelector().exists()).toBe(false);
expect(findAbuseCategoryModal().exists()).toBe(false);
});
});

View File

@ -270,6 +270,9 @@ merge_requests:
- scan_result_policy_violations
- applicable_post_merge_approval_rules
- requested_changes
- scan_result_policy_reads_through_violations
- scan_result_policy_reads_through_approval_rules
- running_scan_result_policy_violations
external_pull_requests:
- project
merge_request_diff:

View File

@ -3946,19 +3946,18 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
context 'when auto_merge_requested is true' do
let(:options) { { auto_merge_requested: true, auto_merge_strategy: auto_merge_strategy } }
where(:auto_merge_strategy, :skip_approved_check, :skip_draft_check, :skip_blocked_check,
:skip_discussions_check, :skip_external_status_check, :skip_requested_changes_check, :skip_jira_check, :skip_locked_lfs_files_check) do
'' | false | false | false | false | false | false | false | false
AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS | false | false | false | false | false | false | false | false
AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS | true | true | true | true | true | true | true | true
where(:auto_merge_strategy, :skip_checks) do
'' | false
AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS | false
AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS | true
end
with_them do
it do
is_expected.to include(skip_approved_check: skip_approved_check, skip_draft_check: skip_draft_check,
skip_blocked_check: skip_blocked_check, skip_discussions_check: skip_discussions_check,
skip_external_status_check: skip_external_status_check, skip_requested_changes_check: skip_requested_changes_check,
skip_jira_check: skip_jira_check)
is_expected.to include(skip_approved_check: skip_checks, skip_draft_check: skip_checks,
skip_blocked_check: skip_checks, skip_discussions_check: skip_checks,
skip_external_status_check: skip_checks, skip_requested_changes_check: skip_checks,
skip_jira_check: skip_checks, skip_security_policy_check: skip_checks)
end
end
end

View File

@ -59,7 +59,7 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat
# We cannot disable SQL query limiting here, since the transaction does not
# begin until we enter the controller.
headers = {
'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => '205,https://gitlab.com/gitlab-org/gitlab/-/issues/469250'
'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => '206,https://gitlab.com/gitlab-org/gitlab/-/issues/469250'
}
post_graphql(query, current_user: current_user, headers: headers)

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'gitlab/dangerfiles/spec_helper'
require 'fast_spec_helper'
require_relative '../../../tooling/danger/settings_sections'
RSpec.describe Tooling::Danger::SettingsSections, feature_category: :tooling do
include_context 'with dangerfile'
subject(:settings_section_check) { fake_danger.new(helper: fake_helper) }
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
let(:matching_changed_files) { ['app/views/foo/bar.html.haml', 'app/assets/js/foo/bar.vue'] }
let(:changed_lines) { ['-render SettingsBlockComponent.new(id: "foo") do', '<settings-block id="foo">'] }
let(:stable_branch?) { false }
before do
allow(fake_helper).to receive(:changed_files).and_return(matching_changed_files)
allow(fake_helper).to receive(:changed_lines).and_return(changed_lines)
allow(fake_helper).to receive(:stable_branch?).and_return(stable_branch?)
end
context 'when on stable branch' do
let(:stable_branch?) { true }
it 'does not write any markdown' do
expect(settings_section_check).not_to receive(:markdown)
settings_section_check.check!
end
end
context 'when none of the changed files are Haml or Vue files' do
let(:matching_changed_files) { [] }
it 'does not write any markdown' do
expect(settings_section_check).not_to receive(:markdown)
settings_section_check.check!
end
end
context 'when none of the changed lines match the pattern' do
let(:changed_lines) { ['-foo', '+bar'] }
it 'does not write any markdown' do
expect(settings_section_check).not_to receive(:markdown)
settings_section_check.check!
end
end
it 'adds a new markdown section listing every matching line' do
expect(settings_section_check).to receive(:markdown).with(/Searchable setting sections/)
expect(settings_section_check).to receive(:markdown).with(/SettingsBlock/)
expect(settings_section_check).to receive(:markdown).with(/settings-block/)
settings_section_check.check!
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Tooling
module Danger
module SettingsSections
def check!
return if helper.stable_branch?
changed_code_files = helper.changed_files(/\.(haml|vue)$/)
return if changed_code_files.empty?
vc_regexp = /(SettingsBlockComponent|settings-block)/
lines_with_matches = filter_changed_lines(changed_code_files, vc_regexp)
return if lines_with_matches.empty?
markdown(<<~MARKDOWN)
## Searchable setting sections
Looks like you have edited the template of some settings section. Please check that all changed sections are still searchable:
- If you created a new section, make sure to add it to either `lib/search/project_settings.rb` or `lib/search/group_settings.rb`, or in their counterparts in `ee/` if this section is only available behind a licensed feature.
- If you removed a section, make sure to also remove it from the files above.
- If you changed a section's id, please update it also in the files above.
- If you just moved code around within the same page, there is nothing to do.
MARKDOWN
lines_with_matches.each do |file, lines|
markdown(<<~MARKDOWN)
#### `#{file}`
```shell
#{lines.join("\n")}
```
MARKDOWN
end
end
def filter_changed_lines(files, pattern)
files_with_lines = {}
files.each do |file|
next if file.start_with?('spec/', 'ee/spec/', 'qa/')
matching_changed_lines = helper.changed_lines(file).select { |line| line =~ pattern }
next unless matching_changed_lines.any?
files_with_lines[file] = matching_changed_lines
end
files_with_lines
end
end
end
end

View File

@ -1359,10 +1359,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.109.0.tgz#af953d8114768343034f1f02bc8e2d93eb613c65"
integrity sha512-MmBTsco2LIh/l16iJQy6R98YDOlE3C++AE0Z1+KCpAX/3+fLAmULx2sWp+JnmM0ws8J0LaeLN6+vWiPaEWA16Q==
"@gitlab/ui@87.8.0":
version "87.8.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-87.8.0.tgz#94b9e301330b22d466fffddaa4f9838385b68ae0"
integrity sha512-oGWyFmI87IbTYb6uYGt79MwV/hkl/vVKqLtMCgx2JLnzYSXWxYAdCKPhmQiO8Fib5RpfYwLzsxZ5qfaazTq4ig==
"@gitlab/ui@88.0.0":
version "88.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-88.0.0.tgz#aab7b5ef169d02d65e80901680052c1f755eeec3"
integrity sha512-O9K5UalOBLboPnskMPezt2nttKL/YXiSlADcDZ/MKTfw9usxRVMm+COuS+zrmqXfOEZfmNMd2lbQnXW9uJlyRQ==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"