diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-11 15:10:20 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-11 15:10:20 +0000 |
commit | e3042fc5ced749e693ccef81b3f5838c55d5480c (patch) | |
tree | e004dca26da0ec413d5c9ebf174962a008fde0bb | |
parent | c33a9adb709ffb40f816e66eb0c98cc750d6cd43 (diff) | |
download | gitlab-ce-e3042fc5ced749e693ccef81b3f5838c55d5480c.tar.gz |
Add latest changes from gitlab-org/gitlab@master
117 files changed, 2041 insertions, 669 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 5f653fbfc26..0bf6709a0ba 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -4f0cd9404f31511f5051e49b363adc06aa3ec365 +30ae36f781ee979330b1f170d81c97c319c2fff1 diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 71cabe80529..e08d294b8c5 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -79,6 +79,7 @@ export default { :toggle-class="toggleClass" :boundary="getBoundaryElement()" menu-class="dropdown-extended-height" + category="tertiary" no-flip right lazy diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index b0f19e5b585..93d8bcc4c19 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -16,13 +16,13 @@ const commentDetailOptions = [ { value: 'standard', label: s__('Integrations|Standard'), - help: s__('Integrations|Includes commit title and branch'), + help: s__('Integrations|Includes commit title and branch.'), }, { value: 'all_details', label: s__('Integrations|All details'), help: s__( - 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs', + 'Integrations|Includes Standard, plus the entire commit message, commit hash, and issue IDs', ), }, ]; @@ -144,7 +144,7 @@ export default { label-for="service[trigger]" :description=" s__( - 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created.', + 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).', ) " > diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 16c76e048bd..0cc818c6d0e 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -278,7 +278,6 @@ export default { v-if="canResolve" ref="resolveButton" v-gl-tooltip - size="small" category="tertiary" :variant="resolveVariant" :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" @@ -292,7 +291,7 @@ export default { <template v-if="canAwardEmoji"> <emoji-picker v-if="glFeatures.improvedEmojiPicker" - toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!" + toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!" @click="setAwardEmoji" > <template #button-content> @@ -305,10 +304,9 @@ export default { v-else v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" + class="note-action-button note-emoji-button add-reaction-button btn-icon js-add-award js-note-emoji" category="tertiary" variant="default" - size="small" :title="$options.i18n.addReactionLabel" :aria-label="$options.i18n.addReactionLabel" data-position="right" @@ -336,7 +334,6 @@ export default { :title="$options.i18n.editCommentLabel" :aria-label="$options.i18n.editCommentLabel" icon="pencil" - size="small" category="tertiary" class="note-action-button js-note-edit" data-qa-selector="note_edit_button" @@ -347,7 +344,6 @@ export default { v-gl-tooltip :title="$options.i18n.deleteCommentLabel" :aria-label="$options.i18n.deleteCommentLabel" - size="small" icon="remove" category="tertiary" class="note-action-button js-note-delete" @@ -360,7 +356,6 @@ export default { :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" icon="ellipsis_v" - size="small" category="tertiary" class="note-action-button more-actions-toggle" data-toggle="dropdown" diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index 5ce03091504..0cd2afcf8a0 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -22,7 +22,6 @@ export default { data-track-event="click_button" data-track-label="reply_comment_button" category="tertiary" - size="small" icon="comment" :title="$options.i18n.buttonText" :aria-label="$options.i18n.buttonText" diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue new file mode 100644 index 00000000000..22c1563350d --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -0,0 +1,67 @@ +<script> +import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import PipelineVisualReference from '../ui/pipeline_visual_reference.vue'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|🚀 Run your first pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|A typical GitLab pipeline consists of three stages: build, test and deploy. Each stage can have one or more jobs.', + ), + secondParagraph: s__( + 'PipelineEditorTutorial|In the example below, %{codeStart}build%{codeEnd} and %{codeStart}deploy%{codeEnd} each contain one job, and %{codeStart}test%{codeEnd} contains two jobs. Your scripts run in jobs like these.', + ), + thirdParagraph: s__( + 'PipelineEditorTutorial|You can use %{linkStart}CI/CD examples and templates%{linkEnd} to get your first %{codeStart}.gitlab-ci.yml%{codeEnd} configuration file started. Your first pipeline runs when you commit the changes.', + ), + note: s__( + 'PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}', + ), + }, + components: { + GlCard, + GlLink, + GlSprintf, + PipelineVisualReference, + }, + inject: ['ciExamplesHelpPagePath', 'runnerHelpPagePath'], +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-3"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <pipeline-visual-reference /> + <p class="gl-my-3"> + <gl-sprintf :message="$options.i18n.thirdParagraph"> + <template #link="{ content }"> + <gl-link :href="ciExamplesHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.note"> + <template #link="{ content }"> + <gl-link :href="runnerHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue new file mode 100644 index 00000000000..3da535f5f94 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue @@ -0,0 +1,35 @@ +<script> +import { GlCard, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|Get started with GitLab CI/CD'), + firstParagraph: s__( + 'PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application.', + ), + secondParagraph: s__( + 'PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor.', + ), + }, + components: { + GlCard, + GlSprintf, + }, +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue new file mode 100644 index 00000000000..f714f6411f1 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue @@ -0,0 +1,75 @@ +<script> +import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|⚙️ Pipeline configuration reference'), + firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'), + browseExamples: s__( + 'PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}', + ), + viewSyntaxRef: s__( + 'PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}', + ), + learnMore: s__( + 'PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}', + ), + needs: s__( + 'PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}', + ), + }, + components: { + GlCard, + GlLink, + GlSprintf, + }, + inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'], +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ul> + <li> + <gl-sprintf :message="$options.i18n.browseExamples"> + <template #link="{ content }"> + <gl-link :href="ciExamplesHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.viewSyntaxRef"> + <template #link="{ content }"> + <gl-link :href="ymlHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link :href="ciHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.needs"> + <template #link="{ content }"> + <gl-link :href="needsHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + </ul> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue new file mode 100644 index 00000000000..512414f0246 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue @@ -0,0 +1,24 @@ +<script> +import { GlCard } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|💡 Tip: Visualize and validate your pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.', + ), + }, + components: { + GlCard, + }, +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index ef5be8abf9a..e1f38b4332b 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -1,6 +1,10 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import FirstPipelineCard from './cards/first_pipeline_card.vue'; +import GettingStartedCard from './cards/getting_started_card.vue'; +import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; +import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue'; export default { width: { @@ -11,6 +15,10 @@ export default { toggleTxt: __('Collapse'), }, components: { + FirstPipelineCard, + GettingStartedCard, + PipelineConfigReferenceCard, + VisualizeAndLintCard, GlButton, GlIcon, }, @@ -55,7 +63,7 @@ export default { <template> <aside aria-live="polite" - class="gl-fixed gl-right-0 gl-h-full gl-bg-gray-10 gl-transition-medium gl-border-l-solid gl-border-1 gl-border-gray-100" + class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-9999 gl-overflow-y-auto" :style="rootStyle" > <gl-button @@ -63,14 +71,19 @@ export default { class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex" :class="buttonClass" :title="__('Toggle sidebar')" - data-testid="toggleBtn" @click="toggleDrawer" > - <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">{{ - __('Collapse') - }}</span> + <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text"> + {{ __('Collapse') }} + </span> <gl-icon data-testid="toggle-icon" :name="buttonIconName" /> </gl-button> - <div v-if="isExpanded" class="gl-p-5" data-testid="drawer-content"></div> + <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content"> + <getting-started-card class="gl-mb-4" /> + <first-pipeline-card class="gl-mb-4" /> + <visualize-and-lint-card class="gl-mb-4" /> + <pipeline-config-reference-card /> + <div class="gl-h-13"></div> + </div> </aside> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue new file mode 100644 index 00000000000..049504181c4 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue @@ -0,0 +1,17 @@ +<script> +export default { + props: { + jobName: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div + class="gl-w-13 gl-h-6 gl-font-sm gl-bg-white gl-inset-border-1-blue-500 gl-text-center gl-text-truncate gl-rounded-pill gl-px-4 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + > + {{ jobName }} + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue new file mode 100644 index 00000000000..1017237365b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue @@ -0,0 +1,43 @@ +<script> +import { s__ } from '~/locale'; +import DemoJobPill from './demo_job_pill.vue'; + +export default { + i18n: { + stageNames: { + build: s__('StageName|Build'), + test: s__('StageName|Test'), + deploy: s__('StageName|Deploy'), + }, + jobNames: { + build: s__('JobName|build-job'), + test_1: s__('JobName|unit-test'), + test_2: s__('JobName|lint-test'), + deploy: s__('JobName|deploy-app'), + }, + }, + stageClasses: + 'gl-bg-blue-50 gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-4 gl-rounded-base', + titleClasses: 'gl-text-blue-600 gl-mb-4', + components: { + DemoJobPill, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-justify-content-center"> + <div :class="$options.stageClasses" class="gl-mr-5"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.build }}</div> + <demo-job-pill :job-name="$options.i18n.jobNames.build" /> + </div> + <div :class="$options.stageClasses" class="gl-mr-5"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.test }}</div> + <demo-job-pill class="gl-mb-3" :job-name="$options.i18n.jobNames.test_1" /> + <demo-job-pill :job-name="$options.i18n.jobNames.test_2" /> + </div> + <div :class="$options.stageClasses"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.deploy }}</div> + <demo-job-pill :job-name="$options.i18n.jobNames.deploy" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 9c5681cf74b..361e2b64e0b 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -30,13 +30,19 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { pipelineEtag, // Add to provide/inject API for static values ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, defaultBranch, emptyStateIllustrationPath, + helpPaths, lintHelpPagePath, + needsHelpPagePath, newMergeRequestPath, + pipelinePagePath, projectFullPath, projectPath, projectNamespace, + runnerHelpPagePath, ymlHelpPagePath, } = el?.dataset; @@ -80,15 +86,21 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { apolloProvider, provide: { ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + configurationPaths, defaultBranch, emptyStateIllustrationPath, + helpPaths, lintHelpPagePath, + needsHelpPagePath, newMergeRequestPath, + pipelinePagePath, projectFullPath, projectPath, projectNamespace, + runnerHelpPagePath, ymlHelpPagePath, - configurationPaths, }, render(h) { return h(PipelineEditorApp); diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index 0422bfb13c5..80ed9a32039 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, n__ } from '~/locale'; +import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -23,6 +23,8 @@ import { ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; +import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql'; + export default { name: 'DetailsHeader', components: { GlButton, GlIcon, TitleArea, MetadataItem }, @@ -35,60 +37,77 @@ export default { type: Object, required: true, }, - metadataLoading: { - type: Boolean, - required: false, - default: false, - }, disabled: { type: Boolean, default: false, required: false, }, }, + data() { + return { + containerRepository: {}, + fetchTagsCount: false, + }; + }, + apollo: { + containerRepository: { + query: getContainerRepositoryTagsCountQuery, + variables() { + return { + id: this.image.id, + }; + }, + }, + }, computed: { + imageDetails() { + return { ...this.image, ...this.containerRepository }; + }, visibilityIcon() { - return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; + return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; }, timeAgo() { - return this.timeFormatted(this.image.updatedAt); + return this.timeFormatted(this.imageDetails.updatedAt); }, updatedText() { return sprintf(UPDATED_AT, { time: this.timeAgo }); }, tagCountText() { - return n__('%d tag', '%d tags', this.image.tagsCount); + if (this.$apollo.queries.containerRepository.loading) { + return s__('ContainerRegistry|-- tags'); + } + return n__('%d tag', '%d tags', this.imageDetails.tagsCount); }, cleanupTextAndTooltip() { - if (!this.image.project.containerExpirationPolicy?.enabled) { + if (!this.imageDetails.project.containerExpirationPolicy?.enabled) { return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP }; } return { [UNSCHEDULED_STATUS]: { text: sprintf(CLEANUP_UNSCHEDULED_TEXT, { - time: this.timeFormatted(this.image.project.containerExpirationPolicy.nextRunAt), + time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt), }), }, [SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP }, [ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP }, [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP }, - }[this.image?.expirationPolicyCleanupStatus]; + }[this.imageDetails?.expirationPolicyCleanupStatus]; }, deleteButtonDisabled() { - return this.disabled || !this.image.canDelete; + return this.disabled || !this.imageDetails.canDelete; }, rootImageTooltip() { - return !this.image.name ? ROOT_IMAGE_TOOLTIP : ''; + return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : ''; }, imageName() { - return this.image.name || ROOT_IMAGE_TEXT; + return this.imageDetails.name || ROOT_IMAGE_TEXT; }, }, }; </script> <template> - <title-area :metadata-loading="metadataLoading"> + <title-area> <template #title> <span data-testid="title"> {{ imageName }} @@ -124,12 +143,7 @@ export default { /> </template> <template #right-actions> - <gl-button - v-if="!metadataLoading" - variant="danger" - :disabled="deleteButtonDisabled" - @click="$emit('delete')" - > + <gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')"> {{ __('Delete image repository') }} </gl-button> </template> diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql index 0f50531c3c5..88c2e667afd 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -8,7 +8,6 @@ query getContainerRepositoryDetails($id: ID!) { canDelete createdAt updatedAt - tagsCount expirationPolicyStartedAt expirationPolicyCleanupStatus project { diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index c87c0f847b3..a703c2dd0ac 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" -query getContainerRepositoryDetails( +query getContainerRepositoryTags( $id: ID! $first: Int $last: Int diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql new file mode 100644 index 00000000000..9092a71edb0 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql @@ -0,0 +1,6 @@ +query getContainerRepositoryTagsCount($id: ID!) { + containerRepository(id: $id) { + id + tagsCount + } +} diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 50feea79747..34ec3b085a5 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -48,14 +48,11 @@ export default { mixins: [Tracking.mixin()], inject: ['breadCrumbState', 'config'], apollo: { - image: { + containerRepository: { query: getContainerRepositoryDetailsQuery, variables() { return this.queryVariables; }, - update(data) { - return data.containerRepository; - }, result() { this.updateBreadcrumb(); }, @@ -66,7 +63,7 @@ export default { }, data() { return { - image: {}, + containerRepository: {}, itemsToBeDeleted: [], isMobile: false, mutationLoading: false, @@ -82,12 +79,12 @@ export default { }; }, isLoading() { - return this.$apollo.queries.image.loading || this.mutationLoading; + return this.$apollo.queries.containerRepository.loading || this.mutationLoading; }, showPartialCleanupWarning() { return ( this.config.showUnfinishedTagCleanupCallout && - this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && + this.containerRepository?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && !this.hidePartialCleanupWarning ); }, @@ -98,13 +95,13 @@ export default { }; }, pageActionsAreDisabled() { - return Boolean(this.image?.status); + return Boolean(this.containerRepository?.status); }, }, methods: { updateBreadcrumb() { - const name = this.image?.id - ? this.image?.name || ROOT_IMAGE_TEXT + const name = this.containerRepository?.id + ? this.containerRepository?.name || ROOT_IMAGE_TEXT : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, @@ -164,7 +161,7 @@ export default { }, deleteImage() { this.deleteImageAlert = true; - this.itemsToBeDeleted = [{ path: this.image.path }]; + this.itemsToBeDeleted = [{ path: this.containerRepository.path }]; this.$refs.deleteModal.show(); }, deleteImageError() { @@ -180,7 +177,7 @@ export default { <template> <div v-gl-resize-observer="handleResize" class="gl-my-3"> - <template v-if="image"> + <template v-if="containerRepository"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" @@ -195,11 +192,11 @@ export default { @dismiss="dismissPartialCleanupWarning" /> - <status-alert v-if="image.status" :status="image.status" /> + <status-alert v-if="containerRepository.status" :status="containerRepository.status" /> <details-header - :image="image" - :metadata-loading="isLoading" + v-if="!isLoading" + :image="containerRepository" :disabled="pageActionsAreDisabled" @delete="deleteImage" /> @@ -215,7 +212,7 @@ export default { /> <delete-image - :id="image.id" + :id="containerRepository.id" ref="deleteImage" use-update-fn @start="deleteImageIniit" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index abc831c8abe..a5d165ebd49 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -1,5 +1,12 @@ <script> -import { GlButtonGroup, GlDropdown, GlDropdownItem, GlLink, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLink, + GlSearchBoxByType, +} from '@gitlab/ui'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import ReviewAppLink from '../review_app_link.vue'; @@ -9,6 +16,7 @@ export default { GlButtonGroup, GlDropdown, GlDropdownItem, + GlIcon, GlLink, GlSearchBoxByType, ReviewAppLink, @@ -71,7 +79,14 @@ export default { size="small" css-class="deploy-link js-deploy-url inline" /> - <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown"> + <gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown"> + <template #button-content> + <gl-icon + class="dropdown-chevron gl-mx-0!" + name="chevron-down" + data-testid="mr-wigdet-deployment-dropdown-icon" + /> + </template> <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="change in filteredChanges" diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 1504f3ee50f..9b38e842635 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -50,6 +50,12 @@ img.avatar { margin-right: $gl-padding; + + @include media-breakpoint-down(sm) { + width: $gl-spacing-scale-6; + height: $gl-spacing-scale-6; + margin-right: $gl-padding-8; + } } .controls { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 801dd44be8e..c2ed709a475 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -676,6 +676,7 @@ $system-note-svg-size: 16px; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { float: none; margin-left: 0; + transform: translateY(-4px); } } diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index 8c2b0300589..d4bff730b06 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -19,7 +19,7 @@ module BoardsActions def show # Add / update the board in the recent visits table - Boards::Visits::CreateService.new(parent, current_user).execute(board) if request.format.html? + board_visit_service.new(parent, current_user).execute(board) if request.format.html? respond_with_board end @@ -52,6 +52,10 @@ module BoardsActions board_klass.to_type end + def board_visit_service + Boards::Visits::CreateService + end + def serializer BoardSerializer.new(current_user: current_user) end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 64b7cbfacc3..43f1a1a847d 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -4,6 +4,7 @@ class Groups::GroupMembersController < Groups::ApplicationController include MembershipActions include MembersPresentation include SortingHelper + include Gitlab::Utils::StrongMemoize MEMBER_PER_PAGE_LIMIT = 50 @@ -21,6 +22,8 @@ class Groups::GroupMembersController < Groups::ApplicationController feature_category :authentication_and_authorization + helper_method :can_manage_members? + def index preload_max_access @sort = params[:sort].presence || sort_value_name @@ -29,7 +32,7 @@ class Groups::GroupMembersController < Groups::ApplicationController .new(@group, current_user, params: filter_params) .execute(include_relations: requested_relations) - if can_manage_members + if can_manage_members? @skip_groups = @group.related_group_ids @invited_members = @members.invite @@ -59,8 +62,10 @@ class Groups::GroupMembersController < Groups::ApplicationController current_user.max_access_for_group[@group.id] = @group.max_member_access(current_user) end - def can_manage_members - can?(current_user, :admin_group_member, @group) + def can_manage_members? + strong_memoize(:can_manage_members) do + can?(current_user, :admin_group_member, @group) + end end def present_invited_members(invited_members) diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index 6ba353fd89a..061e289d8d8 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -9,12 +9,16 @@ module Packages private + def packages_for_project(project) + project.packages.installable + end + def packages_visible_to_user(user, within_group:) return ::Packages::Package.none unless within_group return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group) projects = projects_visible_to_reporters(user, within_group: within_group) - ::Packages::Package.for_projects(projects.select(:id)) + ::Packages::Package.for_projects(projects.select(:id)).installable end def projects_visible_to_user(user, within_group:) diff --git a/app/finders/packages/composer/packages_finder.rb b/app/finders/packages/composer/packages_finder.rb index e63b2ee03fa..b5a1b19216f 100644 --- a/app/finders/packages/composer/packages_finder.rb +++ b/app/finders/packages/composer/packages_finder.rb @@ -9,7 +9,7 @@ module Packages end def execute - packages_for_group_projects.composer.preload_composer + packages_for_group_projects(installable_only: true).composer.preload_composer end end end diff --git a/app/finders/packages/conan/package_finder.rb b/app/finders/packages/conan/package_finder.rb index 26e9182f4e1..8ebdd358ba6 100644 --- a/app/finders/packages/conan/package_finder.rb +++ b/app/finders/packages/conan/package_finder.rb @@ -11,7 +11,7 @@ module Packages end def execute - packages_for_current_user.with_name_like(query).order_name_asc if query + packages_for_current_user.installable.with_name_like(query).order_name_asc if query end private diff --git a/app/finders/packages/generic/package_finder.rb b/app/finders/packages/generic/package_finder.rb index 3a260e11fa3..8ec88754901 100644 --- a/app/finders/packages/generic/package_finder.rb +++ b/app/finders/packages/generic/package_finder.rb @@ -11,6 +11,7 @@ module Packages project .packages .generic + .installable .by_name_and_version!(package_name, package_version) end diff --git a/app/finders/packages/go/package_finder.rb b/app/finders/packages/go/package_finder.rb index 4573417d11f..553e731895d 100644 --- a/app/finders/packages/go/package_finder.rb +++ b/app/finders/packages/go/package_finder.rb @@ -21,6 +21,7 @@ module Packages @project .packages .golang + .installable .with_name(@module_name) .with_version(@module_version) end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index 8771bf90e75..ab12a580e30 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -20,7 +20,7 @@ module Packages attr_reader :current_user, :group, :params - def packages_for_group_projects + def packages_for_group_projects(installable_only: false) packages = ::Packages::Package .including_build_info .including_project_route @@ -32,7 +32,7 @@ module Packages packages = filter_with_version(packages) packages = filter_by_package_type(packages) packages = filter_by_package_name(packages) - filter_by_status(packages) + installable_only ? packages.installable : filter_by_status(packages) end def group_projects_visible_to_current_user diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb index 3bc2f66c098..fd5444684c6 100644 --- a/app/finders/packages/maven/package_finder.rb +++ b/app/finders/packages/maven/package_finder.rb @@ -26,9 +26,9 @@ module Packages def base if @project - packages_for_a_single_project + packages_for_project(@project) elsif @group - packages_for_multiple_projects + packages_visible_to_user(@current_user, within_group: @group) else ::Packages::Package.none end @@ -40,23 +40,6 @@ module Packages matching_packages end - - # Produces a query that retrieves packages from a single project. - def packages_for_a_single_project - @project.packages - end - - # Produces a query that retrieves packages from multiple projects that - # the current user can view within a group. - def packages_for_multiple_projects - packages_visible_to_user(@current_user, within_group: @group) - end - - # Returns the projects that the current user can view within a group. - def projects_visible_to_current_user - @group.all_projects - .public_or_visible_to_user(@current_user) - end end end end diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb index 3b79785d0e1..92ceac297ee 100644 --- a/app/finders/packages/npm/package_finder.rb +++ b/app/finders/packages/npm/package_finder.rb @@ -14,6 +14,7 @@ module Packages def execute base.npm .with_name(@package_name) + .installable .last_of_each_version .preload_files end diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb index 2f66bd145ee..d91ef853a1a 100644 --- a/app/finders/packages/nuget/package_finder.rb +++ b/app/finders/packages/nuget/package_finder.rb @@ -23,7 +23,7 @@ module Packages def base if project? - @project_or_group.packages + packages_for_project(@project_or_group) elsif group? packages_visible_to_user(@current_user, within_group: @project_or_group) else diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb index f1874b77845..368d2028cb5 100644 --- a/app/finders/packages/package_finder.rb +++ b/app/finders/packages/package_finder.rb @@ -12,6 +12,7 @@ module Packages .including_build_info .including_project_route .including_tags + .displayable .processed .find(@package_id) end diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql index c12778109d0..5ee27052f95 100644 --- a/app/graphql/queries/epic/epic_children.query.graphql +++ b/app/graphql/queries/epic/epic_children.query.graphql @@ -16,6 +16,10 @@ fragment RelatedTreeBaseEpic on Epic { adminEpic createEpic } + descendantWeightSum { + closedIssues + openedIssues + } descendantCounts { __typename openedEpics diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index 193cf49d27e..c31f7843e40 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -12,16 +12,21 @@ module Ci commit_sha = project.commit ? project.commit.sha : '' { "ci-config-path": project.ci_config_path_or_default, + "ci-examples-help-page-path" => help_page_path('ci/examples/README'), + "ci-help-page-path" => help_page_path('ci/README'), "commit-sha" => commit_sha, "default-branch" => project.default_branch, "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'), "initial-branch-name": params[:branch_name], "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => namespace_project_new_merge_request_path, "pipeline_etag" => project.commit ? graphql_etag_pipeline_sha_path(commit_sha) : '', + "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, + "runner-help-page-path" => help_page_path('ci/runners/README'), "yml-help-page-path" => help_page_path('ci/yaml/README') } end diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index 979f0e1ab92..dc273e256a8 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific group a user has visited class BoardGroupRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :group belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :group, presence: true validates :board, presence: true - scope :by_user_group, -> (user, group) { where(user: user, group: group) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, group: board.group, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, group, count: nil) - visits = by_user_group(user, group).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, group) { where(user: user, group: group) } - visits.first(count) + def self.board_parent_relation + :group end end diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 509c8f97b83..723afd6feab 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific project a user has visited class BoardProjectRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :project belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :project, presence: true validates :board, presence: true - scope :by_user_project, -> (user, project) { where(user: user, project: project) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, project: board.project, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, project, count: nil) - visits = by_user_project(user, project).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, project) { where(user: user, project: project) } - visits.first(count) + def self.board_parent_relation + :project end end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 04af1145769..bb543b39a79 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -68,6 +68,10 @@ class BulkImports::Entity < ApplicationRecord end end + def encoded_source_full_path + ERB::Util.url_encode(source_full_path) + end + private def validate_parent_is_a_group diff --git a/app/models/concerns/board_recent_visit.rb b/app/models/concerns/board_recent_visit.rb new file mode 100644 index 00000000000..fd4d574ac58 --- /dev/null +++ b/app/models/concerns/board_recent_visit.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module BoardRecentVisit + extend ActiveSupport::Concern + + class_methods do + def visited!(user, board) + find_or_create_by( + "user" => user, + board_parent_relation => board.resource_parent, + board_relation => board + ).tap do |visit| + visit.touch + end + rescue ActiveRecord::RecordNotUnique + retry + end + + def latest(user, parent, count: nil) + visits = by_user_parent(user, parent).order(updated_at: :desc) + visits = visits.preload(board_relation) + + visits.first(count) + end + + def board_relation + :board + end + + def board_parent_relation + raise NotImplementedError + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index ed620c5d6c3..bfa27132299 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -6,6 +6,7 @@ class Packages::Package < ApplicationRecord include Gitlab::Utils::StrongMemoize DISPLAYABLE_STATUSES = [:default, :error].freeze + INSTALLABLE_STATUSES = [:default].freeze belongs_to :project belongs_to :creator, class_name: 'User' @@ -86,6 +87,7 @@ class Packages::Package < ApplicationRecord scope :with_package_type, ->(package_type) { where(package_type: package_type) } scope :with_status, ->(status) { where(status: status) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } + scope :installable, -> { with_status(INSTALLABLE_STATUSES) } scope :including_build_info, -> { includes(pipelines: :user) } scope :including_project_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 5c4b1564914..f1db78501d5 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -106,9 +106,8 @@ class JiraService < IssueTrackerService end def help - "You need to configure Jira before enabling this service. For more details - read the - [Jira service documentation](#{help_page_url('user/project/integrations/jira')})." + jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } end def title diff --git a/app/services/boards/visits/create_service.rb b/app/services/boards/visits/create_service.rb index 428ed1a8bcc..4d659596803 100644 --- a/app/services/boards/visits/create_service.rb +++ b/app/services/boards/visits/create_service.rb @@ -5,13 +5,17 @@ module Boards class CreateService < Boards::BaseService def execute(board) return unless current_user && Gitlab::Database.read_write? - return unless board.is_a?(Board) # other board types do not support board visits yet + return unless board - if parent.is_a?(Group) - BoardGroupRecentVisit.visited!(current_user, board) - else - BoardProjectRecentVisit.visited!(current_user, board) - end + model.visited!(current_user, board) + end + + private + + def model + return BoardGroupRecentVisit if parent.is_a?(Group) + + BoardProjectRecentVisit end end end diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb index 1eead1e62b3..fea424b3aa8 100644 --- a/app/services/packages/nuget/search_service.rb +++ b/app/services/packages/nuget/search_service.rb @@ -103,6 +103,7 @@ module Packages def nuget_packages Packages::Package.nuget + .displayable .has_version .without_nuget_temporary_name end diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index cea4c533ae3..5da3a94c44b 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,15 +1,14 @@ - add_page_specific_style 'page_bundles/members' - page_title _('Group members') -- can_manage_members = can?(current_user, :admin_group_member, @group) -- show_invited_members = can_manage_members && @invited_members.exists? -- show_access_requests = can_manage_members && @requesters.exists? +- show_invited_members = can_manage_members? && @invited_members.load.any? +- show_access_requests = can_manage_members? && @requesters.load.any? - invited_active = params[:search_invited].present? || params[:invited_members_page].present? .js-remove-member-modal .row.gl-mt-3 .col-lg-12 .gl-display-flex.gl-flex-wrap - - if can_manage_members + - if can_manage_members? .gl-w-half.gl-xs-w-full %h4 = _('Group members') @@ -21,7 +20,7 @@ .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } = render 'groups/invite_members_modal', group: @group - - if can_manage_members && Feature.disabled?(:invite_members_group_modal, @group) + - if can_manage_members? && Feature.disabled?(:invite_members_group_modal, @group) %hr.gl-mt-4 %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } @@ -42,7 +41,7 @@ %span = _('Members') %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @members.total_count - - if @group.shared_with_group_links.any? + - if @group.shared_with_group_links.present? %li.nav-item = link_to '#tab-groups', class: ['nav-link'] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do %span @@ -65,7 +64,7 @@ .js-group-members-list{ data: group_members_list_data_attributes(@group, @members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }) } .loading .gl-spinner.gl-spinner-md - - if @group.shared_with_group_links.any? + - if @group.shared_with_group_links.present? #tab-groups.tab-pane .js-group-group-links-list{ data: group_group_links_list_data_attributes(@group) } .loading diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 40ff568c80b..7d435ac03f8 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1836,6 +1836,15 @@ :idempotent: :tags: - :exclude_from_kubernetes +- :name: bulk_imports_export_request + :worker_name: BulkImports::ExportRequestWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: bulk_imports_pipeline :worker_name: BulkImports::PipelineWorker :feature_category: :importers diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index f6b1aef5069..8ad31c68374 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -24,6 +24,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker created_entities.first(next_batch_size).each do |entity| create_pipeline_tracker_for(entity) + BulkImports::ExportRequestWorker.perform_async(entity.id) BulkImports::EntityWorker.perform_async(entity.id) entity.start! diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb new file mode 100644 index 00000000000..cccc24d3bdc --- /dev/null +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module BulkImports + class ExportRequestWorker + include ApplicationWorker + + idempotent! + worker_has_external_dependencies! + feature_category :importers + + GROUP_EXPORTED_URL_PATH = "/groups/%s/export_relations" + + def perform(entity_id) + entity = BulkImports::Entity.find(entity_id) + + request_export(entity) + end + + private + + def request_export(entity) + http_client(entity.bulk_import.configuration) + .post(GROUP_EXPORTED_URL_PATH % entity.encoded_source_full_path) + end + + def http_client(configuration) + @client ||= Clients::Http.new( + uri: configuration.url, + token: configuration.access_token + ) + end + end +end diff --git a/changelogs/unreleased/321625-epic_boards-redirect.yml b/changelogs/unreleased/321625-epic_boards-redirect.yml new file mode 100644 index 00000000000..82d1f336bb8 --- /dev/null +++ b/changelogs/unreleased/321625-epic_boards-redirect.yml @@ -0,0 +1,5 @@ +--- +title: Redirect to the last visited epic board +merge_request: 61474 +author: +type: added diff --git a/changelogs/unreleased/325508-set-traversal_ids-for-every-namespace.yml b/changelogs/unreleased/325508-set-traversal_ids-for-every-namespace.yml new file mode 100644 index 00000000000..3867845f4e0 --- /dev/null +++ b/changelogs/unreleased/325508-set-traversal_ids-for-every-namespace.yml @@ -0,0 +1,5 @@ +--- +title: Set traversal_ids for every namespace +merge_request: 57318 +author: +type: performance diff --git a/changelogs/unreleased/326229-package-displayable.yml b/changelogs/unreleased/326229-package-displayable.yml new file mode 100644 index 00000000000..705abe854bd --- /dev/null +++ b/changelogs/unreleased/326229-package-displayable.yml @@ -0,0 +1,5 @@ +--- +title: Include installable and/or displayable packages only in package finders +merge_request: 59921 +author: +type: changed diff --git a/changelogs/unreleased/329778-fine-tune-a-few-queries-found-in-groupmembers-index.yml b/changelogs/unreleased/329778-fine-tune-a-few-queries-found-in-groupmembers-index.yml new file mode 100644 index 00000000000..790ce9f0989 --- /dev/null +++ b/changelogs/unreleased/329778-fine-tune-a-few-queries-found-in-groupmembers-index.yml @@ -0,0 +1,5 @@ +--- +title: Fine tune a few queries found in GroupMembers#index +merge_request: 60857 +author: +type: performance diff --git a/changelogs/unreleased/georgekoltsov-add-export-request-worker.yml b/changelogs/unreleased/georgekoltsov-add-export-request-worker.yml new file mode 100644 index 00000000000..d97eee4f59a --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-add-export-request-worker.yml @@ -0,0 +1,5 @@ +--- +title: Add relations export request when Bulk Import is initiated +merge_request: 61365 +author: +type: changed diff --git a/changelogs/unreleased/jira-form-copy-updates.yml b/changelogs/unreleased/jira-form-copy-updates.yml new file mode 100644 index 00000000000..05129d99f07 --- /dev/null +++ b/changelogs/unreleased/jira-form-copy-updates.yml @@ -0,0 +1,5 @@ +--- +title: Improve field descriptions in the Jira integration form +merge_request: 61205 +author: +type: changed diff --git a/changelogs/unreleased/make-comment-actions-larger.yml b/changelogs/unreleased/make-comment-actions-larger.yml new file mode 100644 index 00000000000..1d7342b0742 --- /dev/null +++ b/changelogs/unreleased/make-comment-actions-larger.yml @@ -0,0 +1,5 @@ +--- +title: Increase note actions target size +merge_request: 59776 +author: +type: changed diff --git a/changelogs/unreleased/review-app-button-styles.yml b/changelogs/unreleased/review-app-button-styles.yml new file mode 100644 index 00000000000..fbbcbd974db --- /dev/null +++ b/changelogs/unreleased/review-app-button-styles.yml @@ -0,0 +1,6 @@ +--- +title: Remove extra padding and margin from merge request widget review app dropdown + chevron +merge_request: 61302 +author: +type: fixed diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index a3f3af2c83a..36fc6672531 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -56,6 +56,8 @@ - 1 - - bulk_imports_entity - 1 +- - bulk_imports_export_request + - 1 - - bulk_imports_pipeline - 1 - - bulk_imports_relation_export diff --git a/db/migrate/20210511104929_add_epic_board_recent_visits_table.rb b/db/migrate/20210511104929_add_epic_board_recent_visits_table.rb new file mode 100644 index 00000000000..9822276f9c4 --- /dev/null +++ b/db/migrate/20210511104929_add_epic_board_recent_visits_table.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddEpicBoardRecentVisitsTable < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + def up + with_lock_retries do + unless table_exists?(:boards_epic_board_recent_visits) + create_table :boards_epic_board_recent_visits do |t| + t.references :user, index: true, null: false, foreign_key: { on_delete: :cascade } + t.references :epic_board, index: true, foreign_key: { to_table: :boards_epic_boards, on_delete: :cascade }, null: false + t.references :group, index: true, foreign_key: { to_table: :namespaces, on_delete: :cascade }, null: false + t.timestamps_with_timezone null: false + end + end + end + end + + def down + with_lock_retries do + drop_table :boards_epic_board_recent_visits + end + end +end diff --git a/db/migrate/20210511104930_add_index_to_epic_board_recent_visits.rb b/db/migrate/20210511104930_add_index_to_epic_board_recent_visits.rb new file mode 100644 index 00000000000..1341886c50c --- /dev/null +++ b/db/migrate/20210511104930_add_index_to_epic_board_recent_visits.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddIndexToEpicBoardRecentVisits < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_epic_board_recent_visits_on_user_group_and_board' + + disable_ddl_transaction! + + def up + add_concurrent_index :boards_epic_board_recent_visits, + [:user_id, :group_id, :epic_board_id], + name: INDEX_NAME, + unique: true + end + + def down + remove_concurrent_index_by_name :boards_epic_board_recent_visits, INDEX_NAME + end +end diff --git a/db/post_migrate/20210506065000_schedule_backfill_traversal_ids.rb b/db/post_migrate/20210506065000_schedule_backfill_traversal_ids.rb new file mode 100644 index 00000000000..5ae80c1da80 --- /dev/null +++ b/db/post_migrate/20210506065000_schedule_backfill_traversal_ids.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ScheduleBackfillTraversalIds < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + ROOTS_MIGRATION = 'BackfillNamespaceTraversalIdsRoots' + CHILDREN_MIGRATION = 'BackfillNamespaceTraversalIdsChildren' + DOWNTIME = false + BATCH_SIZE = 1_000 + SUB_BATCH_SIZE = 100 + DELAY_INTERVAL = 2.minutes + + disable_ddl_transaction! + + def up + # Personal namespaces and top-level groups + final_delay = queue_background_migration_jobs_by_range_at_intervals( + ::Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots::Namespace.base_query, + ROOTS_MIGRATION, + DELAY_INTERVAL, + batch_size: BATCH_SIZE, + other_job_arguments: [SUB_BATCH_SIZE], + track_jobs: true + ) + final_delay += DELAY_INTERVAL + + # Subgroups + queue_background_migration_jobs_by_range_at_intervals( + ::Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren::Namespace.base_query, + CHILDREN_MIGRATION, + DELAY_INTERVAL, + batch_size: BATCH_SIZE, + initial_delay: final_delay, + other_job_arguments: [SUB_BATCH_SIZE], + track_jobs: true + ) + end +end diff --git a/db/schema_migrations/20210506065000 b/db/schema_migrations/20210506065000 new file mode 100644 index 00000000000..5ffe1800cd9 --- /dev/null +++ b/db/schema_migrations/20210506065000 @@ -0,0 +1 @@ +d286628cce50c469afe899d5ac40f20df8dceb6ee10c6cf49c64fbaeea7e4a2e
\ No newline at end of file diff --git a/db/schema_migrations/20210511104929 b/db/schema_migrations/20210511104929 new file mode 100644 index 00000000000..af4f0ae0c01 --- /dev/null +++ b/db/schema_migrations/20210511104929 @@ -0,0 +1 @@ +7c2a036033a3f6a3f80755c8ce4a0deab5933084974af4d87e7b97cc446fcbda
\ No newline at end of file diff --git a/db/schema_migrations/20210511104930 b/db/schema_migrations/20210511104930 new file mode 100644 index 00000000000..9c07569e616 --- /dev/null +++ b/db/schema_migrations/20210511104930 @@ -0,0 +1 @@ +51a8eeb8919e3f59579885b9e316ba8116566ae9b363b5dd750a65f42503c391
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8f05390ae4a..8b7d6737916 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10057,6 +10057,24 @@ CREATE SEQUENCE boards_epic_board_positions_id_seq ALTER SEQUENCE boards_epic_board_positions_id_seq OWNED BY boards_epic_board_positions.id; +CREATE TABLE boards_epic_board_recent_visits ( + id bigint NOT NULL, + user_id bigint NOT NULL, + epic_board_id bigint NOT NULL, + group_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE boards_epic_board_recent_visits_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE boards_epic_board_recent_visits_id_seq OWNED BY boards_epic_board_recent_visits.id; + CREATE TABLE boards_epic_boards ( id bigint NOT NULL, hide_backlog_list boolean DEFAULT false NOT NULL, @@ -19353,6 +19371,8 @@ ALTER TABLE ONLY boards_epic_board_labels ALTER COLUMN id SET DEFAULT nextval('b ALTER TABLE ONLY boards_epic_board_positions ALTER COLUMN id SET DEFAULT nextval('boards_epic_board_positions_id_seq'::regclass); +ALTER TABLE ONLY boards_epic_board_recent_visits ALTER COLUMN id SET DEFAULT nextval('boards_epic_board_recent_visits_id_seq'::regclass); + ALTER TABLE ONLY boards_epic_boards ALTER COLUMN id SET DEFAULT nextval('boards_epic_boards_id_seq'::regclass); ALTER TABLE ONLY boards_epic_list_user_preferences ALTER COLUMN id SET DEFAULT nextval('boards_epic_list_user_preferences_id_seq'::regclass); @@ -20468,6 +20488,9 @@ ALTER TABLE ONLY boards_epic_board_labels ALTER TABLE ONLY boards_epic_board_positions ADD CONSTRAINT boards_epic_board_positions_pkey PRIMARY KEY (id); +ALTER TABLE ONLY boards_epic_board_recent_visits + ADD CONSTRAINT boards_epic_board_recent_visits_pkey PRIMARY KEY (id); + ALTER TABLE ONLY boards_epic_boards ADD CONSTRAINT boards_epic_boards_pkey PRIMARY KEY (id); @@ -22293,6 +22316,12 @@ CREATE INDEX index_boards_epic_board_positions_on_epic_id ON boards_epic_board_p CREATE INDEX index_boards_epic_board_positions_on_scoped_relative_position ON boards_epic_board_positions USING btree (epic_board_id, epic_id, relative_position); +CREATE INDEX index_boards_epic_board_recent_visits_on_epic_board_id ON boards_epic_board_recent_visits USING btree (epic_board_id); + +CREATE INDEX index_boards_epic_board_recent_visits_on_group_id ON boards_epic_board_recent_visits USING btree (group_id); + +CREATE INDEX index_boards_epic_board_recent_visits_on_user_id ON boards_epic_board_recent_visits USING btree (user_id); + CREATE INDEX index_boards_epic_boards_on_group_id ON boards_epic_boards USING btree (group_id); CREATE INDEX index_boards_epic_list_user_preferences_on_epic_list_id ON boards_epic_list_user_preferences USING btree (epic_list_id); @@ -22857,6 +22886,8 @@ CREATE INDEX index_environments_on_state_and_auto_stop_at ON environments USING CREATE UNIQUE INDEX index_epic_board_list_preferences_on_user_and_list ON boards_epic_list_user_preferences USING btree (user_id, epic_list_id); +CREATE UNIQUE INDEX index_epic_board_recent_visits_on_user_group_and_board ON boards_epic_board_recent_visits USING btree (user_id, group_id, epic_board_id); + CREATE INDEX index_epic_issues_on_epic_id ON epic_issues USING btree (epic_id); CREATE INDEX index_epic_issues_on_epic_id_and_issue_id ON epic_issues USING btree (epic_id, issue_id); @@ -26626,6 +26657,9 @@ ALTER TABLE ONLY packages_rubygems_metadata ALTER TABLE ONLY packages_pypi_metadata ADD CONSTRAINT fk_rails_9698717cdd FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE; +ALTER TABLE ONLY boards_epic_board_recent_visits + ADD CONSTRAINT fk_rails_96c2c18642 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY packages_dependency_links ADD CONSTRAINT fk_rails_96ef1c00d3 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE; @@ -26890,6 +26924,9 @@ ALTER TABLE ONLY pages_deployments ALTER TABLE ONLY merge_request_user_mentions ADD CONSTRAINT fk_rails_c440b9ea31 FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE; +ALTER TABLE ONLY boards_epic_board_recent_visits + ADD CONSTRAINT fk_rails_c4dcba4a3e FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY ci_job_artifacts ADD CONSTRAINT fk_rails_c5137cb2c1 FOREIGN KEY (job_id) REFERENCES ci_builds(id) ON DELETE CASCADE; @@ -27067,6 +27104,9 @@ ALTER TABLE ONLY draft_notes ALTER TABLE ONLY namespace_package_settings ADD CONSTRAINT fk_rails_e773444769 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY boards_epic_board_recent_visits + ADD CONSTRAINT fk_rails_e77911cf03 FOREIGN KEY (epic_board_id) REFERENCES boards_epic_boards(id) ON DELETE CASCADE; + ALTER TABLE ONLY dast_site_tokens ADD CONSTRAINT fk_rails_e84f721a8e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/api/members.md b/doc/api/members.md index 4c740247a70..6098a80d0dd 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -288,7 +288,8 @@ Example response: "state": "active", "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", "web_url": "http://192.168.1.8:3000/root", - "last_activity_on": "2021-01-27" + "last_activity_on": "2021-01-27", + "membership_type": "group_member" }, { "id": 2, @@ -298,7 +299,8 @@ Example response: "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", "web_url": "http://192.168.1.8:3000/root", "email": "john@example.com", - "last_activity_on": "2021-01-25" + "last_activity_on": "2021-01-25", + "membership_type": "group_member" }, { "id": 3, @@ -307,7 +309,8 @@ Example response: "state": "active", "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", "web_url": "http://192.168.1.8:3000/root", - "last_activity_on": "2021-01-20" + "last_activity_on": "2021-01-20", + "membership_type": "group_invite" } ] ``` diff --git a/lib/banzai/cross_project_reference.rb b/lib/banzai/cross_project_reference.rb index b7344808989..1bf6cf11526 100644 --- a/lib/banzai/cross_project_reference.rb +++ b/lib/banzai/cross_project_reference.rb @@ -17,7 +17,12 @@ module Banzai return context[:project] || context[:group] unless ref return context[:project] if context[:project]&.full_path == ref - Project.find_by_full_path(ref) + if reference_cache.cache_loaded? + # optimization to reuse the parent_per_reference query information + reference_cache.parent_per_reference[ref || reference_cache.current_parent_path] + else + Project.find_by_full_path(ref) + end end end end diff --git a/lib/banzai/filter/references/abstract_reference_filter.rb b/lib/banzai/filter/references/abstract_reference_filter.rb index 2763e084de9..08014ccdcce 100644 --- a/lib/banzai/filter/references/abstract_reference_filter.rb +++ b/lib/banzai/filter/references/abstract_reference_filter.rb @@ -8,6 +8,12 @@ module Banzai class AbstractReferenceFilter < ReferenceFilter include CrossProjectReference + def initialize(doc, context = nil, result = nil) + super + + @reference_cache = ReferenceCache.new(self, context) + end + # REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found # reference (which we replace with placeholder during re-scaping). The # random number helps ensure it's pretty close to unique. Since it's a @@ -112,6 +118,8 @@ module Banzai def call return doc unless project || group || user + reference_cache.load_reference_cache(nodes) if respond_to?(:parent_records) + ref_pattern = object_reference_pattern link_pattern = object_class.link_reference_pattern @@ -174,9 +182,9 @@ module Banzai def object_link_filter(text, pattern, link_content: nil, link_reference: false) references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches| parent_path = if parent_type == :group - full_group_path(namespace_ref) + reference_cache.full_group_path(namespace_ref) else - full_project_path(namespace_ref, project_ref) + reference_cache.full_project_path(namespace_ref, project_ref) end parent = from_ref_cached(parent_path) @@ -263,127 +271,6 @@ module Banzai text end - # Returns a Hash containing all object references (e.g. issue IDs) per the - # project they belong to. - def references_per_parent - @references_per ||= {} - - @references_per[parent_type] ||= begin - refs = Hash.new { |hash, key| hash[key] = Set.new } - regex = [ - object_class.link_reference_pattern, - object_class.reference_pattern - ].compact.reduce { |a, b| Regexp.union(a, b) } - - nodes.each do |node| - node.to_html.scan(regex) do - path = if parent_type == :project - full_project_path($~[:namespace], $~[:project]) - else - full_group_path($~[:group]) - end - - if ident = identifier($~) - refs[path] << ident - end - end - end - - refs - end - end - - # Returns a Hash containing referenced projects grouped per their full - # path. - def parent_per_reference - @per_reference ||= {} - - @per_reference[parent_type] ||= begin - refs = Set.new - - references_per_parent.each do |ref, _| - refs << ref - end - - find_for_paths(refs.to_a).index_by(&:full_path) - end - end - - def relation_for_paths(paths) - klass = parent_type.to_s.camelize.constantize - result = klass.where_full_path_in(paths) - return result if parent_type == :group - - result.includes(:namespace) if parent_type == :project - end - - # Returns projects for the given paths. - def find_for_paths(paths) - if Gitlab::SafeRequestStore.active? - cache = refs_cache - to_query = paths - cache.keys - - unless to_query.empty? - records = relation_for_paths(to_query) - - found = [] - records.each do |record| - ref = record.full_path - get_or_set_cache(cache, ref) { record } - found << ref - end - - not_found = to_query - found - not_found.each do |ref| - get_or_set_cache(cache, ref) { nil } - end - end - - cache.slice(*paths).values.compact - else - relation_for_paths(paths) - end - end - - def current_parent_path - @current_parent_path ||= parent&.full_path - end - - def current_project_namespace_path - @current_project_namespace_path ||= project&.namespace&.full_path - end - - def records_per_parent - @_records_per_project ||= {} - - @_records_per_project[object_class.to_s.underscore] ||= begin - hash = Hash.new { |h, k| h[k] = {} } - - parent_per_reference.each do |path, parent| - record_ids = references_per_parent[path] - - parent_records(parent, record_ids).each do |record| - hash[parent][record_identifier(record)] = record - end - end - - hash - end - end - - private - - def full_project_path(namespace, project_ref) - return current_parent_path unless project_ref - - namespace_ref = namespace || current_project_namespace_path - "#{namespace_ref}/#{project_ref}" - end - - def refs_cache - Gitlab::SafeRequestStore["banzai_#{parent_type}_refs".to_sym] ||= {} - end - def parent_type :project end @@ -392,11 +279,9 @@ module Banzai parent_type == :project ? project : group end - def full_group_path(group_ref) - return current_parent_path unless group_ref + private - group_ref - end + attr_accessor :reference_cache def escape_with_placeholders(text, placeholder_data) escaped = escape_html_entities(text) @@ -409,5 +294,3 @@ module Banzai end end end - -Banzai::Filter::References::AbstractReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::AbstractReferenceFilter') diff --git a/lib/banzai/filter/references/commit_reference_filter.rb b/lib/banzai/filter/references/commit_reference_filter.rb index 1baafeccbd9..157dc696cc8 100644 --- a/lib/banzai/filter/references/commit_reference_filter.rb +++ b/lib/banzai/filter/references/commit_reference_filter.rb @@ -19,7 +19,7 @@ module Banzai def find_object(project, id) return unless project.is_a?(Project) && project.valid_repo? - _, record = records_per_parent[project].detect { |k, _v| Gitlab::Git.shas_eql?(k, id) } + _, record = reference_cache.records_per_parent[project].detect { |k, _v| Gitlab::Git.shas_eql?(k, id) } record end @@ -28,7 +28,7 @@ module Banzai return [] unless noteable.is_a?(MergeRequest) @referenced_merge_request_commit_shas ||= begin - referenced_shas = references_per_parent.values.reduce(:|).to_a + referenced_shas = reference_cache.references_per_parent.values.reduce(:|).to_a noteable.all_commit_shas.select do |sha| referenced_shas.any? { |ref| Gitlab::Git.shas_eql?(sha, ref) } end @@ -66,12 +66,12 @@ module Banzai extras end - private - def parent_records(parent, ids) parent.commits_by(oids: ids.to_a) end + private + def noteable context[:noteable] end diff --git a/lib/banzai/filter/references/design_reference_filter.rb b/lib/banzai/filter/references/design_reference_filter.rb index de8d58de72f..01e1036dcec 100644 --- a/lib/banzai/filter/references/design_reference_filter.rb +++ b/lib/banzai/filter/references/design_reference_filter.rb @@ -36,7 +36,7 @@ module Banzai self.object_class = ::DesignManagement::Design def find_object(project, identifier) - records_per_parent[project][identifier] + reference_cache.records_per_parent[project][identifier] end def parent_records(project, identifiers) @@ -59,15 +59,6 @@ module Banzai super.includes(:route, :namespace, :group) end - def parent_type - :project - end - - # optimisation to reuse the parent_per_reference query information - def parent_from_ref(ref) - parent_per_reference[ref || current_parent_path] - end - def url_for_object(design, project) path_options = { vueroute: design.filename } Gitlab::Routing.url_helpers.designs_project_issue_path(project, design.issue, path_options) diff --git a/lib/banzai/filter/references/issuable_reference_filter.rb b/lib/banzai/filter/references/issuable_reference_filter.rb index b8ccb926ae9..6349f8542ca 100644 --- a/lib/banzai/filter/references/issuable_reference_filter.rb +++ b/lib/banzai/filter/references/issuable_reference_filter.rb @@ -9,11 +9,7 @@ module Banzai end def find_object(parent, iid) - records_per_parent[parent][iid] - end - - def parent_from_ref(ref) - parent_per_reference[ref || current_parent_path] + reference_cache.records_per_parent[parent][iid] end end end diff --git a/lib/banzai/filter/references/label_reference_filter.rb b/lib/banzai/filter/references/label_reference_filter.rb index f9668d22d40..9c7c95d97f4 100644 --- a/lib/banzai/filter/references/label_reference_filter.rb +++ b/lib/banzai/filter/references/label_reference_filter.rb @@ -17,7 +17,7 @@ module Banzai unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| namespace = $~[:namespace] project = $~[:project] - project_path = full_project_path(namespace, project) + project_path = reference_cache.full_project_path(namespace, project) label = find_label_cached(project_path, $~[:label_id], $~[:label_name]) if label @@ -93,7 +93,7 @@ module Banzai parent = project || group if project || full_path_ref?(matches) - project_path = full_project_path(matches[:namespace], matches[:project]) + project_path = reference_cache.full_project_path(matches[:namespace], matches[:project]) parent_from_ref = from_ref_cached(project_path) reference = parent_from_ref.to_human_reference(parent) diff --git a/lib/banzai/filter/references/milestone_reference_filter.rb b/lib/banzai/filter/references/milestone_reference_filter.rb index c7e4b8b35a2..31a961f3e73 100644 --- a/lib/banzai/filter/references/milestone_reference_filter.rb +++ b/lib/banzai/filter/references/milestone_reference_filter.rb @@ -67,7 +67,7 @@ module Banzai end def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name) - project_path = full_project_path(namespace_ref, project_ref) + project_path = reference_cache.full_project_path(namespace_ref, project_ref) # Returns group if project is not found by path parent = parent_from_ref(project_path) diff --git a/lib/banzai/filter/references/reference_cache.rb b/lib/banzai/filter/references/reference_cache.rb new file mode 100644 index 00000000000..195357a8d3d --- /dev/null +++ b/lib/banzai/filter/references/reference_cache.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + class ReferenceCache + include Gitlab::Utils::StrongMemoize + include RequestStoreReferenceCache + + def initialize(filter, context) + @filter = filter + @context = context + end + + def load_reference_cache(nodes) + load_references_per_parent(nodes) + load_parent_per_reference + load_records_per_parent + + @cache_loaded = true + end + + # Loads all object references (e.g. issue IDs) per + # project/group they belong to. + def load_references_per_parent(nodes) + @references_per_parent ||= {} + + @references_per_parent[parent_type] ||= begin + refs = Hash.new { |hash, key| hash[key] = Set.new } + + nodes.each do |node| + node.to_html.scan(regex) do + path = if parent_type == :project + full_project_path($~[:namespace], $~[:project]) + else + full_group_path($~[:group]) + end + + ident = filter.identifier($~) + refs[path] << ident if ident + end + end + + refs + end + end + + def references_per_parent + @references_per_parent[parent_type] + end + + # Returns a Hash containing referenced projects grouped per their full + # path. + def load_parent_per_reference + @per_reference ||= {} + + @per_reference[parent_type] ||= begin + refs = references_per_parent.keys.to_set + + find_for_paths(refs.to_a).index_by(&:full_path) + end + end + + def parent_per_reference + @per_reference[parent_type] + end + + def load_records_per_parent + @_records_per_project ||= {} + + @_records_per_project[filter.object_class.to_s.underscore] ||= begin + hash = Hash.new { |h, k| h[k] = {} } + + parent_per_reference.each do |path, parent| + record_ids = references_per_parent[path] + + filter.parent_records(parent, record_ids).each do |record| + hash[parent][filter.record_identifier(record)] = record + end + end + + hash + end + end + + def records_per_parent + @_records_per_project[filter.object_class.to_s.underscore] + end + + def relation_for_paths(paths) + klass = parent_type.to_s.camelize.constantize + result = klass.where_full_path_in(paths) + return result if parent_type == :group + + result.includes(namespace: :route) if parent_type == :project + end + + # Returns projects for the given paths. + def find_for_paths(paths) + if Gitlab::SafeRequestStore.active? + cache = refs_cache + to_query = paths - cache.keys + + unless to_query.empty? + records = relation_for_paths(to_query) + + found = [] + records.each do |record| + ref = record.full_path + get_or_set_cache(cache, ref) { record } + found << ref + end + + not_found = to_query - found + not_found.each do |ref| + get_or_set_cache(cache, ref) { nil } + end + end + + cache.slice(*paths).values.compact + else + relation_for_paths(paths) + end + end + + def current_parent_path + strong_memoize(:current_parent_path) do + parent&.full_path + end + end + + def current_project_namespace_path + strong_memoize(:current_project_namespace_path) do + project&.namespace&.full_path + end + end + + def full_project_path(namespace, project_ref) + return current_parent_path unless project_ref + + namespace_ref = namespace || current_project_namespace_path + "#{namespace_ref}/#{project_ref}" + end + + def full_group_path(group_ref) + return current_parent_path unless group_ref + + group_ref + end + + def cache_loaded? + !!@cache_loaded + end + + private + + attr_accessor :filter, :context + + delegate :project, :group, :parent, :parent_type, to: :filter + + def regex + strong_memoize(:regex) do + [ + filter.object_class.link_reference_pattern, + filter.object_class.reference_pattern + ].compact.reduce { |a, b| Regexp.union(a, b) } + end + end + + def refs_cache + Gitlab::SafeRequestStore["banzai_#{parent_type}_refs".to_sym] ||= {} + end + end + end + end +end + +Banzai::Filter::References::ReferenceCache.prepend_if_ee('EE::Banzai::Filter::References::ReferenceCache') diff --git a/lib/banzai/filter/references/reference_filter.rb b/lib/banzai/filter/references/reference_filter.rb index a83cb12afd3..58436f4505e 100644 --- a/lib/banzai/filter/references/reference_filter.rb +++ b/lib/banzai/filter/references/reference_filter.rb @@ -97,6 +97,18 @@ module Banzai @nodes ||= each_node.to_a end + def object_class + self.class.object_class + end + + def project + context[:project] + end + + def group + context[:group] + end + private # Returns a data attribute String to attach to a reference link @@ -141,14 +153,6 @@ module Banzai needs :project unless skip_project_check? end - def project - context[:project] - end - - def group - context[:group] - end - def user context[:user] end @@ -216,10 +220,6 @@ module Banzai node.is_a?(Nokogiri::XML::Element) end - def object_class - self.class.object_class - end - def object_reference_pattern @object_reference_pattern ||= object_class.reference_pattern end diff --git a/lib/bulk_imports/clients/http.rb b/lib/bulk_imports/clients/http.rb index 55abb9a15be..c89679f63b5 100644 --- a/lib/bulk_imports/clients/http.rb +++ b/lib/bulk_imports/clients/http.rb @@ -28,6 +28,17 @@ module BulkImports end end + def post(resource, body = {}) + with_error_handling do + Gitlab::HTTP.post( + resource_url(resource), + headers: request_headers, + follow_redirects: false, + body: body + ) + end + end + def each_page(method, resource, query = {}, &block) return to_enum(__method__, method, resource, query) unless block_given? diff --git a/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children.rb b/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children.rb new file mode 100644 index 00000000000..79e7a2f2279 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # A job to set namespaces.traversal_ids in sub-batches, of all namespaces with + # a parent and not already set. + # rubocop:disable Style/Documentation + class BackfillNamespaceTraversalIdsChildren + class Namespace < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'namespaces' + + scope :base_query, -> { where.not(parent_id: nil) } + end + + PAUSE_SECONDS = 0.1 + + def perform(start_id, end_id, sub_batch_size) + batch_query = Namespace.base_query.where(id: start_id..end_id) + batch_query.each_batch(of: sub_batch_size) do |sub_batch| + first, last = sub_batch.pluck(Arel.sql('min(id), max(id)')).first + ranged_query = Namespace.unscoped.base_query.where(id: first..last) + + update_sql = <<~SQL + UPDATE namespaces + SET traversal_ids = calculated_ids.traversal_ids + FROM #{calculated_traversal_ids(ranged_query)} calculated_ids + WHERE namespaces.id = calculated_ids.id + AND namespaces.traversal_ids = '{}' + SQL + ActiveRecord::Base.connection.execute(update_sql) + + sleep PAUSE_SECONDS + end + + # We have to add all arguments when marking a job as succeeded as they + # are all used to track the job by `queue_background_migration_jobs_by_range_at_intervals` + mark_job_as_succeeded(start_id, end_id, sub_batch_size) + end + + private + + # Calculate the ancestor path for a given set of namespaces. + def calculated_traversal_ids(batch) + <<~SQL + ( + WITH RECURSIVE cte(source_id, namespace_id, parent_id, height) AS ( + ( + SELECT batch.id, batch.id, batch.parent_id, 1 + FROM (#{batch.to_sql}) AS batch + ) + UNION ALL + ( + SELECT cte.source_id, n.id, n.parent_id, cte.height+1 + FROM namespaces n, cte + WHERE n.id = cte.parent_id + ) + ) + SELECT flat_hierarchy.source_id as id, + array_agg(flat_hierarchy.namespace_id ORDER BY flat_hierarchy.height DESC) as traversal_ids + FROM (SELECT * FROM cte FOR UPDATE) flat_hierarchy + GROUP BY flat_hierarchy.source_id + ) + SQL + end + + def mark_job_as_succeeded(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( + 'BackfillNamespaceTraversalIdsChildren', + arguments + ) + end + end + end +end diff --git a/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots.rb b/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots.rb new file mode 100644 index 00000000000..f3fc87cbac7 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # A job to set namespaces.traversal_ids in sub-batches, of all namespaces + # without a parent and not already set. + # rubocop:disable Style/Documentation + class BackfillNamespaceTraversalIdsRoots + class Namespace < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'namespaces' + + scope :base_query, -> { where(parent_id: nil) } + end + + PAUSE_SECONDS = 0.1 + + def perform(start_id, end_id, sub_batch_size) + ranged_query = Namespace.base_query + .where(id: start_id..end_id) + .where("traversal_ids = '{}'") + + ranged_query.each_batch(of: sub_batch_size) do |sub_batch| + sub_batch.update_all('traversal_ids = ARRAY[id]') + sleep PAUSE_SECONDS + end + + mark_job_as_succeeded(start_id, end_id, sub_batch_size) + end + + private + + def mark_job_as_succeeded(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( + 'BackfillNamespaceTraversalIdsRoots', + arguments + ) + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 085d16b7bd5..dd1195bebc3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3289,6 +3289,9 @@ msgstr "" msgid "Allowed Geo IP" msgstr "" +msgid "Allowed characters: +, 0-9, -, and spaces." +msgstr "" + msgid "Allowed email domain restriction only permitted for top-level groups" msgstr "" @@ -8564,6 +8567,9 @@ msgstr "" msgid "ContainerRegistry|%{title} was successfully scheduled for deletion" msgstr "" +msgid "ContainerRegistry|-- tags" +msgstr "" + msgid "ContainerRegistry|Build an image" msgstr "" @@ -17586,10 +17592,10 @@ msgstr "" msgid "Integrations|Failed to unlink namespace. Please try again." msgstr "" -msgid "Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs" +msgid "Integrations|Includes Standard, plus the entire commit message, commit hash, and issue IDs" msgstr "" -msgid "Integrations|Includes commit title and branch" +msgid "Integrations|Includes commit title and branch." msgstr "" msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira." @@ -17667,7 +17673,7 @@ msgstr "" msgid "Integrations|Use default settings" msgstr "" -msgid "Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created." +msgid "Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled)." msgstr "" msgid "Integrations|You can now close this window and return to the GitLab for Jira application." @@ -18609,6 +18615,9 @@ msgstr "" msgid "JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only." msgstr "" +msgid "JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}." +msgstr "" + msgid "JiraService|transition ids can have only numbers which can be split with , or ;" msgstr "" @@ -18657,6 +18666,18 @@ msgstr "" msgid "Job was retried" msgstr "" +msgid "JobName|build-job" +msgstr "" + +msgid "JobName|deploy-app" +msgstr "" + +msgid "JobName|lint-test" +msgstr "" + +msgid "JobName|unit-test" +msgstr "" + msgid "Jobs" msgstr "" @@ -23812,6 +23833,54 @@ msgstr "" msgid "PipelineCharts|Total:" msgstr "" +msgid "PipelineEditorTutorial|A typical GitLab pipeline consists of three stages: build, test and deploy. Each stage can have one or more jobs." +msgstr "" + +msgid "PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}" +msgstr "" + +msgid "PipelineEditorTutorial|Get started with GitLab CI/CD" +msgstr "" + +msgid "PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application." +msgstr "" + +msgid "PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}" +msgstr "" + +msgid "PipelineEditorTutorial|In the example below, %{codeStart}build%{codeEnd} and %{codeStart}deploy%{codeEnd} each contain one job, and %{codeStart}test%{codeEnd} contains two jobs. Your scripts run in jobs like these." +msgstr "" + +msgid "PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}" +msgstr "" + +msgid "PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}" +msgstr "" + +msgid "PipelineEditorTutorial|Resources to help with your CI/CD configuration:" +msgstr "" + +msgid "PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor." +msgstr "" + +msgid "PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes." +msgstr "" + +msgid "PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}" +msgstr "" + +msgid "PipelineEditorTutorial|You can use %{linkStart}CI/CD examples and templates%{linkEnd} to get your first %{codeStart}.gitlab-ci.yml%{codeEnd} configuration file started. Your first pipeline runs when you commit the changes." +msgstr "" + +msgid "PipelineEditorTutorial|⚙️ Pipeline configuration reference" +msgstr "" + +msgid "PipelineEditorTutorial|💡 Tip: Visualize and validate your pipeline" +msgstr "" + +msgid "PipelineEditorTutorial|🚀 Run your first pipeline" +msgstr "" + msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty." msgstr "" @@ -30512,6 +30581,15 @@ msgstr "" msgid "Stage removed" msgstr "" +msgid "StageName|Build" +msgstr "" + +msgid "StageName|Deploy" +msgstr "" + +msgid "StageName|Test" +msgstr "" + msgid "Standard" msgstr "" diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index 54b190a220a..b666f73110a 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Groups::GroupMembersController do expect(response).to render_template(:index) end - context 'user with owner access' do + context 'when user can manage members' do let_it_be(:invited) { create_list(:group_member, 3, :invited, group: group) } before do @@ -71,6 +71,19 @@ RSpec.describe Groups::GroupMembersController do end end + context 'when user cannot manage members' do + before do + sign_in(user) + end + + it 'does not assign invited members or skip_groups', :aggregate_failures do + get :index, params: { group_id: group } + + expect(assigns(:invited_members)).to be_nil + expect(assigns(:skip_groups)).to be_nil + end + end + context 'when user has owner access to subgroup' do let_it_be(:nested_group) { create(:group, parent: group) } let_it_be(:nested_group_user) { create(:user) } diff --git a/spec/finders/concerns/packages/finder_helper_spec.rb b/spec/finders/concerns/packages/finder_helper_spec.rb index c1740ee1796..bad4c482bc6 100644 --- a/spec/finders/concerns/packages/finder_helper_spec.rb +++ b/spec/finders/concerns/packages/finder_helper_spec.rb @@ -3,6 +3,30 @@ require 'spec_helper' RSpec.describe ::Packages::FinderHelper do + describe '#packages_for_project' do + let_it_be_with_reload(:project1) { create(:project) } + let_it_be(:package1) { create(:package, project: project1) } + let_it_be(:package2) { create(:package, :error, project: project1) } + let_it_be(:project2) { create(:project) } + let_it_be(:package3) { create(:package, project: project2) } + + let(:finder_class) do + Class.new do + include ::Packages::FinderHelper + + def execute(project1) + packages_for_project(project1) + end + end + end + + let(:finder) { finder_class.new } + + subject { finder.execute(project1) } + + it { is_expected.to eq [package1]} + end + describe '#packages_visible_to_user' do using RSpec::Parameterized::TableSyntax @@ -12,6 +36,7 @@ RSpec.describe ::Packages::FinderHelper do let_it_be_with_reload(:subgroup) { create(:group, parent: group) } let_it_be_with_reload(:project2) { create(:project, namespace: subgroup) } let_it_be(:package2) { create(:package, project: project2) } + let_it_be(:package3) { create(:package, :error, project: project2) } let(:finder_class) do Class.new do diff --git a/spec/finders/packages/composer/packages_finder_spec.rb b/spec/finders/packages/composer/packages_finder_spec.rb new file mode 100644 index 00000000000..d4328827de3 --- /dev/null +++ b/spec/finders/packages/composer/packages_finder_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe ::Packages::Composer::PackagesFinder do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + let(:params) { {} } + + describe '#execute' do + let_it_be(:composer_package) { create(:composer_package, project: project) } + let_it_be(:composer_package2) { create(:composer_package, project: project) } + let_it_be(:error_package) { create(:composer_package, :error, project: project) } + let_it_be(:composer_package3) { create(:composer_package) } + + subject { described_class.new(user, group, params).execute } + + before do + project.add_developer(user) + end + + it { is_expected.to match_array([composer_package, composer_package2]) } + end +end diff --git a/spec/finders/packages/conan/package_finder_spec.rb b/spec/finders/packages/conan/package_finder_spec.rb index 936a0e5ff4b..b26f8900090 100644 --- a/spec/finders/packages/conan/package_finder_spec.rb +++ b/spec/finders/packages/conan/package_finder_spec.rb @@ -11,7 +11,8 @@ RSpec.describe ::Packages::Conan::PackageFinder do subject { described_class.new(user, query: query).execute } - context 'packages that are not visible to user' do + context 'packages that are not installable' do + let!(:conan_package3) { create(:conan_package, :error, project: project) } let!(:non_visible_project) { create(:project, :private) } let!(:non_visible_conan_package) { create(:conan_package, project: non_visible_project) } let(:query) { "#{conan_package.name.split('/').first[0, 3]}%" } diff --git a/spec/finders/packages/generic/package_finder_spec.rb b/spec/finders/packages/generic/package_finder_spec.rb index ed34268e7a9..707f943b285 100644 --- a/spec/finders/packages/generic/package_finder_spec.rb +++ b/spec/finders/packages/generic/package_finder_spec.rb @@ -23,6 +23,13 @@ RSpec.describe ::Packages::Generic::PackageFinder do expect(found_package).to eq(package) end + it 'does not find uninstallable packages' do + error_package = create(:generic_package, :error, project: project) + + expect { finder.execute!(error_package.name, error_package.version) } + .to raise_error(ActiveRecord::RecordNotFound) + end + it 'raises ActiveRecord::RecordNotFound if package is not found' do expect { finder.execute!(package.name, '3.1.4') } .to raise_error(ActiveRecord::RecordNotFound) diff --git a/spec/finders/packages/go/package_finder_spec.rb b/spec/finders/packages/go/package_finder_spec.rb index b6fad1e7061..dbcb8255d47 100644 --- a/spec/finders/packages/go/package_finder_spec.rb +++ b/spec/finders/packages/go/package_finder_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Packages::Go::PackageFinder do let_it_be(:mod) { create :go_module, project: project } let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.1' } - let_it_be(:package) { create :golang_package, project: project, name: mod.name, version: 'v1.0.1' } + let_it_be_with_refind(:package) { create :golang_package, project: project, name: mod.name, version: 'v1.0.1' } let(:finder) { described_class.new(project, mod_name, version_name) } @@ -54,6 +54,17 @@ RSpec.describe Packages::Go::PackageFinder do it { is_expected.to eq(package) } end + context 'with an uninstallable package' do + let(:mod_name) { mod.name } + let(:version_name) { version.name } + + before do + package.update_column(:status, 1) + end + + it { is_expected.to eq(nil) } + end + context 'with an invalid name' do let(:mod_name) { 'foo/bar' } let(:version_name) { 'baz' } diff --git a/spec/finders/packages/maven/package_finder_spec.rb b/spec/finders/packages/maven/package_finder_spec.rb index 9a6bb675248..d5f521ff895 100644 --- a/spec/finders/packages/maven/package_finder_spec.rb +++ b/spec/finders/packages/maven/package_finder_spec.rb @@ -6,7 +6,7 @@ RSpec.describe ::Packages::Maven::PackageFinder do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, namespace: group) } - let_it_be(:package) { create(:maven_package, project: project) } + let_it_be_with_refind(:package) { create(:maven_package, project: project) } let(:param_path) { nil } let(:param_project) { nil } @@ -36,6 +36,16 @@ RSpec.describe ::Packages::Maven::PackageFinder do expect { subject }.to raise_error(ActiveRecord::RecordNotFound) end end + + context 'with an uninstallable package' do + let(:param_path) { package.maven_metadatum.path } + + before do + package.update_column(:status, 1) + end + + it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + end end context 'within the project' do diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb index f021d800f31..a995f3b96c4 100644 --- a/spec/finders/packages/npm/package_finder_spec.rb +++ b/spec/finders/packages/npm/package_finder_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe ::Packages::Npm::PackageFinder do let_it_be_with_reload(:project) { create(:project)} - let_it_be(:package) { create(:npm_package, project: project) } + let_it_be_with_refind(:package) { create(:npm_package, project: project) } let(:project) { package.project } let(:package_name) { package.name } @@ -46,6 +46,14 @@ RSpec.describe ::Packages::Npm::PackageFinder do it { is_expected.to be_empty } end + + context 'with an uninstallable package' do + before do + package.update_column(:status, 1) + end + + it { is_expected.to be_empty } + end end subject { finder.execute } diff --git a/spec/finders/packages/nuget/package_finder_spec.rb b/spec/finders/packages/nuget/package_finder_spec.rb index 10b5f6c8ec2..59cca2d06dc 100644 --- a/spec/finders/packages/nuget/package_finder_spec.rb +++ b/spec/finders/packages/nuget/package_finder_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Packages::Nuget::PackageFinder do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:project) { create(:project, namespace: subgroup) } - let_it_be(:package1) { create(:nuget_package, project: project) } + let_it_be_with_refind(:package1) { create(:nuget_package, project: project) } let_it_be(:package2) { create(:nuget_package, name: package1.name, version: '2.0.0', project: project) } let_it_be(:package3) { create(:nuget_package, name: 'Another.Dummy.Package', project: project) } let_it_be(:other_package_1) { create(:nuget_package, name: package1.name, version: package1.version) } @@ -33,6 +33,14 @@ RSpec.describe Packages::Nuget::PackageFinder do it { is_expected.to be_empty } end + context 'with an uninstallable package' do + before do + package1.update_column(:status, 1) + end + + it { is_expected.to contain_exactly(package2) } + end + context 'with valid version' do let(:package_version) { '2.0.0' } diff --git a/spec/finders/packages/package_finder_spec.rb b/spec/finders/packages/package_finder_spec.rb index e8c7404a612..6a1d857dad4 100644 --- a/spec/finders/packages/package_finder_spec.rb +++ b/spec/finders/packages/package_finder_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe ::Packages::PackageFinder do let_it_be(:project) { create(:project) } - let_it_be(:maven_package) { create(:maven_package, project: project) } + let_it_be_with_refind(:maven_package) { create(:maven_package, project: project) } describe '#execute' do let(:package_id) { maven_package.id } @@ -13,6 +13,16 @@ RSpec.describe ::Packages::PackageFinder do it { is_expected.to eq(maven_package) } + context 'with non-displayable package' do + before do + maven_package.update_column(:status, 1) + end + + it 'raises an exception' do + expect { subject }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + context 'processing packages' do let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } let(:package_id) { nuget_package.id } diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js new file mode 100644 index 00000000000..8a4f07c4d88 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -0,0 +1,47 @@ +import { getByRole } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; + +describe('First pipeline card', () => { + let wrapper; + + const defaultProvide = { + ciExamplesHelpPagePath: '/pipelines/examples', + runnerHelpPagePath: '/help/runners', + }; + + const createComponent = () => { + wrapper = mount(FirstPipelineCard, { + provide: { + ...defaultProvide, + }, + }); + }; + + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const findPipelinesLink = () => getLinkByName(/examples and templates/i); + const findRunnersLink = () => getLinkByName(/make sure your instance has runners available/i); + const findVisualReference = () => wrapper.findComponent(PipelineVisualReference); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(findVisualReference().exists()).toBe(true); + }); + + it('renders the links', () => { + expect(findRunnersLink()).toContain(defaultProvide.runnerHelpPagePath); + expect(findPipelinesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js new file mode 100644 index 00000000000..c592e959068 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; + +describe('Getting started card', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(GettingStartedCard); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js new file mode 100644 index 00000000000..3c8821d05a7 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -0,0 +1,51 @@ +import { getByRole } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; + +describe('Pipeline config reference card', () => { + let wrapper; + + const defaultProvide = { + ciExamplesHelpPagePath: 'help/ci/examples/', + ciHelpPagePath: 'help/ci/introduction', + needsHelpPagePath: 'help/ci/yaml#needs', + ymlHelpPagePath: 'help/ci/yaml', + }; + + const createComponent = () => { + wrapper = mount(PipelineConfigReferenceCard, { + provide: { + ...defaultProvide, + }, + }); + }; + + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const findCiExamplesLink = () => getLinkByName(/CI\/CD examples and templates/i); + const findCiIntroLink = () => getLinkByName(/GitLab CI\/CD concepts/i); + const findNeedsLink = () => getLinkByName(/Needs keyword/i); + const findYmlSyntaxLink = () => getLinkByName(/.gitlab-ci.yml syntax reference/i); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); + + it('renders the links', () => { + expect(findCiExamplesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); + expect(findCiIntroLink()).toContain(defaultProvide.ciHelpPagePath); + expect(findNeedsLink()).toContain(defaultProvide.needsHelpPagePath); + expect(findYmlSyntaxLink()).toContain(defaultProvide.ymlHelpPagePath); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js new file mode 100644 index 00000000000..bebd2484c1d --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; + +describe('Visual and Lint card', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(VisualizeAndLintCard); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index 587373c99b4..fea7d90de52 100644 --- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,4 +1,9 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; +import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; +import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; describe('Pipeline editor drawer', () => { @@ -8,7 +13,12 @@ describe('Pipeline editor drawer', () => { wrapper = shallowMount(PipelineEditorDrawer); }; - const findToggleBtn = () => wrapper.find('[data-testid="toggleBtn"]'); + const findFirstPipelineCard = () => wrapper.findComponent(FirstPipelineCard); + const findGettingStartedCard = () => wrapper.findComponent(GettingStartedCard); + const findPipelineConfigReferenceCard = () => wrapper.findComponent(PipelineConfigReferenceCard); + const findToggleBtn = () => wrapper.findComponent(GlButton); + const findVisualizeAndLintCard = () => wrapper.findComponent(VisualizeAndLintCard); + const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]'); const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]'); const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]'); @@ -24,7 +34,7 @@ describe('Pipeline editor drawer', () => { createComponent(); }); - it('show the left facing arrow icon', () => { + it('shows the left facing arrow icon', () => { expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left'); }); @@ -51,7 +61,7 @@ describe('Pipeline editor drawer', () => { await clickToggleBtn(); }); - it('show the right facing arrow icon', () => { + it('shows the right facing arrow icon', () => { expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right'); }); @@ -59,10 +69,17 @@ describe('Pipeline editor drawer', () => { expect(findCollapseText().exists()).toBe(true); }); - it('show the drawer content', () => { + it('shows the drawer content', () => { expect(findDrawerContent().exists()).toBe(true); }); + it('shows all the introduction cards', () => { + expect(findFirstPipelineCard().exists()).toBe(true); + expect(findGettingStartedCard().exists()).toBe(true); + expect(findPipelineConfigReferenceCard().exists()).toBe(true); + expect(findVisualizeAndLintCard().exists()).toBe(true); + }); + it('can close the drawer by clicking on the toggle button', async () => { expect(findDrawerContent().exists()).toBe(true); diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js new file mode 100644 index 00000000000..edd2b45569a --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; + +describe('Demo job pill', () => { + let wrapper; + const jobName = 'my-build-job'; + + const createComponent = () => { + wrapper = shallowMount(DemoJobPill, { + propsData: { + jobName, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the jobName', () => { + expect(wrapper.text()).toContain(jobName); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js b/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js new file mode 100644 index 00000000000..e4834544484 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; +import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; + +describe('Demo job pill', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineVisualReference); + }; + + const findAllDemoJobPills = () => wrapper.findAllComponents(DemoJobPill); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all stage names', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.build); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.test); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.deploy); + }); + + it('renders all job pills', () => { + expect(findAllDemoJobPills()).toHaveLength(4); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index 4fe44a3307a..632f506f4ae 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,7 +1,10 @@ import { GlButton, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { UNSCHEDULED_STATUS, @@ -16,15 +19,18 @@ import { ROOT_IMAGE_TEXT, ROOT_IMAGE_TOOLTIP, } from '~/registry/explorer/constants'; +import getContainerRepositoryTagCountQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { imageTagsCountMock } from '../../mock_data'; describe('Details Header', () => { let wrapper; + let apolloProvider; + let localVue; const defaultImage = { name: 'foo', updatedAt: '2020-11-03T13:29:21Z', - tagsCount: 10, canDelete: true, project: { visibility: 'public', @@ -51,12 +57,31 @@ describe('Details Header', () => { await wrapper.vm.$nextTick(); }; - const mountComponent = (propsData = { image: defaultImage }) => { + const mountComponent = ({ + propsData = { image: defaultImage }, + resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), + $apollo = undefined, + } = {}) => { + const mocks = {}; + + if ($apollo) { + mocks.$apollo = $apollo; + } else { + localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + } + wrapper = shallowMount(component, { + localVue, + apolloProvider, propsData, directives: { GlTooltip: createMockDirective(), }, + mocks, stubs: { TitleArea, }, @@ -64,41 +89,48 @@ describe('Details Header', () => { }; afterEach(() => { + // if we want to mix createMockApollo and manual mocks we need to reset everything wrapper.destroy(); + apolloProvider = undefined; + localVue = undefined; wrapper = null; }); + describe('image name', () => { describe('missing image name', () => { - it('root image ', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); + beforeEach(() => { + mountComponent({ propsData: { image: { ...defaultImage, name: '' } } }); + + return waitForPromises(); + }); + it('root image ', () => { expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); }); it('has an icon', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); - expect(findInfoIcon().exists()).toBe(true); expect(findInfoIcon().props('name')).toBe('information-o'); }); it('has a tooltip', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); - const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); }); }); describe('with image name present', () => { - it('shows image.name ', () => { + beforeEach(() => { mountComponent(); + + return waitForPromises(); + }); + + it('shows image.name ', () => { expect(findTitle().text()).toContain('foo'); }); it('has no icon', () => { - mountComponent(); - expect(findInfoIcon().exists()).toBe(false); }); }); @@ -111,12 +143,6 @@ describe('Details Header', () => { expect(findDeleteButton().exists()).toBe(true); }); - it('is hidden while loading', () => { - mountComponent({ image: defaultImage, metadataLoading: true }); - - expect(findDeleteButton().exists()).toBe(false); - }); - it('has the correct text', () => { mountComponent(); @@ -149,7 +175,7 @@ describe('Details Header', () => { `( 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', ({ canDelete, disabled, isDisabled }) => { - mountComponent({ image: { ...defaultImage, canDelete }, disabled }); + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); expect(findDeleteButton().props('disabled')).toBe(isDisabled); }, @@ -158,15 +184,32 @@ describe('Details Header', () => { describe('metadata items', () => { describe('tags count', () => { + it('displays "-- tags" while loading', async () => { + // here we are forced to mock apollo because `waitForMetadataItems` waits + // for two ticks, de facto allowing the promise to resolve, so there is + // no way to catch the component as both rendered and in loading state + mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); + + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('-- tags'); + }); + it('when there is more than one tag has the correct text', async () => { mountComponent(); + + await waitForPromises(); await waitForMetadataItems(); - expect(findTagsCount().props('text')).toBe('10 tags'); + expect(findTagsCount().props('text')).toBe('13 tags'); }); it('when there is one tag has the correct text', async () => { - mountComponent({ image: { ...defaultImage, tagsCount: 1 } }); + mountComponent({ + resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })), + }); + + await waitForPromises(); await waitForMetadataItems(); expect(findTagsCount().props('text')).toBe('1 tag'); @@ -208,11 +251,13 @@ describe('Details Header', () => { 'when the status is $status the text is $text and the tooltip is $tooltip', async ({ status, text, tooltip }) => { mountComponent({ - image: { - ...defaultImage, - expirationPolicyCleanupStatus: status, - project: { - containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + propsData: { + image: { + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, }, }, }); @@ -242,7 +287,9 @@ describe('Details Header', () => { expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); }); it('shows an eye slashed when the project is not public', async () => { - mountComponent({ image: { ...defaultImage, project: { visibility: 'private' } } }); + mountComponent({ + propsData: { image: { ...defaultImage, project: { visibility: 'private' } } }, + }); await waitForMetadataItems(); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index 7d544b71466..fe258dcd4e8 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -113,7 +113,6 @@ export const containerRepositoryMock = { canDelete: true, createdAt: '2020-11-03T13:29:21Z', updatedAt: '2020-11-03T13:29:21Z', - tagsCount: 13, expirationPolicyStartedAt: null, expirationPolicyCleanupStatus: 'UNSCHEDULED', project: { @@ -175,6 +174,16 @@ export const imageTagsMock = (nodes = tagsMock) => ({ }, }); +export const imageTagsCountMock = (override) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tagsCount: 13, + ...override, + }, + }, +}); + export const graphQLImageDetailsMock = (override) => ({ data: { containerRepository: { diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index eb01fb1a7e6..022f6e71fe6 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -292,7 +292,6 @@ describe('Details Page', () => { await waitForApolloRequestRender(); expect(findDetailsHeader().props()).toMatchObject({ - metadataLoading: false, image: { name: containerRepositoryMock.name, project: { diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js index a5d91468ef2..eb6e3711e2e 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js @@ -1,4 +1,5 @@ -import { mount } from '@vue/test-utils'; +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; import { deploymentMockData } from './deployment_mock_data'; @@ -11,14 +12,14 @@ const appButtonText = { describe('Deployment View App button', () => { let wrapper; - const factory = (options = {}) => { - wrapper = mount(DeploymentViewButton, { + const createComponent = (options = {}) => { + wrapper = mountExtended(DeploymentViewButton, { ...options, }); }; beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: deploymentMockData, appButtonText, @@ -30,15 +31,21 @@ describe('Deployment View App button', () => { wrapper.destroy(); }); + const findReviewAppLink = () => wrapper.findComponent(ReviewAppLink); + const findMrWigdetDeploymentDropdown = () => wrapper.findComponent(GlDropdown); + const findMrWigdetDeploymentDropdownIcon = () => + wrapper.findByTestId('mr-wigdet-deployment-dropdown-icon'); + const findDeployUrlMenuItems = () => wrapper.findAllComponents(GlLink); + describe('text', () => { it('renders text as passed', () => { - expect(wrapper.find(ReviewAppLink).text()).toContain(appButtonText.text); + expect(findReviewAppLink().props().display.text).toBe(appButtonText.text); }); }); describe('without changes', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: { ...deploymentMockData, changes: null }, appButtonText, @@ -47,13 +54,13 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app without dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); }); }); describe('with a single change', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, appButtonText, @@ -62,21 +69,20 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app without dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false); }); it('renders the link to the review app linked to to the first change', () => { const expectedUrl = deploymentMockData.changes[0].external_url; - const deployUrl = wrapper.find('.js-deploy-url'); - expect(deployUrl.attributes().href).not.toBeNull(); - expect(deployUrl.attributes().href).toEqual(expectedUrl); + expect(findReviewAppLink().attributes('href')).toBe(expectedUrl); }); }); describe('with multiple changes', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: deploymentMockData, appButtonText, @@ -85,18 +91,18 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app with dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(true); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(true); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(true); }); it('renders all the links to the review apps', () => { - const allUrls = wrapper.findAll('.js-deploy-url-menu-item').wrappers; + const allUrls = findDeployUrlMenuItems().wrappers; const expectedUrls = deploymentMockData.changes.map((change) => change.external_url); expectedUrls.forEach((expectedUrl, idx) => { const deployUrl = allUrls[idx]; - expect(deployUrl.attributes().href).not.toBeNull(); - expect(deployUrl.attributes().href).toEqual(expectedUrl); + expect(deployUrl.attributes('href')).toBe(expectedUrl); }); }); }); diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index e276796f3ec..aacfc3b91c6 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -40,16 +40,21 @@ RSpec.describe Ci::PipelineEditorHelper do it 'returns pipeline editor data' do expect(pipeline_editor_data).to eq({ "ci-config-path": project.ci_config_path_or_default, + "ci-examples-help-page-path" => help_page_path('ci/examples/README'), + "ci-help-page-path" => help_page_path('ci/README'), "commit-sha" => project.commit.sha, "default-branch" => project.default_branch, "empty-state-illustration-path" => 'foo', "initial-branch-name": nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => graphql_etag_pipeline_sha_path(project.commit.sha), + "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, + "runner-help-page-path" => help_page_path('ci/runners/README'), "yml-help-page-path" => help_page_path('ci/yaml/README') }) end @@ -61,16 +66,21 @@ RSpec.describe Ci::PipelineEditorHelper do it 'returns pipeline editor data' do expect(pipeline_editor_data).to eq({ "ci-config-path": project.ci_config_path_or_default, + "ci-examples-help-page-path" => help_page_path('ci/examples/README'), + "ci-help-page-path" => help_page_path('ci/README'), "commit-sha" => '', "default-branch" => project.default_branch, "empty-state-illustration-path" => 'foo', "initial-branch-name": nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => '', + "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, + "runner-help-page-path" => help_page_path('ci/runners/README'), "yml-help-page-path" => help_page_path('ci/yaml/README') }) end diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb index 95b78ceb5d5..60ff15a88e0 100644 --- a/spec/lib/banzai/cross_project_reference_spec.rb +++ b/spec/lib/banzai/cross_project_reference_spec.rb @@ -4,10 +4,12 @@ require 'spec_helper' RSpec.describe Banzai::CrossProjectReference do let(:including_class) { Class.new.include(described_class).new } + let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {})} before do allow(including_class).to receive(:context).and_return({}) allow(including_class).to receive(:parent_from_ref).and_call_original + allow(including_class).to receive(:reference_cache).and_return(reference_cache) end describe '#parent_from_ref' do @@ -47,5 +49,18 @@ RSpec.describe Banzai::CrossProjectReference do expect(including_class.parent_from_ref('cross/reference')).to eq project2 end end + + context 'when reference cache is loaded' do + let(:project2) { double('referenced project') } + + before do + allow(reference_cache).to receive(:cache_loaded?).and_return(true) + allow(reference_cache).to receive(:parent_per_reference).and_return({ 'cross/reference' => project2 }) + end + + it 'pulls from the reference cache' do + expect(including_class.parent_from_ref('cross/reference')).to eq project2 + end + end end end diff --git a/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb b/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb index d10b52bf7d0..3cb3ebc42a6 100644 --- a/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb @@ -8,18 +8,6 @@ RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do let(:doc) { Nokogiri::HTML.fragment('') } let(:filter) { described_class.new(doc, project: project) } - describe '#references_per_parent' do - let(:doc) { Nokogiri::HTML.fragment("#1 #{project.full_path}#2 #2") } - - it 'returns a Hash containing references grouped per parent paths' do - expect(described_class).to receive(:object_class).exactly(6).times.and_return(Issue) - - refs = filter.references_per_parent - - expect(refs).to match(a_hash_including(project.full_path => contain_exactly(1, 2))) - end - end - describe '#data_attributes_for' do let_it_be(:issue) { create(:issue, project: project) } @@ -32,74 +20,6 @@ RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do end end - describe '#parent_per_reference' do - it 'returns a Hash containing projects grouped per parent paths' do - expect(filter).to receive(:references_per_parent) - .and_return({ project.full_path => Set.new([1]) }) - - expect(filter.parent_per_reference) - .to eq({ project.full_path => project }) - end - end - - describe '#find_for_paths' do - context 'with RequestStore disabled' do - it 'returns a list of Projects for a list of paths' do - expect(filter.find_for_paths([project.full_path])) - .to eq([project]) - end - - it "return an empty array for paths that don't exist" do - expect(filter.find_for_paths(['nonexistent/project'])) - .to eq([]) - end - end - - context 'with RequestStore enabled', :request_store do - it 'returns a list of Projects for a list of paths' do - expect(filter.find_for_paths([project.full_path])) - .to eq([project]) - end - - context 'when no project with that path exists' do - it 'returns no value' do - expect(filter.find_for_paths(['nonexistent/project'])) - .to eq([]) - end - - it 'adds the ref to the project refs cache' do - project_refs_cache = {} - allow(filter).to receive(:refs_cache).and_return(project_refs_cache) - - filter.find_for_paths(['nonexistent/project']) - - expect(project_refs_cache).to eq({ 'nonexistent/project' => nil }) - end - - context 'when the project refs cache includes nil values' do - before do - # adds { 'nonexistent/project' => nil } to cache - filter.from_ref_cached('nonexistent/project') - end - - it "return an empty array for paths that don't exist" do - expect(filter.find_for_paths(['nonexistent/project'])) - .to eq([]) - end - end - end - end - end - - describe '#current_parent_path' do - it 'returns the path of the current parent' do - doc = Nokogiri::HTML.fragment('') - filter = described_class.new(doc, project: project) - - expect(filter.current_parent_path).to eq(project.full_path) - end - end - context 'abstract methods' do describe '#find_object' do it 'raises NotImplementedError' do diff --git a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb index 0e1cb1ade74..88c2494b243 100644 --- a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb @@ -470,24 +470,6 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do end end - describe '#records_per_parent' do - context 'using an internal issue tracker' do - it 'returns a Hash containing the issues per project' do - doc = Nokogiri::HTML.fragment('') - filter = described_class.new(doc, project: project) - - expect(filter).to receive(:parent_per_reference) - .and_return({ project.full_path => project }) - - expect(filter).to receive(:references_per_parent) - .and_return({ project.full_path => Set.new([issue.iid]) }) - - expect(filter.records_per_parent) - .to eq({ project => { issue.iid => issue } }) - end - end - end - describe '.references_in' do let(:merge_request) { create(:merge_request) } diff --git a/spec/lib/banzai/filter/references/reference_cache_spec.rb b/spec/lib/banzai/filter/references/reference_cache_spec.rb new file mode 100644 index 00000000000..2e37e34bba5 --- /dev/null +++ b/spec/lib/banzai/filter/references/reference_cache_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Banzai::Filter::References::ReferenceCache do + let_it_be(:project) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:issue1) { create(:issue, project: project) } + let_it_be(:issue2) { create(:issue, project: project) } + let_it_be(:issue3) { create(:issue, project: project2) } + let_it_be(:doc) { Nokogiri::HTML.fragment("#{issue1.to_reference} #{issue2.to_reference} #{issue3.to_reference(full: true)}") } + + let(:filter_class) { Banzai::Filter::References::IssueReferenceFilter } + let(:filter) { filter_class.new(doc, project: project) } + let(:cache) { described_class.new(filter, { project: project }) } + + describe '#load_references_per_parent' do + it 'loads references grouped per parent paths' do + cache.load_references_per_parent(filter.nodes) + + expect(cache.references_per_parent).to eq({ project.full_path => [issue1.iid, issue2.iid].to_set, + project2.full_path => [issue3.iid].to_set }) + end + end + + describe '#load_parent_per_reference' do + it 'returns a Hash containing projects grouped per parent paths' do + cache.load_references_per_parent(filter.nodes) + cache.load_parent_per_reference + + expect(cache.parent_per_reference).to match({ project.full_path => project, project2.full_path => project2 }) + end + end + + describe '#load_records_per_parent' do + it 'returns a Hash containing projects grouped per parent paths' do + cache.load_references_per_parent(filter.nodes) + cache.load_parent_per_reference + cache.load_records_per_parent + + expect(cache.records_per_parent).to match({ project => { issue1.iid => issue1, issue2.iid => issue2 }, + project2 => { issue3.iid => issue3 } }) + end + end + + describe '#initialize_reference_cache' do + it 'does not have an N+1 query problem with cross projects' do + doc_single = Nokogiri::HTML.fragment("#1") + filter_single = filter_class.new(doc_single, project: project) + cache_single = described_class.new(filter_single, { project: project }) + + control_count = ActiveRecord::QueryRecorder.new do + cache_single.load_references_per_parent(filter_single.nodes) + cache_single.load_parent_per_reference + cache_single.load_records_per_parent + end.count + + # Since this is an issue filter that is not batching issue queries + # across projects, we have to account for that. + # 1 for both projects, 1 for issues in each project == 3 + max_count = control_count + 1 + + expect do + cache.load_references_per_parent(filter.nodes) + cache.load_parent_per_reference + cache.load_records_per_parent + end.not_to exceed_query_limit(max_count) + end + end + + describe '#find_for_paths' do + context 'with RequestStore disabled' do + it 'returns a list of Projects for a list of paths' do + expect(cache.find_for_paths([project.full_path])) + .to eq([project]) + end + + it 'return an empty array for paths that do not exist' do + expect(cache.find_for_paths(['nonexistent/project'])) + .to eq([]) + end + end + + context 'with RequestStore enabled', :request_store do + it 'returns a list of Projects for a list of paths' do + expect(cache.find_for_paths([project.full_path])) + .to eq([project]) + end + + context 'when no project with that path exists' do + it 'returns no value' do + expect(cache.find_for_paths(['nonexistent/project'])) + .to eq([]) + end + + it 'adds the ref to the project refs cache' do + project_refs_cache = {} + allow(cache).to receive(:refs_cache).and_return(project_refs_cache) + + cache.find_for_paths(['nonexistent/project']) + + expect(project_refs_cache).to eq({ 'nonexistent/project' => nil }) + end + end + end + end + + describe '#current_parent_path' do + it 'returns the path of the current parent' do + expect(cache.current_parent_path).to eq project.full_path + end + end + + describe '#current_project_namespace_path' do + it 'returns the path of the current project namespace' do + expect(cache.current_project_namespace_path).to eq project.namespace.full_path + end + end + + describe '#full_project_path' do + it 'returns current parent path when no ref specified' do + expect(cache.full_project_path('something', nil)).to eq cache.current_parent_path + end + + it 'returns combined namespace and project ref' do + expect(cache.full_project_path('something', 'cool')).to eq 'something/cool' + end + + it 'returns uses default namespace and project ref when namespace nil' do + expect(cache.full_project_path(nil, 'cool')).to eq "#{project.namespace.full_path}/cool" + end + end + + describe '#full_group_path' do + it 'returns current parent path when no group ref specified' do + expect(cache.full_group_path(nil)).to eq cache.current_parent_path + end + + it 'returns group ref' do + expect(cache.full_group_path('cool_group')).to eq 'cool_group' + end + end +end diff --git a/spec/lib/bulk_imports/clients/http_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb index 2d841b7fac2..213fa23675e 100644 --- a/spec/lib/bulk_imports/clients/http_spec.rb +++ b/spec/lib/bulk_imports/clients/http_spec.rb @@ -8,66 +8,23 @@ RSpec.describe BulkImports::Clients::Http do let(:uri) { 'http://gitlab.example' } let(:token) { 'token' } let(:resource) { 'resource' } + let(:response_double) { double(code: 200, success?: true, parsed_response: {}) } subject { described_class.new(uri: uri, token: token) } - describe '#get' do - let(:response_double) { double(code: 200, success?: true, parsed_response: {}) } - - shared_examples 'performs network request' do - it 'performs network request' do - expect(Gitlab::HTTP).to receive(:get).with(*expected_args).and_return(response_double) - - subject.get(resource) - end - end - - describe 'request query' do - include_examples 'performs network request' do - let(:expected_args) do - [ - anything, - hash_including( - query: { - page: described_class::DEFAULT_PAGE, - per_page: described_class::DEFAULT_PER_PAGE - } - ) - ] - end - end - end - - describe 'request headers' do - include_examples 'performs network request' do - let(:expected_args) do - [ - anything, - hash_including( - headers: { - 'Content-Type' => 'application/json', - 'Authorization' => "Bearer #{token}" - } - ) - ] - end - end - end + shared_examples 'performs network request' do + it 'performs network request' do + expect(Gitlab::HTTP).to receive(method).with(*expected_args).and_return(response_double) - describe 'request uri' do - include_examples 'performs network request' do - let(:expected_args) do - ['http://gitlab.example:80/api/v4/resource', anything] - end - end + subject.public_send(method, resource) end context 'error handling' do context 'when error occurred' do it 'raises ConnectionError' do - allow(Gitlab::HTTP).to receive(:get).and_raise(Errno::ECONNREFUSED) + allow(Gitlab::HTTP).to receive(method).and_raise(Errno::ECONNREFUSED) - expect { subject.get(resource) }.to raise_exception(described_class::ConnectionError) + expect { subject.public_send(method, resource) }.to raise_exception(described_class::ConnectionError) end end @@ -75,12 +32,34 @@ RSpec.describe BulkImports::Clients::Http do it 'raises ConnectionError' do response_double = double(code: 503, success?: false) - allow(Gitlab::HTTP).to receive(:get).and_return(response_double) + allow(Gitlab::HTTP).to receive(method).and_return(response_double) - expect { subject.get(resource) }.to raise_exception(described_class::ConnectionError) + expect { subject.public_send(method, resource) }.to raise_exception(described_class::ConnectionError) end end end + end + + describe '#get' do + let(:method) { :get } + + include_examples 'performs network request' do + let(:expected_args) do + [ + 'http://gitlab.example:80/api/v4/resource', + hash_including( + query: { + page: described_class::DEFAULT_PAGE, + per_page: described_class::DEFAULT_PER_PAGE + }, + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{token}" + } + ) + ] + end + end describe '#each_page' do let(:objects1) { [{ object: 1 }, { object: 2 }] } @@ -129,4 +108,23 @@ RSpec.describe BulkImports::Clients::Http do end end end + + describe '#post' do + let(:method) { :post } + + include_examples 'performs network request' do + let(:expected_args) do + [ + 'http://gitlab.example:80/api/v4/resource', + hash_including( + body: {}, + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{token}" + } + ) + ] + end + end + end end diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb new file mode 100644 index 00000000000..35928deff82 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren, :migration, schema: 20210506065000 do + let(:namespaces_table) { table(:namespaces) } + + let!(:user_namespace) { namespaces_table.create!(id: 1, name: 'user', path: 'user', type: nil) } + let!(:root_group) { namespaces_table.create!(id: 2, name: 'group', path: 'group', type: 'Group', parent_id: nil) } + let!(:sub_group) { namespaces_table.create!(id: 3, name: 'subgroup', path: 'subgroup', type: 'Group', parent_id: 2) } + + describe '#perform' do + it 'backfills traversal_ids for child namespaces' do + described_class.new.perform(1, 3, 5) + + expect(user_namespace.reload.traversal_ids).to eq([]) + expect(root_group.reload.traversal_ids).to eq([]) + expect(sub_group.reload.traversal_ids).to eq([root_group.id, sub_group.id]) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb new file mode 100644 index 00000000000..96e43275972 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots, :migration, schema: 20210506065000 do + let(:namespaces_table) { table(:namespaces) } + + let!(:user_namespace) { namespaces_table.create!(id: 1, name: 'user', path: 'user', type: nil) } + let!(:root_group) { namespaces_table.create!(id: 2, name: 'group', path: 'group', type: 'Group', parent_id: nil) } + let!(:sub_group) { namespaces_table.create!(id: 3, name: 'subgroup', path: 'subgroup', type: 'Group', parent_id: 2) } + + describe '#perform' do + it 'backfills traversal_ids for root namespaces' do + described_class.new.perform(1, 3, 5) + + expect(user_namespace.reload.traversal_ids).to eq([user_namespace.id]) + expect(root_group.reload.traversal_ids).to eq([root_group.id]) + expect(sub_group.reload.traversal_ids).to eq([]) + end + end +end diff --git a/spec/models/board_group_recent_visit_spec.rb b/spec/models/board_group_recent_visit_spec.rb index c6fbd263072..d2d287d8e24 100644 --- a/spec/models/board_group_recent_visit_spec.rb +++ b/spec/models/board_group_recent_visit_spec.rb @@ -3,9 +3,8 @@ require 'spec_helper' RSpec.describe BoardGroupRecentVisit do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:board) { create(:board, group: group) } + let_it_be(:board_parent) { create(:group) } + let_it_be(:board) { create(:board, group: board_parent) } describe 'relationships' do it { is_expected.to belong_to(:user) } @@ -19,56 +18,9 @@ RSpec.describe BoardGroupRecentVisit do it { is_expected.to validate_presence_of(:board) } end - describe '#visited' do - it 'creates a visit if one does not exists' do - expect { described_class.visited!(user, board) }.to change(described_class, :count).by(1) - end - - shared_examples 'was visited previously' do - let!(:visit) { create :board_group_recent_visit, group: board.group, board: board, user: user, updated_at: 7.days.ago } - - it 'updates the timestamp' do - freeze_time do - described_class.visited!(user, board) - - expect(described_class.count).to eq 1 - expect(described_class.first.updated_at).to be_like_time(Time.zone.now) - end - end - end - - it_behaves_like 'was visited previously' - - context 'when we try to create a visit that is not unique' do - before do - expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') - expect(described_class).to receive(:find_or_create_by).and_return(visit) - end - - it_behaves_like 'was visited previously' - end - end - - describe '#latest' do - def create_visit(time) - create :board_group_recent_visit, group: group, user: user, updated_at: time - end - - it 'returns the most recent visited' do - create_visit(7.days.ago) - create_visit(5.days.ago) - recent = create_visit(1.day.ago) - - expect(described_class.latest(user, group)).to eq recent - end - - it 'returns last 3 visited boards' do - create_visit(7.days.ago) - visit1 = create_visit(3.days.ago) - visit2 = create_visit(2.days.ago) - visit3 = create_visit(5.days.ago) - - expect(described_class.latest(user, group, count: 3)).to eq([visit2, visit1, visit3]) - end + it_behaves_like 'boards recent visit' do + let_it_be(:board_relation) { :board } + let_it_be(:board_parent_relation) { :group } + let_it_be(:visit_relation) { :board_group_recent_visit } end end diff --git a/spec/models/board_project_recent_visit_spec.rb b/spec/models/board_project_recent_visit_spec.rb index 145a4f5b1a7..262c3a8faaa 100644 --- a/spec/models/board_project_recent_visit_spec.rb +++ b/spec/models/board_project_recent_visit_spec.rb @@ -3,9 +3,8 @@ require 'spec_helper' RSpec.describe BoardProjectRecentVisit do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:board) { create(:board, project: project) } + let_it_be(:board_parent) { create(:project) } + let_it_be(:board) { create(:board, project: board_parent) } describe 'relationships' do it { is_expected.to belong_to(:user) } @@ -19,56 +18,9 @@ RSpec.describe BoardProjectRecentVisit do it { is_expected.to validate_presence_of(:board) } end - describe '#visited' do - it 'creates a visit if one does not exists' do - expect { described_class.visited!(user, board) }.to change(described_class, :count).by(1) - end - - shared_examples 'was visited previously' do - let!(:visit) { create :board_project_recent_visit, project: board.project, board: board, user: user, updated_at: 7.days.ago } - - it 'updates the timestamp' do - freeze_time do - described_class.visited!(user, board) - - expect(described_class.count).to eq 1 - expect(described_class.first.updated_at).to be_like_time(Time.zone.now) - end - end - end - - it_behaves_like 'was visited previously' - - context 'when we try to create a visit that is not unique' do - before do - expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') - expect(described_class).to receive(:find_or_create_by).and_return(visit) - end - - it_behaves_like 'was visited previously' - end - end - - describe '#latest' do - def create_visit(time) - create :board_project_recent_visit, project: project, user: user, updated_at: time - end - - it 'returns the most recent visited' do - create_visit(7.days.ago) - create_visit(5.days.ago) - recent = create_visit(1.day.ago) - - expect(described_class.latest(user, project)).to eq recent - end - - it 'returns last 3 visited boards' do - create_visit(7.days.ago) - visit1 = create_visit(3.days.ago) - visit2 = create_visit(2.days.ago) - visit3 = create_visit(5.days.ago) - - expect(described_class.latest(user, project, count: 3)).to eq([visit2, visit1, visit3]) - end + it_behaves_like 'boards recent visit' do + let_it_be(:board_relation) { :board } + let_it_be(:board_parent_relation) { :project } + let_it_be(:visit_relation) { :board_project_recent_visit } end end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index 652ea431696..d1b7125a6e6 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -125,4 +125,13 @@ RSpec.describe BulkImports::Entity, type: :model do end end end + + describe '#encoded_source_full_path' do + it 'encodes entity source full path' do + expected = 'foo%2Fbar' + entity = build(:bulk_import_entity, source_full_path: 'foo/bar') + + expect(entity.encoded_source_full_path).to eq(expected) + end + end end diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 5d5351eb9fe..ffdb9fc988c 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -660,27 +660,37 @@ RSpec.describe Packages::Package, type: :model do it { is_expected.to match_array([pypi_package]) } end - describe '.displayable' do + context 'status scopes' do let_it_be(:hidden_package) { create(:maven_package, :hidden) } let_it_be(:processing_package) { create(:maven_package, :processing) } let_it_be(:error_package) { create(:maven_package, :error) } - subject { described_class.displayable } + describe '.displayable' do + subject { described_class.displayable } - it 'does not include non-displayable packages', :aggregate_failures do - is_expected.to include(error_package) - is_expected.not_to include(hidden_package) - is_expected.not_to include(processing_package) + it 'does not include non-displayable packages', :aggregate_failures do + is_expected.to include(error_package) + is_expected.not_to include(hidden_package) + is_expected.not_to include(processing_package) + end end - end - describe '.with_status' do - let_it_be(:hidden_package) { create(:maven_package, :hidden) } + describe '.installable' do + subject { described_class.installable } - subject { described_class.with_status(:hidden) } + it 'does not include non-displayable packages', :aggregate_failures do + is_expected.not_to include(error_package) + is_expected.not_to include(hidden_package) + is_expected.not_to include(processing_package) + end + end + + describe '.with_status' do + subject { described_class.with_status(:hidden) } - it 'returns packages with specified status' do - is_expected.to match_array([hidden_package]) + it 'returns packages with specified status' do + is_expected.to match_array([hidden_package]) + end end end end diff --git a/spec/services/boards/visits/create_service_spec.rb b/spec/services/boards/visits/create_service_spec.rb index 64faa2cf07b..8910345d170 100644 --- a/spec/services/boards/visits/create_service_spec.rb +++ b/spec/services/boards/visits/create_service_spec.rb @@ -7,47 +7,20 @@ RSpec.describe Boards::Visits::CreateService do let(:user) { create(:user) } context 'when a project board' do - let(:project) { create(:project) } - let(:project_board) { create(:board, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:board) { create(:board, project: project) } - subject(:service) { described_class.new(project_board.resource_parent, user) } + let_it_be(:model) { BoardProjectRecentVisit } - it 'returns nil when there is no user' do - service.current_user = nil - - expect(service.execute(project_board)).to eq nil - end - - it 'returns nil when database is read-only' do - allow(Gitlab::Database).to receive(:read_only?) { true } - - expect(service.execute(project_board)).to eq nil - end - - it 'records the visit' do - expect(BoardProjectRecentVisit).to receive(:visited!).once - - service.execute(project_board) - end + it_behaves_like 'boards recent visit create service' end context 'when a group board' do - let(:group) { create(:group) } - let(:group_board) { create(:board, group: group) } - - subject(:service) { described_class.new(group_board.resource_parent, user) } - - it 'returns nil when there is no user' do - service.current_user = nil - - expect(service.execute(group_board)).to eq nil - end - - it 'records the visit' do - expect(BoardGroupRecentVisit).to receive(:visited!).once + let_it_be(:group) { create(:group) } + let_it_be(:board) { create(:board, group: group) } + let_it_be(:model) { BoardGroupRecentVisit } - service.execute(group_board) - end + it_behaves_like 'boards recent visit create service' end end end diff --git a/spec/services/packages/nuget/search_service_spec.rb b/spec/services/packages/nuget/search_service_spec.rb index db758dc6672..1838065c5be 100644 --- a/spec/services/packages/nuget/search_service_spec.rb +++ b/spec/services/packages/nuget/search_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Packages::Nuget::SearchService do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:project) { create(:project, namespace: subgroup) } - let_it_be(:package_a) { create(:nuget_package, project: project, name: 'DummyPackageA') } + let_it_be_with_refind(:package_a) { create(:nuget_package, project: project, name: 'DummyPackageA') } let_it_be(:packages_b) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageB') } let_it_be(:packages_c) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageC') } let_it_be(:package_d) { create(:nuget_package, project: project, name: 'FooBarD') } @@ -79,6 +79,16 @@ RSpec.describe Packages::Nuget::SearchService do it { expect_search_results 4, package_a, packages_b, packages_c, package_d } end + context 'with non-displayable packages' do + let(:search_term) { '' } + + before do + package_a.update_column(:status, 1) + end + + it { expect_search_results 3, packages_b, packages_c, package_d } + end + context 'with prefix search term' do let(:search_term) { 'dummy' } diff --git a/spec/support/shared_examples/finders/packages_shared_examples.rb b/spec/support/shared_examples/finders/packages_shared_examples.rb index 2d4e8d0df1f..b3ec2336cca 100644 --- a/spec/support/shared_examples/finders/packages_shared_examples.rb +++ b/spec/support/shared_examples/finders/packages_shared_examples.rb @@ -20,9 +20,11 @@ end RSpec.shared_examples 'concerning package statuses' do let_it_be(:hidden_package) { create(:maven_package, :hidden, project: project) } + let_it_be(:error_package) { create(:maven_package, :error, project: project) } - context 'hidden packages' do + context 'displayable packages' do it { is_expected.not_to include(hidden_package) } + it { is_expected.to include(error_package) } end context 'with status param' do diff --git a/spec/support/shared_examples/services/boards/boards_recent_visit_shared_examples.rb b/spec/support/shared_examples/services/boards/boards_recent_visit_shared_examples.rb new file mode 100644 index 00000000000..68ea460dabc --- /dev/null +++ b/spec/support/shared_examples/services/boards/boards_recent_visit_shared_examples.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'boards recent visit' do + let_it_be(:user) { create(:user) } + + describe '#visited' do + it 'creates a visit if one does not exists' do + expect { described_class.visited!(user, board) }.to change(described_class, :count).by(1) + end + + shared_examples 'was visited previously' do + let_it_be(:visit) do + create(visit_relation, + board_parent_relation => board_parent, + board_relation => board, + user: user, + updated_at: 7.days.ago + ) + end + + it 'updates the timestamp' do + freeze_time do + described_class.visited!(user, board) + + expect(described_class.count).to eq 1 + expect(described_class.first.updated_at).to be_like_time(Time.zone.now) + end + end + end + + it_behaves_like 'was visited previously' + + context 'when we try to create a visit that is not unique' do + before do + expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') + expect(described_class).to receive(:find_or_create_by).and_return(visit) + end + + it_behaves_like 'was visited previously' + end + end + + describe '#latest' do + def create_visit(time) + create(visit_relation, board_parent_relation => board_parent, user: user, updated_at: time) + end + + it 'returns the most recent visited' do + create_visit(7.days.ago) + create_visit(5.days.ago) + recent = create_visit(1.day.ago) + + expect(described_class.latest(user, board_parent)).to eq recent + end + + it 'returns last 3 visited boards' do + create_visit(7.days.ago) + visit1 = create_visit(3.days.ago) + visit2 = create_visit(2.days.ago) + visit3 = create_visit(5.days.ago) + + expect(described_class.latest(user, board_parent, count: 3)).to eq([visit2, visit1, visit3]) + end + end +end diff --git a/spec/support/shared_examples/services/boards/create_service_shared_examples.rb b/spec/support/shared_examples/services/boards/create_service_shared_examples.rb new file mode 100644 index 00000000000..63b5e3a5a84 --- /dev/null +++ b/spec/support/shared_examples/services/boards/create_service_shared_examples.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'boards recent visit create service' do + let_it_be(:user) { create(:user) } + + subject(:service) { described_class.new(board.resource_parent, user) } + + it 'returns nil when there is no user' do + service.current_user = nil + + expect(service.execute(board)).to be_nil + end + + it 'returns nil when database is read only' do + allow(Gitlab::Database).to receive(:read_only?) { true } + + expect(service.execute(board)).to be_nil + end + + it 'records the visit' do + expect(model).to receive(:visited!).once + + service.execute(board) + end +end diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index 5964ec45563..9119394f250 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -69,7 +69,7 @@ RSpec.describe BulkImportWorker do end context 'when there are created entities to process' do - it 'marks a batch of entities as started, enqueues BulkImports::EntityWorker and reenqueues' do + it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1) bulk_import = create(:bulk_import, :created) @@ -78,6 +78,7 @@ RSpec.describe BulkImportWorker do expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id) expect(BulkImports::EntityWorker).to receive(:perform_async) + expect(BulkImports::ExportRequestWorker).to receive(:perform_async) subject.perform(bulk_import.id) diff --git a/spec/workers/bulk_imports/export_request_worker_spec.rb b/spec/workers/bulk_imports/export_request_worker_spec.rb new file mode 100644 index 00000000000..f7838279212 --- /dev/null +++ b/spec/workers/bulk_imports/export_request_worker_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::ExportRequestWorker do + let_it_be(:bulk_import) { create(:bulk_import) } + let_it_be(:config) { create(:bulk_import_configuration, bulk_import: bulk_import) } + let_it_be(:entity) { create(:bulk_import_entity, source_full_path: 'foo/bar', bulk_import: bulk_import) } + + let(:response_double) { double(code: 200, success?: true, parsed_response: {}) } + let(:job_args) { [entity.id] } + + describe '#perform' do + before do + allow(Gitlab::HTTP).to receive(:post).and_return(response_double) + end + + include_examples 'an idempotent worker' do + it 'requests relations export' do + expected = "/groups/foo%2Fbar/export_relations" + + expect_next_instance_of(BulkImports::Clients::Http) do |client| + expect(client).to receive(:post).with(expected).twice + end + + perform_multiple(job_args) + end + end + end +end |