diff options
127 files changed, 2104 insertions, 566 deletions
diff --git a/.gitlab/issue_templates/Doc_cleanup.md b/.gitlab/issue_templates/Doc_cleanup.md index 3ea692ed1ac..1eb3829e281 100644 --- a/.gitlab/issue_templates/Doc_cleanup.md +++ b/.gitlab/issue_templates/Doc_cleanup.md @@ -1,5 +1,3 @@ -/labels ~"documentation" ~"docs-only" ~"documentation" ~"docs::improvement" ~"type::maintenance" ~"maintenance::refactor" ~"Seeking community contributions" ~"quick win" ~"Technical Writing" - <!-- * Use this template for documentation issues identified * by [Vale](https://docs.gitlab.com/ee/development/documentation/testing.html#vale) @@ -16,6 +14,8 @@ Do you want to work on this issue? - **If the issue is unassigned**, in a comment, type `@docs-hackathon I would like to work on this issue` and a writer will assign it to you. + To be fair to others, do not ask for more than three issues at a time. + - **If the issue is assigned to someone already**, choose another issue. Do not open a merge request for this issue if you are not assigned. ## To resolve the issue @@ -35,4 +35,4 @@ Thank you again for contributing to the GitLab documentation! :tada: ## Documentation issue - +/labels ~"documentation" ~"docs-only" ~"documentation" ~"docs::improvement" ~"type::maintenance" ~"maintenance::refactor" ~"Seeking community contributions" ~"quick win" ~"Technical Writing" diff --git a/.rubocop_todo/gitlab/namespaced_class.yml b/.rubocop_todo/gitlab/namespaced_class.yml index e49169bae67..bc5b49888cd 100644 --- a/.rubocop_todo/gitlab/namespaced_class.yml +++ b/.rubocop_todo/gitlab/namespaced_class.yml @@ -371,6 +371,7 @@ Gitlab/NamespacedClass: - 'app/policies/deploy_keys_project_policy.rb' - 'app/policies/deploy_token_policy.rb' - 'app/policies/deployment_policy.rb' + - 'app/policies/description_version_policy.rb' - 'app/policies/draft_note_policy.rb' - 'app/policies/environment_policy.rb' - 'app/policies/external_issue_policy.rb' diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue new file mode 100644 index 00000000000..93477a01073 --- /dev/null +++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue @@ -0,0 +1,92 @@ +<script> +import { GlButton, GlDrawer, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + name: 'AbuseCategorySelector', + csrf, + components: { + GlButton, + GlDrawer, + GlForm, + GlFormGroup, + GlFormRadioGroup, + }, + inject: ['formSubmitPath', 'userId', 'reportedFromUrl'], + props: { + showDrawer: { + type: Boolean, + required: true, + }, + }, + i18n: { + title: __('Report abuse to administrator'), + close: __('Close'), + label: s__('ReportAbuse|Why are you reporting this user?'), + next: __('Next'), + }, + categoryOptions: [ + { value: 'spam', text: s__("ReportAbuse|They're posting spam.") }, + { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") }, + { value: 'phishing', text: s__("ReportAbuse|They're phising.") }, + { value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") }, + { + value: 'credentials', + text: s__("ReportAbuse|They're posting personal information or credentials."), + }, + { value: 'copyright', text: s__("ReportAbuse|They're violating a copyright or trademark.") }, + { value: 'malware', text: s__("ReportAbuse|They're posting malware.") }, + { value: 'other', text: s__('ReportAbuse|Something else.') }, + ], + data() { + return { + selected: '', + }; + }, + computed: { + drawerOffsetTop() { + const wrapperEl = document.querySelector('.content-wrapper'); + return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; + }, + }, + methods: { + closeDrawer() { + this.$emit('close-drawer'); + }, + }, +}; +</script> +<template> + <gl-drawer :header-height="drawerOffsetTop" :open="showDrawer" @close="closeDrawer"> + <template #title> + <h2 + class="gl-font-size-h2 gl-mt-0 gl-mb-0 gl-line-height-24" + data-testid="category-drawer-title" + > + {{ $options.i18n.title }} + </h2> + </template> + <template #default> + <gl-form :action="formSubmitPath" method="post" class="gl-text-left"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + + <input type="hidden" name="user_id" :value="userId" data-testid="input-user-id" /> + <input type="hidden" name="ref_url" :value="reportedFromUrl" data-testid="input-referer" /> + + <gl-form-group :label="$options.i18n.label"> + <gl-form-radio-group + v-model="selected" + :options="$options.categoryOptions" + name="abuse_report[category]" + required + /> + </gl-form-group> + + <gl-button type="submit" variant="confirm" data-testid="submit-form-button"> + {{ $options.i18n.next }} + </gl-button> + </gl-form> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index d712c90242c..ff301a99243 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -11,6 +11,7 @@ export default function initGFMInput($els) { emojis: true, members: enableGFM, issues: enableGFM, + iterations: enableGFM, milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 2eab5b84e3e..04b3599ea8c 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -18,6 +18,10 @@ function initPopovers(elements) { // Render GitLab flavoured Markdown export function renderGFM(element) { + if (!element) { + return; + } + const [ highlightEls, krokiEls, diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 216796b357c..56461165588 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -1,9 +1,9 @@ <script> -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; export default { components: { - CiBadge, + CiBadgeLink, }, props: { schedule: { @@ -24,7 +24,11 @@ export default { <template> <div> - <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" /> + <ci-badge-link + v-if="hasPipeline" + :status="lastPipelineStatus" + class="gl-vertical-align-middle" + /> <span v-else data-testid="pipeline-schedule-status-text"> {{ s__('PipelineSchedules|None') }} </span> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index efa7909c913..e359344ab77 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -3,7 +3,7 @@ import { GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { durationTimeFormatted } from '~/lib/utils/datetime_utility'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; @@ -11,7 +11,7 @@ import LinkCell from './cells/link_cell.vue'; export default { components: { - CiBadge, + CiBadgeLink, GlTableLite, LinkCell, RunnerTags, @@ -80,7 +80,7 @@ export default { fixed > <template #cell(status)="{ item = {} }"> - <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + <ci-badge-link v-if="item.detailedStatus" :status="item.detailedStatus" /> </template> <template #cell(job)="{ item = {} }"> diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index edfc22f644b..075dbb06190 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -8,7 +8,7 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, nodes { id detailedStatus { - # fields for `<ci-badge>` + # fields for `<ci-badge-link>` id detailsPath group diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 1f14bcd70bd..9a57548ddf3 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -997,6 +997,11 @@ "pull-push" ] }, + "unprotect": { + "type": "boolean", + "markdownDescription": "Use `unprotect: true` to set a cache to be shared between protected and unprotected branches.", + "default": false + }, "untracked": { "type": "boolean", "markdownDescription": "Use `untracked: true` to cache all files that are untracked in your Git repository. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cacheuntracked)", diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 293cd2df16f..81da8409873 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -39,8 +39,18 @@ export const CONTACTS_REMOVE_COMMAND = '/remove_contacts'; * @param string user input * @return {string} escaped user input */ -function escape(string) { - return lodashEscape(string).replace(/\$/g, '$'); +export function escape(string) { + // To prevent double (or multiple) enconding attack + // Decode the user input repeatedly prior to escaping the final decoded string. + let encodedString = string; + let decodedString = decodeURIComponent(encodedString); + + while (decodedString !== encodedString) { + encodedString = decodeURIComponent(decodedString); + decodedString = decodeURIComponent(encodedString); + } + + return lodashEscape(decodedString.replace(/\$/g, '$')); } export function showAndHideHelper($input, alias = '') { @@ -106,6 +116,7 @@ export const defaultAutocompleteConfig = { issues: true, mergeRequests: true, epics: true, + iterations: true, milestones: true, labels: true, snippets: true, @@ -209,6 +220,10 @@ class GfmAutoComplete { [[referencePrefix]] = value.params; if (/^[@%~]/.test(referencePrefix)) { tpl += '<%- referencePrefix %>'; + } else if (/^[*]/.test(referencePrefix)) { + // EE-ONLY + referencePrefix = '*iteration:'; + tpl += '<%- referencePrefix %>'; } } } @@ -883,7 +898,8 @@ class GfmAutoComplete { const atSymbolsWithBar = Object.keys(controllers) .join('|') .replace(/[$]/, '\\$&') - .replace(/([[\]:])/g, '\\$1'); + .replace(/([[\]:])/g, '\\$1') + .replace(/([*])/g, '\\$1'); const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); @@ -912,6 +928,7 @@ GfmAutoComplete.atTypeMap = { '#': 'issues', '!': 'mergeRequests', '&': 'epics', + '*iteration:': 'iterations', '~': 'labels', '%': 'milestones', '/': 'commands', diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index d8c5c292f52..9ee4439b618 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,7 +1,7 @@ <script> import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; import DurationCell from './cells/duration_cell.vue'; import JobCell from './cells/job_cell.vue'; @@ -14,7 +14,7 @@ export default { }, components: { ActionsCell, - CiBadge, + CiBadgeLink, DurationCell, GlTable, JobCell, @@ -55,7 +55,7 @@ export default { </template> <template #cell(status)="{ item }"> - <ci-badge :status="item.detailedStatus" /> + <ci-badge-link :status="item.detailedStatus" /> </template> <template #cell(job)="{ item }"> diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index df3b55ed2ad..21d0bda6b5d 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -103,6 +103,14 @@ function deferredInitialisation() { initCopyCodeButton(); initGitlabVersionCheck(); + // Init super sidebar + if (gon.use_new_navigation) { + // eslint-disable-next-line promise/catch-or-return + import('./super_sidebar/super_sidebar_bundle').then(({ initSuperSidebar }) => { + initSuperSidebar(); + }); + } + addSelectOnFocusBehaviour('.js-select-on-focus'); const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 6cd8bf57313..daf5e95e6ef 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -2,7 +2,9 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { mapActions } from 'vuex'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; export default { name: 'RoleDropdown', @@ -50,22 +52,37 @@ export default { return dispatch(`${this.namespace}/updateMemberRole`, payload); }, }), - handleSelect(value, name) { - if (value === this.member.accessLevel.integerValue) { + async handleOverageConfirm(currentAccessLevel, value) { + return guestOverageConfirmAction({ + currentAccessIntValue: currentAccessLevel, + dropdownIntValue: value, + }); + }, + async handleSelect(value, name) { + const currentAccessLevel = this.member.accessLevel.integerValue; + if (value === currentAccessLevel) { return; } this.busy = true; + const confirmed = await this.handleOverageConfirm(currentAccessLevel, value); + if (!confirmed) { + this.busy = false; + return; + } + this.updateMemberRole({ memberId: this.member.id, accessLevel: { integerValue: value, stringValue: name }, }) .then(() => { this.$toast.show(s__('Members|Role updated successfully.')); - this.busy = false; }) - .catch(() => { + .catch((error) => { + Sentry.captureException(error); + }) + .finally(() => { this.busy = false; }); }, diff --git a/app/assets/javascripts/members/guest_overage_confirm_action.js b/app/assets/javascripts/members/guest_overage_confirm_action.js new file mode 100644 index 00000000000..2205c3ad792 --- /dev/null +++ b/app/assets/javascripts/members/guest_overage_confirm_action.js @@ -0,0 +1,3 @@ +export const guestOverageConfirmAction = () => { + return true; +}; diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index 5cbb7a06bc1..30c351359e4 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; +import { initReportAbuse } from '~/users/profile'; import UserTabs from './user_tabs'; function initUserProfile(action) { @@ -19,3 +20,4 @@ const page = $('body').attr('data-page'); const action = page.split(':')[1]; initUserProfile(action); new UserCallout(); // eslint-disable-line no-new +initReportAbuse(); diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index c56537f4039..041b62e02ec 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; import { DEFAULT_FIELDS } from '../../constants'; @@ -12,7 +12,7 @@ export default { fields: DEFAULT_FIELDS, retry: __('Retry'), components: { - CiBadge, + CiBadgeLink, GlButton, GlLink, GlTableLite, @@ -72,7 +72,7 @@ export default { <div class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" > - <ci-badge :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> + <ci-badge-link :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> <div class="gl-text-truncate"> <gl-link :href="item.detailedStatus.detailsPath" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index 936ae4da1ec..919694f8f85 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,12 +1,12 @@ <script> import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; export default { components: { - CiBadge, + CiBadgeLink, PipelinesTimeago, }, mixins: [Tracking.mixin()], @@ -38,7 +38,7 @@ export default { <template> <div> - <ci-badge + <ci-badge-link class="gl-mb-3" :status="pipelineStatus" :show-text="!isChildView" diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index f009c0310c5..d029f8cf89f 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -35,7 +35,7 @@ const fetchData = (projectPath, path, ref, offset) => { gon.relative_url_root || '/', projectPath, '/-/refs/', - ref, + encodeURIComponent(ref), '/logs_tree/', encodeURIComponent(removeLeadingSlash(path)), ); diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js index 8ff52104c93..ceb80d85f74 100644 --- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -24,7 +24,12 @@ export function generateRefDestinationPath(projectRootPath, selectedRef) { [, namespace, , target] = match; } - const destinationPath = joinPaths(projectRootPath, namespace, selectedRef, target); + const destinationPath = joinPaths( + projectRootPath, + namespace, + encodeURIComponent(selectedRef), + target, + ); return `${destinationPath}${window.location.hash}`; } diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue new file mode 100644 index 00000000000..fea29458f45 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue @@ -0,0 +1,24 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlIcon, + }, + i18n: { + help: __('Help'), + new: __('New'), + }, +}; +</script> + +<template> + <div class="bottom-links gl-p-3"> + <a href="#" class="gl-text-black-normal" + ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{ + $options.i18n.help + }}</a + > + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue new file mode 100644 index 00000000000..f1ddb8290a0 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -0,0 +1,83 @@ +<script> +import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { contextSwitcherItems } from '../mock_data'; +import NavItem from './nav_item.vue'; + +export default { + components: { + GlAvatar, + GlSearchBoxByType, + NavItem, + }, + i18n: { + contextNavigation: s__('Navigation|Context navigation'), + switchTo: s__('Navigation|Switch to...'), + recentProjects: s__('Navigation|Recent projects'), + recentGroups: s__('Navigation|Recent groups'), + }, + contextSwitcherItems, + viewAllProjectsItem: { + title: s__('Navigation|View all projects'), + link: '/projects', + icon: 'project', + }, + viewAllGroupsItem: { + title: s__('Navigation|View all groups'), + link: '/groups', + icon: 'group', + }, +}; +</script> + +<template> + <div> + <gl-search-box-by-type /> + <nav :aria-label="$options.i18n.contextNavigation"> + <ul class="gl-p-0 gl-list-style-none"> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.switchTo }} + </div> + <ul :aria-label="$options.i18n.switchTo" class="gl-p-0"> + <nav-item :item="$options.contextSwitcherItems.yourWork" /> + </ul> + </li> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.recentProjects }} + </div> + <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0"> + <nav-item + v-for="project in $options.contextSwitcherItems.recentProjects" + :key="project.title" + :item="project" + > + <template #icon> + <gl-avatar shape="rect" :size="32" :src="project.avatar" /> + </template> + </nav-item> + <nav-item :item="$options.viewAllProjectsItem" /> + </ul> + </li> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.recentGroups }} + </div> + <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0"> + <nav-item + v-for="project in $options.contextSwitcherItems.recentGroups" + :key="project.title" + :item="project" + > + <template #icon> + <gl-avatar shape="rect" :size="32" :src="project.avatar" /> + </template> + </nav-item> + <nav-item :item="$options.viewAllGroupsItem" /> + </ul> + </li> + </ul> + </nav> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue new file mode 100644 index 00000000000..b6f058f7aee --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -0,0 +1,45 @@ +<script> +import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlTruncate, + GlAvatar, + GlIcon, + }, + directives: { + CollapseToggle: GlCollapseToggleDirective, + }, + props: { + context: { + type: Object, + required: true, + }, + expanded: { + type: Boolean, + required: true, + }, + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + }, +}; +</script> + +<template> + <button + v-collapse-toggle.context-switcher + type="button" + class="context-switcher-toggle gl-bg-transparent gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-pl-3 gl-pr-5 gl-h-8" + > + <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" /> + <div class="gl-overflow-auto"> + <gl-truncate :text="context.title" /> + </div> + <span class="gl-flex-grow-1 gl-text-right"> + <gl-icon :name="collapseIcon" /> + </span> + </button> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue new file mode 100644 index 00000000000..873d7c48574 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -0,0 +1,29 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + icon: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + }, +}; +</script> + +<template> + <a + href="#" + class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold" + > + <gl-icon :name="icon" /> + {{ count }} + </a> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue new file mode 100644 index 00000000000..4fd6918fd6f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -0,0 +1,37 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + name: 'NavItem', + components: { + GlIcon, + }, + props: { + item: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <a + :href="item.link" + class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08" + > + <div class="gl-mr-3"> + <slot name="icon"> + <gl-icon v-if="item.icon" :name="item.icon" /> + </slot> + </div> + <div class="gl-pr-3"> + {{ item.title }} + <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1"> + {{ item.subtitle }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue new file mode 100644 index 00000000000..e5c29f966c1 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -0,0 +1,46 @@ +<script> +import { GlCollapse } from '@gitlab/ui'; +import { user, counts, context } from '../mock_data'; +import UserBar from './user_bar.vue'; +import ContextSwitcherToggle from './context_switcher_toggle.vue'; +import ContextSwitcher from './context_switcher.vue'; +import BottomBar from './bottom_bar.vue'; + +export default { + context, + user, + counts, + components: { + GlCollapse, + UserBar, + ContextSwitcherToggle, + ContextSwitcher, + BottomBar, + }, + data() { + return { + contextSwitcherOpened: false, + }; + }, +}; +</script> + +<template> + <aside + class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999" + data-testid="super-sidebar" + > + <user-bar :user="$options.user" :counts="$options.counts" /> + <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> + <div class="gl-flex-grow-1 gl-overflow-auto"> + <context-switcher-toggle :context="$options.context" :expanded="contextSwitcherOpened" /> + <gl-collapse id="context-switcher" v-model="contextSwitcherOpened"> + <context-switcher /> + </gl-collapse> + </div> + <div class="gl-px-3"> + <bottom-bar /> + </div> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue new file mode 100644 index 00000000000..00fcf70929c --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -0,0 +1,61 @@ +<script> +import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; +import logo from '../../../../views/shared/_logo.svg'; +import Counter from './counter.vue'; + +export default { + logo, + components: { + GlAvatar, + GlDropdown, + GlIcon, + NewNavToggle, + Counter, + }, + directives: { + SafeHtml, + }, + inject: ['rootPath', 'toggleNewNavEndpoint'], + props: { + user: { + type: Object, + required: true, + }, + counts: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="user-bar"> + <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3"> + <div class="gl-flex-grow-1"> + <a v-safe-html="$options.logo" :href="rootPath"></a> + </div> + <gl-dropdown variant="link" no-caret> + <template #button-content> + <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" /> + </template> + </gl-dropdown> + <button class="gl-border-none"> + <gl-icon name="search" class="gl-vertical-align-middle" /> + </button> + <gl-dropdown data-testid="user-dropdown" variant="link" no-caret> + <template #button-content> + <gl-avatar :entity-name="user.name" :src="user.avatar_url" :size="32" /> + </template> + <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled /> + </gl-dropdown> + </div> + <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> + <counter icon="issues" :count="counts.assigned_issues" /> + <counter icon="merge-request-open" :count="counts.assigned_merge_requests" /> + <counter icon="todo-done" :count="counts.pending_todos" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js new file mode 100644 index 00000000000..b16a188b94f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/mock_data.js @@ -0,0 +1,70 @@ +import { s__ } from '~/locale'; + +export const user = { + name: 'GitLab Bot', + avatar_url: '', +}; + +export const counts = { + assigned_issues: 0, + assigned_merge_requests: 4, + pending_todos: 12, +}; + +export const context = { + title: 'Typeahead.js', + link: '/', + avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32', +}; + +export const contextSwitcherItems = { + yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' }, + recentProjects: [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Orange', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Lemon', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Coconut', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64', + }, + ], + recentGroups: [ + { + title: 'Developer Evangelism at GitLab', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64', + }, + { + title: 'security-products', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64', + }, + { + title: 'Tanuki-Workshops', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64', + }, + ], +}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js new file mode 100644 index 00000000000..35aa6aff08c --- /dev/null +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import SuperSidebar from './components/super_sidebar.vue'; + +export const initSuperSidebar = () => { + const el = document.querySelector('.js-super-sidebar'); + + if (!el) return false; + + const { rootPath, toggleNewNavEndpoint } = el.dataset; + + return new Vue({ + el, + name: 'SuperSidebarRoot', + provide: { + rootPath, + toggleNewNavEndpoint, + }, + render(h) { + return h(SuperSidebar); + }, + }); +}; diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index b19f92aaeb4..c88c528a632 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -11,14 +11,14 @@ import { } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, sprintf } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import StateActions from './states_table_actions.vue'; export default { components: { - CiBadge, + CiBadgeLink, GlAlert, GlBadge, GlLink, @@ -198,7 +198,7 @@ export default { :id="`terraformJobStatusContainer${item.name}`" class="gl-my-2" > - <ci-badge + <ci-badge-link :id="`terraformJobStatus${item.name}`" :status="pipelineDetailedStatus(item)" class="gl-py-1" diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue new file mode 100644 index 00000000000..3008cdb6726 --- /dev/null +++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue @@ -0,0 +1,51 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +export default { + name: 'ReportAbuseButton', + components: { + GlButton, + AbuseCategorySelector, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['formSubmitPath', 'userId', 'reportedFromUrl'], + i18n: { + reportAbuse: __('Report abuse to administrator'), + }, + data() { + return { + open: false, + }; + }, + computed: { + buttonTooltipText() { + return this.$options.i18n.reportAbuse; + }, + }, + methods: { + openDrawer() { + this.open = true; + }, + closeDrawer() { + this.open = false; + }, + }, +}; +</script> +<template> + <span> + <gl-button + v-gl-tooltip="buttonTooltipText" + category="primary" + :aria-label="buttonTooltipText" + icon="error" + @click="openDrawer" + /> + <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + </span> +</template> diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js new file mode 100644 index 00000000000..9246324a990 --- /dev/null +++ b/app/assets/javascripts/users/profile/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import ReportAbuseButton from './components/report_abuse_button.vue'; + +export const initReportAbuse = () => { + const el = document.getElementById('js-report-abuse'); + + if (!el) return false; + + const { formSubmitPath, userId, reportedFromUrl } = el.dataset; + + return new Vue({ + el, + provide: { formSubmitPath, userId, reportedFromUrl }, + render(createElement) { + return createElement(ReportAbuseButton); + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 9a3555d3e11..f7d6f7b4345 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -76,17 +76,17 @@ export default { <div :class="{ 'gl-display-flex gl-align-items-center': actions.length, - 'gl-md-display-flex gl-align-items-center': !actions.length, + 'gl-md-display-flex gl-align-items-center gl-flex-wrap gl-gap-3': !actions.length, }" - class="media-body" + class="media-body gl-line-height-24" > <slot></slot> <div :class="{ - 'state-container-action-buttons gl-flex-direction-column gl-flex-wrap gl-justify-content-end': !actions.length, + 'state-container-action-buttons gl-flex-wrap gl-lg-justify-content-end': !actions.length, 'gl-md-pt-0 gl-pt-3': hasActionsSlot, }" - class="gl-display-flex gl-font-size-0 gl-ml-auto gl-gap-3" + class="gl-display-flex gl-font-size-0 gl-gap-3" > <slot name="actions"> <actions v-if="actions.length" :tertiary-buttons="actions" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 8e1b18c63a4..a5d982fe221 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -88,25 +88,24 @@ export default { </template> <template v-if="!isLoading && !state.shouldBeRebased" #actions> <gl-button - v-if="userPermissions.canMerge" + v-if="showResolveButton" + :href="mr.conflictResolutionPath" size="small" variant="confirm" - category="secondary" - data-testid="merge-locally-button" - class="js-check-out-modal-trigger gl-align-self-start" - :class="{ 'gl-mr-2': showResolveButton }" + class="gl-align-self-start" + data-testid="resolve-conflicts-button" > - {{ s__('mrWidget|Resolve locally') }} + {{ s__('mrWidget|Resolve conflicts') }} </gl-button> <gl-button - v-if="showResolveButton" - :href="mr.conflictResolutionPath" + v-if="userPermissions.canMerge" size="small" variant="confirm" - class="gl-mb-2 gl-md-mb-0 gl-align-self-start" - data-testid="resolve-conflicts-button" + category="secondary" + data-testid="merge-locally-button" + class="js-check-out-modal-trigger gl-align-self-start" > - {{ s__('mrWidget|Resolve conflicts') }} + {{ s__('mrWidget|Resolve locally') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 4ae4edf02c3..d687f0346c7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -179,27 +179,27 @@ export default { </template> <template v-if="!isLoading" #actions> <gl-button - v-if="showRebaseWithoutPipeline" :loading="isMakingRequest" variant="confirm" size="small" - category="secondary" - data-testid="rebase-without-ci-button" - class="gl-align-self-start gl-mr-2" - @click="rebaseWithoutCi" + data-qa-selector="mr_rebase_button" + data-testid="standard-rebase-button" + class="gl-align-self-start" + @click="rebase" > - {{ __('Rebase without pipeline') }} + {{ __('Rebase') }} </gl-button> <gl-button + v-if="showRebaseWithoutPipeline" :loading="isMakingRequest" variant="confirm" size="small" - data-qa-selector="mr_rebase_button" - data-testid="standard-rebase-button" - class="gl-mb-2 gl-md-mb-0 gl-align-self-start" - @click="rebase" + category="secondary" + data-testid="rebase-without-ci-button" + class="gl-align-self-start gl-mr-2" + @click="rebaseWithoutCi" > - {{ __('Rebase') }} + {{ __('Rebase without pipeline') }} </gl-button> </template> </state-container> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 07db6b3c147..e60353578b0 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -38,6 +38,7 @@ @import 'framework/sidebar'; @import 'framework/contextual_sidebar_header'; @import 'framework/contextual_sidebar'; +@import 'framework/super_sidebar'; @import 'framework/tables'; @import 'framework/notes'; @import 'framework/tabs'; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss new file mode 100644 index 00000000000..59a9df9ede0 --- /dev/null +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -0,0 +1,22 @@ +.super-sidebar { + top: 0; + width: $contextual-sidebar-width; + + .user-bar { + background-color: $t-gray-a-04; + + .tanuki-logo { + @include gl-vertical-align-middle; + } + } + + .context-switcher-toggle { + &[aria-expanded='true'] { + background-color: $t-gray-a-08; + } + } +} + +.with-performance-bar .super-sidebar { + top: $performance-bar-height; +} diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 4950561bcb7..2cc4ca55d19 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -1197,13 +1197,13 @@ $tabs-holder-z-index: 250; } .mr-section-container { + .media-body { + column-gap: 0; + } + .state-container-action-buttons { @include media-breakpoint-up(md) { flex-direction: row-reverse; - - .btn { - margin-left: auto; - } } } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 714dd932147..48c268e1f2a 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -243,6 +243,14 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } +.gl-gap-2 { + gap: $gl-spacing-scale-2; +} + +.gl-hover-bg-t-gray-a-08:hover { + background-color: $t-gray-a-08; +} + /* End gitlab-ui#1709 */ /* diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 80aca7e21ce..0cf486f9afb 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true class AbuseReportsController < ApplicationController - before_action :set_user, only: [:new] + before_action :set_user, :set_ref_url, only: [:new, :add_category] feature_category :insider_threat def new - @abuse_report = AbuseReport.new - @abuse_report.user_id = @user.id - @ref_url = params.fetch(:ref_url, '') + @abuse_report = AbuseReport.new(user_id: @user.id) + end + + def add_category + @abuse_report = AbuseReport.new( + user_id: @user.id, + category: report_params[:category] + ) + + render :new end def create @@ -30,7 +37,7 @@ class AbuseReportsController < ApplicationController private def report_params - params.require(:abuse_report).permit(:message, :user_id) + params.require(:abuse_report).permit(:message, :user_id, :category) end # rubocop: disable CodeReuse/ActiveRecord @@ -44,4 +51,8 @@ class AbuseReportsController < ApplicationController end end # rubocop: enable CodeReuse/ActiveRecord + + def set_ref_url + @ref_url = params.fetch(:ref_url, '') + end end diff --git a/app/graphql/types/description_version_type.rb b/app/graphql/types/description_version_type.rb new file mode 100644 index 00000000000..bee30597e4c --- /dev/null +++ b/app/graphql/types/description_version_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + class DescriptionVersionType < BaseObject + graphql_name 'DescriptionVersion' + + authorize :read_issuable + + field :id, ::Types::GlobalIDType[::DescriptionVersion], + null: false, + description: 'ID of the description version.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Content of the given description version.' + end +end + +Types::DescriptionVersionType.prepend_mod diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 05629ea9223..7e09a3e91cf 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -11,54 +11,65 @@ module Types implements(Types::ResolvableInterface) - field :id, ::Types::GlobalIDType[::Note], null: false, - description: 'ID of the note.' + field :id, ::Types::GlobalIDType[::Note], + null: false, + description: 'ID of the note.' field :project, Types::ProjectType, - null: true, - description: 'Project associated with the note.' + null: true, + description: 'Project associated with the note.' field :author, Types::UserType, - null: false, - description: 'User who wrote this note.' + null: false, + description: 'User who wrote this note.' field :system, GraphQL::Types::Boolean, - null: false, - description: 'Indicates whether this note was created by the system or by a user.' + null: false, + description: 'Indicates whether this note was created by the system or by a user.' field :system_note_icon_name, - GraphQL::Types::String, - null: true, - description: 'Name of the icon corresponding to a system note.' + GraphQL::Types::String, + null: true, + description: 'Name of the icon corresponding to a system note.' field :body, GraphQL::Types::String, - null: false, - method: :note, - description: 'Content of the note.' - - field :confidential, GraphQL::Types::Boolean, null: true, - description: 'Indicates if this note is confidential.', - method: :confidential?, - deprecated: { - reason: :renamed, - replacement: 'internal', - milestone: '15.5' - } - - field :internal, GraphQL::Types::Boolean, null: true, - description: 'Indicates if this note is internal.', - method: :confidential? - - field :created_at, Types::TimeType, null: false, - description: 'Timestamp of the note creation.' - field :discussion, Types::Notes::DiscussionType, null: true, - description: 'Discussion this note is a part of.' - field :position, Types::Notes::DiffPositionType, null: true, - description: 'Position of this note on a diff.' - field :updated_at, Types::TimeType, null: false, - description: "Timestamp of the note's last activity." + null: false, + method: :note, + description: 'Content of the note.' + + field :confidential, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if this note is confidential.', + method: :confidential?, + deprecated: { + reason: :renamed, + replacement: 'internal', + milestone: '15.5' + } + + field :internal, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if this note is internal.', + method: :confidential? + + field :created_at, Types::TimeType, + null: false, + description: 'Timestamp of the note creation.' + field :discussion, Types::Notes::DiscussionType, + null: true, + description: 'Discussion this note is a part of.' + field :position, Types::Notes::DiffPositionType, + null: true, + description: 'Position of this note on a diff.' + field :updated_at, Types::TimeType, + null: false, + description: "Timestamp of the note's last activity." field :url, GraphQL::Types::String, - null: true, - description: 'URL to view this Note in the Web UI.' + null: true, + description: 'URL to view this Note in the Web UI.' + + field :system_note_metadata, Types::Notes::SystemNoteMetadataType, + null: true, + description: 'Metadata for the given note if it is a system note.' markdown_field :body_html, null: true, method: :note diff --git a/app/graphql/types/notes/system_note_metadata_type.rb b/app/graphql/types/notes/system_note_metadata_type.rb new file mode 100644 index 00000000000..b3dd7e037f9 --- /dev/null +++ b/app/graphql/types/notes/system_note_metadata_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Notes + class SystemNoteMetadataType < BaseObject + graphql_name 'SystemNoteMetadata' + + authorize :read_note + + field :id, ::Types::GlobalIDType[::SystemNoteMetadata], + null: false, + description: 'Global ID of the specific system note metadata.' + + field :action, GraphQL::Types::String, + null: true, + description: 'System note metadata action.' + field :description_version, ::Types::DescriptionVersionType, + null: true, + description: 'Version of the changed description.' + end + end +end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index bf3b132e33a..d0421cd5184 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -65,6 +65,10 @@ module NavHelper %w(dev_ops_report usage_trends) end + def show_super_sidebar? + Feature.enabled?(:super_sidebar_nav, current_user) && current_user&.use_new_navigation + end + private def get_header_links diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index f1f22d94061..c90023dd9df 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -12,11 +12,23 @@ class AbuseReport < ApplicationRecord validates :reporter, presence: true validates :user, presence: true validates :message, presence: true + validates :category, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } scope :by_user, ->(user) { where(user_id: user) } scope :with_users, -> { includes(:reporter, :user) } + enum category: { + spam: 1, + offensive: 2, + phishing: 3, + crypto: 4, + credentials: 5, + copyright: 6, + malware: 7, + other: 8 + } + # For CacheMarkdownField alias_method :author, :reporter diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d5b9c338e39..111d2797fed 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -884,8 +884,9 @@ module Ci return cache unless project.ci_separated_caches - type_suffix = pipeline.protected_ref? ? 'protected' : 'non_protected' cache.map do |entry| + type_suffix = !entry[:unprotect] && pipeline.protected_ref? ? 'protected' : 'non_protected' + entry.merge(key: "#{entry[:key]}-#{type_suffix}") end end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index 96c8553c101..fb61b7f5fde 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -6,6 +6,8 @@ class DescriptionVersion < ApplicationRecord validate :exactly_one_issuable + delegate :resource_parent, to: :issuable + def self.issuable_attrs %i(issue merge_request).freeze end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index da07d8dd9fc..b0676c25f8e 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -166,8 +166,6 @@ class Milestone < ApplicationRecord end def self.states_count(projects, groups = nil) - return STATE_COUNT_HASH unless projects || groups - counts = Milestone .for_projects_and_groups(projects, groups) .reorder(nil) diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb index a60c0d2f3bc..f88fa052665 100644 --- a/app/models/synthetic_note.rb +++ b/app/models/synthetic_note.rb @@ -14,7 +14,7 @@ class SyntheticNote < Note discussion_id: event.discussion_id, noteable: resource, event: event, - system_note_metadata: ::SystemNoteMetadata.new(action: action), + system_note_metadata: ::SystemNoteMetadata.new(action: action, id: event.discussion_id), resource_parent: resource_parent } diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 4e86036952b..36166bdbc9a 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -34,6 +34,12 @@ class SystemNoteMetadata < ApplicationRecord belongs_to :note belongs_to :description_version + delegate_missing_to :note + + def declarative_policy_delegate + note + end + def icon_types ICON_TYPES end diff --git a/app/policies/description_version_policy.rb b/app/policies/description_version_policy.rb new file mode 100644 index 00000000000..9ee9df3278b --- /dev/null +++ b/app/policies/description_version_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class DescriptionVersionPolicy < BasePolicy + delegate { @subject.issuable } +end diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index d5dfddef837..5c8db51d122 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -10,6 +10,7 @@ = form_errors(@abuse_report) = f.hidden_field :user_id + = f.hidden_field :category .form-group.row .col-sm-2.col-form-label = f.label :user_id diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index bb1d051f71f..f1d29e77e34 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,5 +1,9 @@ +- if show_super_sidebar? + - @left_sidebar = true .layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class } - - if defined?(nav) && nav + - if show_super_sidebar? + %aside.js-super-sidebar.nav-sidebar{ data: { root_path: root_path, toggle_new_nav_endpoint: profile_preferences_url } } + - elsif defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } .mobile-overlay diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 455d18a5ae8..fa79219df4a 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -11,7 +11,14 @@ = render "layouts/visual_review" if ENV['REVIEW_APPS_ENABLED'] = render 'peek/bar' = header_message - = render partial: "layouts/header/default", locals: { project: @project, group: @group } + + - if show_super_sidebar? # TODO: Move this CSS to a better place + :css + body { + --header-height: 0px; + } + - else + = render partial: "layouts/header/default", locals: { project: @project, group: @group } = render 'layouts/page', sidebar: sidebar, nav: nav = footer_message diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 03ecf8cac22..19c68da835b 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -23,9 +23,7 @@ icon: 'error', button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Already reported for abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - else - = render Pajamas::ButtonComponent.new(href: new_abuse_report_path(user_id: @user.id, ref_url: request.referer), - icon: 'error', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: _('Report abuse to administrator'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + #js-report-abuse{ data: { form_submit_path: add_category_abuse_reports_path, user_id: @user.id, reported_from_url: user_url(@user) } } - verified_gpg_keys = @user.gpg_keys.select(&:verified?) - if verified_gpg_keys.any? = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, diff --git a/config/routes.rb b/config/routes.rb index a9cb462b326..7569bce530a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -210,7 +210,11 @@ InitializerConnections.with_disabled_database_connections do end # Spam reports - resources :abuse_reports, only: [:new, :create] + resources :abuse_reports, only: [:new, :create] do + collection do + post :add_category + end + end # JWKS (JSON Web Key Set) endpoint # Used by third parties to verify CI_JOB_JWT diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 4f6bfc5c82a..fa890531861 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -50,6 +50,17 @@ class Gitlab::Seeder::CycleAnalytics end def seed! + unless project.repository_exists? + puts + puts 'WARNING' + puts '=======' + puts "Seeding #{self.class} is not possible because the given project (#{project.full_path}) doesn't have a repository." + puts 'Try specifying a project with working repository or omit the VSA_SEED_PROJECT_ID parameter so the seed script will automatically create one.' + puts + + return + end + create_developers! create_issues! @@ -169,6 +180,7 @@ class Gitlab::Seeder::CycleAnalytics ) project = FactoryBot.create( :project, + :repository, name: "Value Stream Management Project #{suffix}", path: "vsmp-#{suffix}", creator: admin, diff --git a/db/migrate/20221204090437_add_category_to_abuse_report.rb b/db/migrate/20221204090437_add_category_to_abuse_report.rb new file mode 100644 index 00000000000..e908f3354bb --- /dev/null +++ b/db/migrate/20221204090437_add_category_to_abuse_report.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCategoryToAbuseReport < Gitlab::Database::Migration[2.1] + def change + add_column :abuse_reports, :category, :integer, limit: 2, default: 1, null: false + end +end diff --git a/db/schema_migrations/20221204090437 b/db/schema_migrations/20221204090437 new file mode 100644 index 00000000000..3ae8d4c2067 --- /dev/null +++ b/db/schema_migrations/20221204090437 @@ -0,0 +1 @@ +16bdaabcc19086652b0543dcdc7204305a920794fdab38c042d06bb2be76dde0
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index a8b5547abaf..1ba63e4a20e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10602,7 +10602,8 @@ CREATE TABLE abuse_reports ( created_at timestamp without time zone, updated_at timestamp without time zone, message_html text, - cached_markdown_version integer + cached_markdown_version integer, + category smallint DEFAULT 1 NOT NULL ); CREATE SEQUENCE abuse_reports_id_seq diff --git a/doc/.vale/gitlab/HeadingDepth.yml b/doc/.vale/gitlab/HeadingDepth.yml index 7a3e5b4b552..5bbe667481c 100644 --- a/doc/.vale/gitlab/HeadingDepth.yml +++ b/doc/.vale/gitlab/HeadingDepth.yml @@ -1,5 +1,5 @@ --- -# Warning: gitlab.HeadingDepth +# Suggestion: gitlab.HeadingDepth # # Checks that there are no headings greater than 3 levels # @@ -7,7 +7,7 @@ extends: existence message: "Refactor the section or page to avoid headings greater than H5." link: https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#headings-in-markdown -level: warning +level: suggestion scope: raw raw: - '(?<=\n)#{5,}\s.*' diff --git a/doc/.vale/gitlab/SentenceLength.yml b/doc/.vale/gitlab/SentenceLength.yml index 69b0d27072e..48ebf02bc7f 100644 --- a/doc/.vale/gitlab/SentenceLength.yml +++ b/doc/.vale/gitlab/SentenceLength.yml @@ -1,5 +1,5 @@ --- -# Warning: gitlab.SentenceLength +# Suggestion: gitlab.SentenceLength # # Counts words in a sentence and alerts if a sentence exceeds 25 words. # @@ -8,6 +8,6 @@ extends: occurrence message: "Improve readability by using fewer than 25 words in this sentence." scope: sentence link: https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#language -level: warning +level: suggestion max: 25 token: \b(\w+)\b diff --git a/doc/.vale/gitlab/Spelling.yml b/doc/.vale/gitlab/Spelling.yml index 92c5cb13b29..74d919831ac 100644 --- a/doc/.vale/gitlab/Spelling.yml +++ b/doc/.vale/gitlab/Spelling.yml @@ -10,7 +10,7 @@ # # For a list of all options, see https://vale.sh/docs/topics/styles/ extends: spelling -message: "Check the spelling of '%s'. If the spelling is correct, add this word to the spelling exception list." +message: "Check the spelling of '%s'. If the spelling is correct, ask a Technical Writer to add this word to the spelling exception list." level: warning ignore: - gitlab/spelling-exceptions.txt diff --git a/doc/.vale/gitlab/Uppercase.yml b/doc/.vale/gitlab/Uppercase.yml index 039ad7c5f03..724194695c4 100644 --- a/doc/.vale/gitlab/Uppercase.yml +++ b/doc/.vale/gitlab/Uppercase.yml @@ -1,13 +1,13 @@ --- -# Warning: gitlab.Uppercase +# Suggestion: gitlab.Uppercase # # Checks for use of all uppercase letters with unknown reason. # # For a list of all options, see https://vale.sh/docs/topics/styles/ extends: conditional -message: "Instead of uppercase for '%s', use lowercase or backticks (`) if possible. Otherwise, add this word or acronym to the rule's exception list." +message: "Instead of uppercase for '%s', use lowercase or backticks (`) if possible. Otherwise, ask a Technical Writer to add this word or acronym to the rule's exception list." link: https://docs.gitlab.com/ee/development/documentation/testing.html#vale-uppercase-acronym-test -level: warning +level: suggestion ignorecase: false # Ensures that the existence of 'first' implies the existence of 'second'. first: '\b([A-Z]{3,5})\b' diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md index 6b62e82f54d..5f9323016c0 100644 --- a/doc/api/geo_nodes.md +++ b/doc/api/geo_nodes.md @@ -198,22 +198,22 @@ _This can only be run against a primary Geo node._ PUT /geo_nodes/:id ``` -| Attribute | Type | Required | Description | -|-----------------------------|---------|-----------|---------------------------------------------------------------------------| -| `id` | integer | yes | The ID of the Geo node. | -| `enabled` | boolean | no | Flag indicating if the Geo node is enabled. | -| `name` | string | yes | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in `gitlab.rb`, otherwise it must match `external_url`. | -| `url` | string | yes | The user-facing URL of the Geo node. | -| `internal_url` | string | no | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set.| -| `files_max_capacity` | integer | no | Control the maximum concurrency of LFS/attachment backfill for this secondary node. | -| `repos_max_capacity` | integer | no | Control the maximum concurrency of repository backfill for this secondary node. | -| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. | -| `container_repositories_max_capacity` | integer | no | Control the maximum concurrency of container repository sync for this node. | -| `sync_object_storage` | boolean | no | Flag indicating if the secondary Geo node should replicate blobs in Object Storage. | -| `selective_sync_type` | string | no | Limit syncing to only specific groups or shards. Valid values: `"namespaces"`, `"shards"`, or `null`. | -| `selective_sync_shards` | array | no | The repository storage for the projects synced if `selective_sync_type` == `shards`. | -| `selective_sync_namespace_ids` | array | no | The IDs of groups that should be synced, if `selective_sync_type` == `namespaces`. | -| `minimum_reverification_interval` | integer | no | The interval (in days) in which the repository verification is valid. Once expired, it is reverified. This has no effect when set on a secondary node. | +| Attribute | Type | Required | Description | +|-----------------------------|---------|---------|---------------------------------------------------------------------------| +| `id` | integer | yes | The ID of the Geo node. | +| `enabled` | boolean | no | Flag indicating if the Geo node is enabled. | +| `name` | string | no | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in `gitlab.rb`, otherwise it must match `external_url`. | +| `url` | string | no | The user-facing URL of the Geo node. | +| `internal_url` | string | no | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set.| +| `files_max_capacity` | integer | no | Control the maximum concurrency of LFS/attachment backfill for this secondary node. | +| `repos_max_capacity` | integer | no | Control the maximum concurrency of repository backfill for this secondary node. | +| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. | +| `container_repositories_max_capacity` | integer | no | Control the maximum concurrency of container repository sync for this node. | +| `sync_object_storage` | boolean | no | Flag indicating if the secondary Geo node should replicate blobs in Object Storage. | +| `selective_sync_type` | string | no | Limit syncing to only specific groups or shards. Valid values: `"namespaces"`, `"shards"`, or `null`. | +| `selective_sync_shards` | array | no | The repository storage for the projects synced if `selective_sync_type` == `shards`. | +| `selective_sync_namespace_ids` | array | no | The IDs of groups that should be synced, if `selective_sync_type` == `namespaces`. | +| `minimum_reverification_interval` | integer | no | The interval (in days) in which the repository verification is valid. Once expired, it is reverified. This has no effect when set on a secondary node. | Example response: @@ -255,9 +255,6 @@ in GitLab 14.9. Removes the Geo node. -NOTE: -Only a Geo primary node accepts this request. - ```plaintext DELETE /geo_nodes/:id ``` diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3b4f411e4a3..5a60ed3aaa7 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -12080,6 +12080,33 @@ Tags for a given deployment. | <a id="deploymenttagname"></a>`name` | [`String`](#string) | Name of this git tag. | | <a id="deploymenttagpath"></a>`path` | [`String`](#string) | Path for this tag. | +### `DescriptionVersion` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="descriptionversioncandelete"></a>`canDelete` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 15.7. For backwards compatibility with REST API version and to be removed in a next iteration. | +| <a id="descriptionversiondeletepath"></a>`deletePath` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.7. For backwards compatibility with REST API version and to be removed in a next iteration. | +| <a id="descriptionversiondeleted"></a>`deleted` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 15.7. For backwards compatibility with REST API version and to be removed in a next iteration. | +| <a id="descriptionversiondescription"></a>`description` | [`String`](#string) | Content of the given description version. | +| <a id="descriptionversiondiffpath"></a>`diffPath` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.7. For backwards compatibility with REST API version and to be removed in a next iteration. | +| <a id="descriptionversionid"></a>`id` | [`DescriptionVersionID!`](#descriptionversionid) | ID of the description version. | + +#### Fields with arguments + +##### `DescriptionVersion.diff` + +Description diff between versions. + +Returns [`String`](#string). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="descriptionversiondiffversionid"></a>`versionId` | [`DescriptionVersionID`](#descriptionversionid) | ID of a previous version to compare. If not specified first previous version is used. | + ### `Design` A single design. @@ -16357,6 +16384,7 @@ Represents the network policy. | <a id="noteresolvedby"></a>`resolvedBy` | [`UserCore`](#usercore) | User who resolved the object. | | <a id="notesystem"></a>`system` | [`Boolean!`](#boolean) | Indicates whether this note was created by the system or by a user. | | <a id="notesystemnoteiconname"></a>`systemNoteIconName` | [`String`](#string) | Name of the icon corresponding to a system note. | +| <a id="notesystemnotemetadata"></a>`systemNoteMetadata` | [`SystemNoteMetadata`](#systemnotemetadata) | Metadata for the given note if it is a system note. | | <a id="noteupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of the note's last activity. | | <a id="noteurl"></a>`url` | [`String`](#string) | URL to view this Note in the Web UI. | | <a id="noteuserpermissions"></a>`userPermissions` | [`NotePermissions!`](#notepermissions) | Permissions for the current user on the resource. | @@ -19548,9 +19576,18 @@ Represents a Suggested Reviewers result set. | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="suggestedreviewerstypereviewers"></a>`reviewers` | [`[String!]!`](#string) | List of reviewers. | -| <a id="suggestedreviewerstypetopn"></a>`topN` | [`Int`](#int) | Number of reviewers returned. | -| <a id="suggestedreviewerstypeversion"></a>`version` | [`String`](#string) | Suggested reviewer version. | +| <a id="suggestedreviewerstypeaccepted"></a>`accepted` | [`[String!]`](#string) | List of accepted reviewer usernames. | +| <a id="suggestedreviewerstypesuggested"></a>`suggested` | [`[String!]!`](#string) | List of suggested reviewer usernames. | + +### `SystemNoteMetadata` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="systemnotemetadataaction"></a>`action` | [`String`](#string) | System note metadata action. | +| <a id="systemnotemetadatadescriptionversion"></a>`descriptionVersion` | [`DescriptionVersion`](#descriptionversion) | Version of the changed description. | +| <a id="systemnotemetadataid"></a>`id` | [`SystemNoteMetadataID!`](#systemnotemetadataid) | Global ID of the specific system note metadata. | ### `TaskCompletionStatus` @@ -23361,6 +23398,12 @@ A `DependencyProxyManifestID` is a global ID. It is encoded as a string. An example `DependencyProxyManifestID` is: `"gid://gitlab/DependencyProxy::Manifest/1"`. +### `DescriptionVersionID` + +A `DescriptionVersionID` is a global ID. It is encoded as a string. + +An example `DescriptionVersionID` is: `"gid://gitlab/DescriptionVersion/1"`. + ### `DesignManagementDesignAtVersionID` A `DesignManagementDesignAtVersionID` is a global ID. It is encoded as a string. @@ -23698,6 +23741,12 @@ An example `SnippetID` is: `"gid://gitlab/Snippet/1"`. Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text. +### `SystemNoteMetadataID` + +A `SystemNoteMetadataID` is a global ID. It is encoded as a string. + +An example `SystemNoteMetadataID` is: `"gid://gitlab/SystemNoteMetadata/1"`. + ### `TerraformStateID` A `TerraformStateID` is a global ID. It is encoded as a string. diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index dffe409b193..b8457477c34 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -1103,10 +1103,16 @@ job: ### `cache` +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330047) in GitLab 15.0, caches are not shared between protected and unprotected branches. + Use `cache` to specify a list of files and directories to cache between jobs. You can only use paths that are in the local working copy. -Caching is shared between pipelines and jobs. Caches are restored before [artifacts](#artifacts). +Caches are: + +- Shared between pipelines and jobs. +- By default, not shared between [protected](../../user/project/protected_branches.md) and unprotected branches. +- Restored before [artifacts](#artifacts). Learn more about caches in [Caching in GitLab CI/CD](../caching/index.md). @@ -1319,6 +1325,33 @@ rspec: - binaries/ ``` +#### `cache:unprotect` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362114) in GitLab 15.8. + +Use `cache:unprotect` to set a cache to be shared between [protected](../../user/project/protected_branches.md) +and unprotected branches. + +WARNING: +When set to `true`, users without access to protected branches can read and write to +cache keys used by protected branches. + +**Keyword type**: Job keyword. You can use it only as part of a job or in the +[`default` section](#default). + +**Possible inputs**: + +- `true` or `false` (default). + +**Example of `cache:untracked`**: + +```yaml +rspec: + script: test + cache: + unprotect: true +``` + #### `cache:when` > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/18969) in GitLab 13.5 and GitLab Runner v13.5.0. diff --git a/doc/development/documentation/topic_types/index.md b/doc/development/documentation/topic_types/index.md index 964b41303cb..cfc231c268a 100644 --- a/doc/development/documentation/topic_types/index.md +++ b/doc/development/documentation/topic_types/index.md @@ -28,7 +28,7 @@ If inline links are not sufficient, you can create a topic called **Related topi and include an unordered list of related topics. This topic should be above the Troubleshooting section. ```markdown -# Related topics +## Related topics - [Configure your pipeline](link-to-topic). - [Trigger a pipeline manually](link-to-topic). diff --git a/doc/development/merge_request_diffs.md b/doc/development/merge_request_diffs.md new file mode 100644 index 00000000000..a3c8ada898e --- /dev/null +++ b/doc/development/merge_request_diffs.md @@ -0,0 +1,188 @@ +--- +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Merge request diffs development guide **(FREE)** + +This document explains the backend design and flow of merge request diffs. +It should help contributors: + +- Understand the code design. +- Identify areas for improvement through contribution. + +It's intentional that it doesn't contain too many implementation details, as they +can change often. The code better explains these details. The components +mentioned here are the major parts of the application for how merge request diffs +are generated, stored, and returned to users. + +NOTE: +This page is a living document. Update it accordingly when the parts +of the codebase touched in this document are changed or removed, or when new components +are added. + +## Data model + +Four main ActiveRecord models represent what we collectively refer to +as _diffs._ These database-backed records replicate data contained in the +project's Git repository, and are in part a cache against excessive access requests +to [Gitaly](gitaly.md). Additionally, they provide a logical place for: + +- Calculated and retrieved metadata about the pieces of the diff. +- General class- and instance- based logic. + +```mermaid +erDiagram + MergeRequest ||--|{ MergeRequestDiff: "" + MergeRequestDiff |{--|{ MergeRequestDiffCommit: "" + MergeRequestDiff |{--|| MergeRequestDiffDetail: "" + MergeRequestDiff |{--|{ MergeRequestDiffFile: "" + MergeRequestDiffCommit |{--|| MergeRequestDiffCommitUser: "" +``` + +### `MergeRequestDiff` + +`MergeRequestDiff` is defined in `app/models/merge_request_diff.rb`. This +class holds metadata and context related to the diff resulting from a set of +commits. It defines methods that are the primary means for interacting with diff +contents, individual commits, and the files containing changes. + +```ruby +#<MergeRequestDiff:0x00007fd1ed63b4d0 + id: 28, + state: "collected", + merge_request_id: 28, + created_at: Tue, 06 Sep 2022 18:56:02.509469000 UTC +00:00, + updated_at: Tue, 06 Sep 2022 18:56:02.754201000 UTC +00:00, + base_commit_sha: "ae73cb07c9eeaf35924a10f713b364d32b2dd34f", + real_size: "9", + head_commit_sha: "bb5206fee213d983da88c47f9cf4cc6caf9c66dc", + start_commit_sha: "0b4bc9a49b562e85de7cc9e834518ea6828729b9", + commits_count: 6, + external_diff: "diff-28", + external_diff_store: 1, + stored_externally: nil, + files_count: 9, + sorted: true, + diff_type: "regular", + verification_checksum: nil> +``` + +Diff content is usually accessed through this class. Logic is often applied +to diff, file, and commit content before it is returned to a user. + +### `MergeRequestDiffCommit` + +`MergeRequestDiffCommit` is defined in `app/models/merge_request_diff_commit.rb`. +This class corresponds to a single commit contained in its corresponding `MergeRequestDiff`, +and holds header information about the commit. + +```ruby +#<MergeRequestDiffCommit:0x00007fd1dfc6c4c0 + authored_date: Wed, 06 Aug 2022 06:35:52.000000000 UTC +00:00, + committed_date: Wed, 06 Aug 2022 06:35:52.000000000 UTC +00:00, + merge_request_diff_id: 28, + relative_order: 0, + sha: "bb5206fee213d983da88c47f9cf4cc6caf9c66dc", + message: "Feature conflcit added\n\nSigned-off-by: Sample User <sample.user@example.com>\n", + trailers: {}, + commit_author_id: 19, + committer_id: 19> +``` + +Every `MergeRequestDiffCommit` has a corresponding `MergeRequest::DiffCommitUser` +record it `:belongs_to`, in ActiveRecord parlance. These records are `:commit_author` +and `:committer`, and could be distinct individuals. + +### `MergeRequest::DiffCommitUser` + +`MergeRequest::DiffCommitUser` is defined in `app/models/merge_request/diff_commit_user.rb`. +It captures the `name` and `email` of a given commit, but contains no connection +itself to any `User` records. + +```ruby +#<MergeRequest::DiffCommitUser:0x00007fd1dff7c930 + id: 19, + name: "Sample User", + email: "sample.user@example.com"> +``` + +### `MergeRequestDiffFile` + +`MergeRequestDiffFile` is defined in `app/models/merge_request_diff_file.rb`. +This record of this class represents the diff of a single file contained in the +`MergeRequestDiff`. It holds both meta and specific information about the file's +relationship to the change, such as: + +- Whether it is added or renamed. +- Its ordering in the diff. +- The raw diff output itself. + +### `MergeRequestDiffDetail` + +`MergeRequestDiffDetail` is defined in `app/models/merge_request_diff_detail.rb`. +This class provides verification information for Geo replication, but otherwise +is not used for user-facing diffs. + +```ruby +#<MergeRequestDiffFile:0x00007fd1ef7c9048 + merge_request_diff_id: 28, + relative_order: 0, + new_file: true, + renamed_file: false, + deleted_file: false, + too_large: false, + a_mode: "0", + b_mode: "100644", + new_path: "files/ruby/feature.rb", + old_path: "files/ruby/feature.rb", + diff: + "@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n", + binary: false, + external_diff_offset: nil, + external_diff_size: nil> +``` + +## Flow + +These flowcharts should help explain the flow from the controllers down to the +models for different features. This page is not intended to document the entirety +of options for access and working with diffs, focusing solely on the most common. + +### `batch_diffs.json` + +The most common avenue for viewing diffs is the **Changes** +tab in the top navigation bar of merge request pages in the GitLab UI. When selected, the +diffs themselves are loaded via a paginated request to `/-/merge_requests/:id/batch_diffs.json`, +which is served by [`Projects::MergeRequests::DiffsController#diffs_batch`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/controllers/projects/merge_requests/diffs_controller.rb): + +<!-- Don't delete the characters below. Mermaid returns a syntax error if they aren't included.--> + +```mermaid +sequenceDiagram + Note over .#diffs_batch: Preload diffs and ivars + .#diffs_batch->>+.#define_diff_vars: + .#define_diff_vars ->>+ @merge_request: @merge_request_diffs = + Note right of @merge_request: An ordered collection of all diffs in MR + @merge_request-->>-.#define_diff_vars: + .#define_diff_vars ->>+ @merge_request: @merge_request_diff = + Note right of @merge_request: Most recent merge_request_diff (or commit) + @merge_request-->>-.#define_diff_vars: + .#define_diff_vars ->>+ .#define_diff_vars: @compare = + Note right of .#define_diff_vars:: param-filtered merge_request_diff(s) + .#define_diff_vars -->>- .#diffs_batch: + Note over .#diffs_batch: Preloading complete + .#diffs_batch->>+@merge_request: Calculate unfoldable diff lines + Note right of @merge_request: note_positions_for_paths.unfoldable + @merge_request-->>-.#diffs_batch: + Note over .#diffs_batch: Build options hash + Note over .#diffs_batch: Build cache_context + Note over .#diffs_batch: Unfold files in diff + .#diffs_batch->>+Gitlab_Diff_FileCollection_MergeRequestDiffBase: diffs.write_diff + Gitlab_Diff_FileCollection_MergeRequestDiffBase->>+Gitlab_Diff_HighlightCache: Highlight diff + Gitlab_Diff_HighlightCache -->>-Gitlab_Diff_FileCollection_MergeRequestDiffBase: Return highlighted diff + Note over Gitlab_Diff_FileCollection_MergeRequestDiffBase: Cache diff + Gitlab_Diff_FileCollection_MergeRequestDiffBase-->>-.#diffs_batch: + Note over .#diffs_batch: render JSON +``` diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index d0a420a4bbd..a97e511ec1c 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -248,40 +248,37 @@ enabled a feature flag for [moved sidebar actions](../project/merge_requests/ind The following table presents the events that generate notifications for issues, merge requests, and epics: -| Event | Sent to | -|------------------------|---------| -| Change milestone issue | Subscribers and participants mentioned. | -| Change milestone merge request | Subscribers and participants mentioned. | -| Close epic | | -| Close issue | | -| Close merge request | | -| Failed pipeline | The author of the pipeline. | -| Fixed pipeline | The author of the pipeline. Enabled by default. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/24309) in GitLab 13.1._ | -| Issue due | Participants and Custom notification level with this event selected. | -| Merge merge request | | -| Merge when pipeline succeeds | Author, Participants, Watchers, Subscribers, and Custom notification level with this event selected. Custom notification level is ignored for Author, Watchers and Subscribers. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211961) in GitLab 13.4._ | -| Merge request [marked as ready](../project/merge_requests/drafts.md) | Watchers and participants. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15332) in GitLab 13.10._ | -| New epic | | -| New issue | | -| New merge request | | -| New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. | -| Push to merge request | Participants and Custom notification level with this event selected. | -| Reassign issue | Participants, Watchers, Subscribers, Custom notification level with this event selected, and the old assignee. | -| Reassign merge request | Participants, Watchers, Subscribers, Custom notification level with this event selected, and the old assignee. | -| Remove milestone issue | Subscribers and participants mentioned. | -| Remove milestone merge request | Subscribers and participants mentioned. | -| Reopen epic | | -| Reopen issue | | -| Reopen merge request | | -| Successful pipeline | The author of the pipeline, with Custom notification level for successful pipelines. If the pipeline failed previously, a "Fixed pipeline" message is sent for the first successful pipeline after the failure, and then a "Successful pipeline" message for any further successful pipelines. | - -If the title or description of an issue or merge request is -changed, notifications are sent to any **new** mentions by username as -if they had been mentioned in the original text. - -If an open merge request becomes unmergeable due to conflict, its author is notified about the cause. -If a user has also set the merge request to automatically merge when pipeline succeeds, -then that user is also notified. +| Type | Event | Sent to | +|------|-------|---------| +| Epic | Closed | Subscribers and participants mentioned. | +| Epic | New | Anyone mentioned by username in the description, with notification level "Mention" or higher. | +| Epic | New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. | +| Epic | Reopened | Subscribers and participants mentioned. | +| Issue | Closed | Subscribers and participants mentioned. | +| Issue | Due | Participants and Custom notification level with this event selected. | +| Issue | Milestone changed | Subscribers and participants mentioned. | +| Issue | Milestone removed | Subscribers and participants mentioned. | +| Issue | New | Anyone mentioned by username in the description, with notification level "Mention" or higher. | +| Issue | New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. | +| Issue | Title or description changed | Any new mentions by username. | +| Issue | Reassigned | Participants, Watchers, Subscribers, Custom notification level with this event selected, and the old assignee. | +| Issue | Reopened | Subscribers and participants mentioned. | +| Merge Request | Closed | Subscribers and participants mentioned. | +| Merge Request | Conflict | Author and any user that has set the merge request to automatically merge when pipeline succeeds. | +| Merge Request | [Marked as ready](../project/merge_requests/drafts.md) | Watchers and participants. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15332) in GitLab 13.10._ | +| Merge Request | Merged | Subscribers and participants mentioned. | +| Merge Request | Merged when pipeline succeeds | Author, Participants, Watchers, Subscribers, and Custom notification level with this event selected. Custom notification level is ignored for Author, Watchers and Subscribers. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211961) in GitLab 13.4._ | +| Merge Request | Milestone changed | Subscribers and participants mentioned. | +| Merge Request | Milestone removed | Subscribers and participants mentioned. | +| Merge Request | New | Anyone mentioned by username in the description, with notification level "Mention" or higher. | +| Merge Request | New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. | +| Merge Request | Pushed | Participants and Custom notification level with this event selected. | +| Merge Request | Reassigned | Participants, Watchers, Subscribers, Custom notification level with this event selected, and the old assignee. | +| Merge Request | Reopened | Subscribers and participants mentioned. | +| Merge Request | Title or description changed | Any new mentions by username. | +| Pipeline | Failed | The author of the pipeline. | +| Pipeline | Fixed | The author of the pipeline. Enabled by default. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/24309) in GitLab 13.1._ | +| Pipeline | Successful | The author of the pipeline, with Custom notification level for successful pipelines. If the pipeline failed previously, a "Fixed pipeline" message is sent for the first successful pipeline after the failure, and then a "Successful pipeline" message for any further successful pipelines. | By default, you don't receive notifications for issues, merge requests, or epics created by yourself. To always receive notifications on your own issues, merge requests, and so on, turn on diff --git a/lib/api/concerns/packages/debian_package_endpoints.rb b/lib/api/concerns/packages/debian_package_endpoints.rb index 842250d351b..181759a7f38 100644 --- a/lib/api/concerns/packages/debian_package_endpoints.rb +++ b/lib/api/concerns/packages/debian_package_endpoints.rb @@ -35,10 +35,10 @@ module API ::Packages::Debian::DistributionsFinder.new(container, codename_or_suite: params[:distribution]).execute.last! end - def present_distribution_package_file! + def present_distribution_package_file!(project) not_found! unless params[:package_name].start_with?(params[:letter]) - package_file = distribution_from!(user_project).package_files.with_file_name(params[:file_name]).last! + package_file = distribution_from!(project).package_files.with_file_name(params[:file_name]).last! present_package_file!(package_file) end diff --git a/lib/api/debian_group_packages.rb b/lib/api/debian_group_packages.rb index 105a0955912..483d0dd9c90 100644 --- a/lib/api/debian_group_packages.rb +++ b/lib/api/debian_group_packages.rb @@ -12,10 +12,6 @@ module API resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do helpers do - def user_project - @project ||= find_project!(params[:project_id]) - end - def project_or_group user_group end @@ -55,7 +51,7 @@ module API route_setting :authentication, authenticate_non_public: true get 'pool/:distribution/:project_id/:letter/:package_name/:package_version/:file_name', requirements: PACKAGE_FILE_REQUIREMENTS do - present_distribution_package_file! + present_distribution_package_file!(find_project!(params[:project_id])) end end end diff --git a/lib/api/debian_project_packages.rb b/lib/api/debian_project_packages.rb index 23a542e4183..353f64b8dd1 100644 --- a/lib/api/debian_project_packages.rb +++ b/lib/api/debian_project_packages.rb @@ -21,16 +21,16 @@ module API resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do helpers do def project_or_group - user_project + user_project(action: :read_package) end end after_validation do require_packages_enabled! - not_found! unless ::Feature.enabled?(:debian_packages, user_project) + not_found! unless ::Feature.enabled?(:debian_packages, project_or_group) - authorize_read_package! + authorize_read_package!(project_or_group) end params do @@ -58,7 +58,7 @@ module API route_setting :authentication, authenticate_non_public: true get 'pool/:distribution/:letter/:package_name/:package_version/:file_name', requirements: PACKAGE_FILE_REQUIREMENTS do - present_distribution_package_file! + present_distribution_package_file!(project_or_group) end params do diff --git a/lib/gitlab/ci/config/entry/cache.rb b/lib/gitlab/ci/config/entry/cache.rb index a5481071fc5..a635f409109 100644 --- a/lib/gitlab/ci/config/entry/cache.rb +++ b/lib/gitlab/ci/config/entry/cache.rb @@ -9,7 +9,7 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[key untracked paths when policy].freeze + ALLOWED_KEYS = %i[key untracked paths when policy unprotect].freeze ALLOWED_POLICY = %w[pull-push push pull].freeze DEFAULT_POLICY = 'pull-push' ALLOWED_WHEN = %w[on_success on_failure always].freeze @@ -33,18 +33,22 @@ module Gitlab entry :key, Entry::Key, description: 'Cache key used to define a cache affinity.' + entry :unprotect, ::Gitlab::Config::Entry::Boolean, + description: 'Unprotect the cache from a protected ref.' + entry :untracked, ::Gitlab::Config::Entry::Boolean, description: 'Cache all untracked files.' entry :paths, Entry::Paths, description: 'Specify which paths should be cached across builds.' - attributes :policy, :when + attributes :policy, :when, :unprotect def value result = super result[:key] = key_value + result[:unprotect] = unprotect || false result[:policy] = policy || DEFAULT_POLICY # Use self.when to avoid conflict with reserved word result[:when] = self.when || DEFAULT_WHEN diff --git a/lib/gitlab/ci/pipeline/seed/build/cache.rb b/lib/gitlab/ci/pipeline/seed/build/cache.rb index 781065a63db..409b6658cc0 100644 --- a/lib/gitlab/ci/pipeline/seed/build/cache.rb +++ b/lib/gitlab/ci/pipeline/seed/build/cache.rb @@ -14,6 +14,7 @@ module Gitlab @policy = local_cache.delete(:policy) @untracked = local_cache.delete(:untracked) @when = local_cache.delete(:when) + @unprotect = local_cache.delete(:unprotect) @custom_key_prefix = custom_key_prefix raise ArgumentError, "unknown cache keys: #{local_cache.keys}" if local_cache.any? @@ -25,7 +26,8 @@ module Gitlab paths: @paths, policy: @policy, untracked: @untracked, - when: @when + when: @when, + unprotect: @unprotect }.compact end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 12cdcf445f7..5aad6f60d44 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -57,6 +57,7 @@ module Gitlab gon.current_user_fullname = current_user.name gon.current_user_avatar_url = current_user.avatar_url gon.time_display_relative = current_user.time_display_relative + gon.use_new_navigation = Feature.enabled?(:super_sidebar_nav, current_user) && current_user&.use_new_navigation end # Initialize gon.features with any flags that should be diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6309e4f4075..6b4a352c8d7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -825,6 +825,9 @@ msgstr "" msgid "%{level_name} is not allowed since the fork source project has lower visibility." msgstr "" +msgid "%{linkStart} Learn more%{linkEnd}." +msgstr "" + msgid "%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it's ready." msgstr "" @@ -10971,6 +10974,9 @@ msgstr "" msgid "Continue to the next step" msgstr "" +msgid "Continue with overages" +msgstr "" + msgid "Continuous Integration and Deployment" msgstr "" @@ -27237,6 +27243,27 @@ msgstr "" msgid "NavigationTheme|Red" msgstr "" +msgid "Navigation|Context navigation" +msgstr "" + +msgid "Navigation|Recent groups" +msgstr "" + +msgid "Navigation|Recent projects" +msgstr "" + +msgid "Navigation|Switch to..." +msgstr "" + +msgid "Navigation|View all groups" +msgstr "" + +msgid "Navigation|View all projects" +msgstr "" + +msgid "Navigation|Your work" +msgstr "" + msgid "Nav|Help" msgstr "" @@ -34831,6 +34858,33 @@ msgstr "" msgid "Report your license usage data to GitLab" msgstr "" +msgid "ReportAbuse|Something else." +msgstr "" + +msgid "ReportAbuse|They're being offsensive or abusive." +msgstr "" + +msgid "ReportAbuse|They're crypto mining." +msgstr "" + +msgid "ReportAbuse|They're phising." +msgstr "" + +msgid "ReportAbuse|They're posting malware." +msgstr "" + +msgid "ReportAbuse|They're posting personal information or credentials." +msgstr "" + +msgid "ReportAbuse|They're posting spam." +msgstr "" + +msgid "ReportAbuse|They're violating a copyright or trademark." +msgstr "" + +msgid "ReportAbuse|Why are you reporting this user?" +msgstr "" + msgid "Reported %{timeAgo} by %{reportedBy}" msgstr "" @@ -47337,6 +47391,9 @@ msgstr "" msgid "You are about to delete this project containing:" msgstr "" +msgid "You are about to incur additional charges" +msgstr "" + msgid "You are about to transfer the control of your account to %{group_name} group. This action is NOT reversible, you won't be able to access any of your groups and projects outside of %{group_name} once this transfer is complete." msgstr "" diff --git a/package.json b/package.json index ef7eac995ad..f5c010e0d0b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@gitlab/favicon-overlay": "2.0.0", "@gitlab/fonts": "^1.0.1", "@gitlab/svgs": "3.14.0", - "@gitlab/ui": "52.6.0", + "@gitlab/ui": "52.6.1", "@gitlab/visual-review-tools": "1.7.3", "@gitlab/web-ide": "0.0.1-dev-20221217175648", "@rails/actioncable": "6.1.4-7", diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index aacff7c4172..bcce8aa0bfe 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -409,6 +409,7 @@ module QA fill_element(:reply_field, '') fill_element(:reply_field, initial_content.gsub(/(```suggestion:-0\+0\n).*(\n```)/, "\\1#{suggestion}\\2")) click_element(:comment_now_button) + wait_for_requests end def apply_suggestion_with_message(message) diff --git a/scripts/gitlab_component_helpers.sh b/scripts/gitlab_component_helpers.sh index 0d72f940036..c46dbb57a58 100644 --- a/scripts/gitlab_component_helpers.sh +++ b/scripts/gitlab_component_helpers.sh @@ -87,12 +87,10 @@ function upload_package() { function read_curl_package() { local package_url="${1}" - local token_header="${CURL_TOKEN_HEADER}" - local token="${CI_JOB_TOKEN}" echoinfo "Downloading from ${package_url} ..." - curl --fail --silent --retry 3 --header "${token_header}: ${token}" "${package_url}" + curl --fail --silent --retry 3 "${package_url}" } function extract_package() { diff --git a/spec/features/abuse_report_spec.rb b/spec/features/abuse_report_spec.rb index fdd11b59938..87236530046 100644 --- a/spec/features/abuse_report_spec.rb +++ b/spec/features/abuse_report_spec.rb @@ -2,17 +2,43 @@ require 'spec_helper' -RSpec.describe 'Abuse reports', feature_category: :not_owned do - let(:another_user) { create(:user) } +RSpec.describe 'Abuse reports', feature_category: :insider_threat do + let_it_be(:another_user) { create(:user) } + + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project, author: another_user) } before do sign_in(create(:user)) end - it 'report abuse' do + it 'report abuse from an issue', :js do + visit project_issue_path(project, issue) + + click_button 'Issue actions' + click_link 'Report abuse to administrator' + + wait_for_requests + + fill_in 'abuse_report_message', with: 'This user sends spam' + click_button 'Send report' + + expect(page).to have_content 'Thank you for your report' + visit user_path(another_user) - click_link 'Report abuse' + expect(page).to have_button('Already reported for abuse') + end + + it 'report abuse from profile', :js do + visit user_path(another_user) + + click_button 'Report abuse to administrator' + + choose "They're posting spam." + click_button 'Next' + + wait_for_requests fill_in 'abuse_report_message', with: 'This user sends spam' click_button 'Send report' @@ -21,6 +47,6 @@ RSpec.describe 'Abuse reports', feature_category: :not_owned do visit user_path(another_user) - expect(page).to have_button("Already reported for abuse") + expect(page).to have_button('Already reported for abuse') end end diff --git a/spec/features/nav/new_nav_toggle_spec.rb b/spec/features/nav/new_nav_toggle_spec.rb index f040d801cfb..8e5cc7df053 100644 --- a/spec/features/nav/new_nav_toggle_spec.rb +++ b/spec/features/nav/new_nav_toggle_spec.rb @@ -48,14 +48,19 @@ RSpec.describe 'new navigation toggle', :js, feature_category: :navigation do expect(user.reload.use_new_navigation).to eq true end + + it 'shows the old navigation' do + expect(page).to have_selector('.js-navbar') + expect(page).not_to have_selector('[data-testid="super-sidebar"]') + end end context 'when user has new nav enabled' do let(:user_preference) { true } it 'allows to disable new nav', :aggregate_failures do - within '.js-nav-user-dropdown' do - find('a[data-toggle="dropdown"]').click + within '[data-testid="super-sidebar"] [data-testid="user-dropdown"]' do + find('button').click expect(page).to have_content('Navigation redesign') toggle = page.find('.gl-toggle.is-checked') @@ -66,6 +71,11 @@ RSpec.describe 'new navigation toggle', :js, feature_category: :navigation do expect(user.reload.use_new_navigation).to eq false end + + it 'shows the new navigation' do + expect(page).not_to have_selector('.js-navbar') + expect(page).to have_selector('[data-testid="super-sidebar"]') + end end end end diff --git a/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js new file mode 100644 index 00000000000..4f66348f9cd --- /dev/null +++ b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js @@ -0,0 +1,125 @@ +import { GlDrawer, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +jest.mock('~/lib/utils/common_utils', () => ({ + contentTop: jest.fn(), +})); + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('AbuseCategorySelector', () => { + let wrapper; + + const ACTION_PATH = '/abuse_reports/add_category'; + const USER_ID = '1'; + const REPORTED_FROM_URL = 'http://example.com'; + + const createComponent = (props) => { + wrapper = shallowMountExtended(AbuseCategorySelector, { + propsData: { + ...props, + }, + provide: { + formSubmitPath: ACTION_PATH, + userId: USER_ID, + reportedFromUrl: REPORTED_FROM_URL, + }, + }); + }; + + beforeEach(() => { + createComponent({ showDrawer: true }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDrawer = () => wrapper.findComponent(GlDrawer); + const findTitle = () => wrapper.findByTestId('category-drawer-title'); + + const findForm = () => wrapper.findComponent(GlForm); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + + const findCSRFToken = () => findForm().find('input[name="authenticity_token"]'); + const findUserId = () => wrapper.findByTestId('input-user-id'); + const findReferer = () => wrapper.findByTestId('input-referer'); + + const findSubmitFormButton = () => wrapper.findByTestId('submit-form-button'); + + describe('Drawer', () => { + it('is open when prop showDrawer = true', () => { + expect(findDrawer().exists()).toBe(true); + expect(findDrawer().props('open')).toBe(true); + }); + + it('renders title', () => { + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); + }); + + it('emits close-drawer event', async () => { + await findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close-drawer')).toHaveLength(1); + }); + + describe('when props showDrawer = false', () => { + beforeEach(() => { + createComponent({ showDrawer: false }); + }); + + it('hides the drawer', () => { + expect(findDrawer().props('open')).toBe(false); + }); + }); + }); + + describe('Select category form', () => { + it('renders POST form with path', () => { + expect(findForm().attributes()).toMatchObject({ + method: 'post', + action: ACTION_PATH, + }); + }); + + it('renders csrf token', () => { + expect(findCSRFToken().attributes('value')).toBe('mock-csrf-token'); + }); + + it('renders label', () => { + expect(findFormGroup().exists()).toBe(true); + expect(findFormGroup().attributes('label')).toBe(wrapper.vm.$options.i18n.label); + }); + + it('renders radio group', () => { + expect(findRadioGroup().exists()).toBe(true); + expect(findRadioGroup().props('options')).toEqual(wrapper.vm.$options.categoryOptions); + expect(findRadioGroup().attributes('name')).toBe('abuse_report[category]'); + expect(findRadioGroup().attributes('required')).not.toBeUndefined(); + }); + + it('renders userId as a hidden fields', () => { + expect(findUserId().attributes()).toMatchObject({ + type: 'hidden', + name: 'user_id', + value: USER_ID, + }); + }); + + it('renders referer as a hidden fields', () => { + expect(findReferer().attributes()).toMatchObject({ + type: 'hidden', + name: 'ref_url', + value: REPORTED_FROM_URL, + }); + }); + + it('renders submit button', () => { + expect(findSubmitFormButton().exists()).toBe(true); + expect(findSubmitFormButton().text()).toBe(wrapper.vm.$options.i18n.next); + }); + }); +}); diff --git a/spec/frontend/behaviors/markdown/render_gfm_spec.js b/spec/frontend/behaviors/markdown/render_gfm_spec.js new file mode 100644 index 00000000000..0bbb92282e5 --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_gfm_spec.js @@ -0,0 +1,9 @@ +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +describe('renderGFM', () => { + it('handles a missing element', () => { + expect(() => { + renderGFM(); + }).not.toThrow(); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js index 17bf465baf3..0821c59c8a0 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -1,5 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; import { mockPipelineScheduleNodes } from '../../../mock_data'; @@ -18,7 +18,7 @@ describe('Pipeline schedule last pipeline', () => { }); }; - const findCIBadge = () => wrapper.findComponent(CiBadge); + const findCIBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); afterEach(() => { @@ -28,8 +28,10 @@ describe('Pipeline schedule last pipeline', () => { it('displays pipeline status', () => { createComponent(); - expect(findCIBadge().exists()).toBe(true); - expect(findCIBadge().props('status')).toBe(defaultProps.schedule.lastPipeline.detailedStatus); + expect(findCIBadgeLink().exists()).toBe(true); + expect(findCIBadgeLink().props('status')).toBe( + defaultProps.schedule.lastPipeline.detailedStatus, + ); expect(findStatusText().exists()).toBe(false); }); @@ -37,6 +39,6 @@ describe('Pipeline schedule last pipeline', () => { createComponent({ schedule: mockPipelineScheduleNodes[0] }); expect(findStatusText().text()).toBe('None'); - expect(findCIBadge().exists()).toBe(false); + expect(findCIBadgeLink().exists()).toBe(false); }); }); diff --git a/spec/frontend/gfm_auto_complete/mock_data.js b/spec/frontend/gfm_auto_complete/mock_data.js index 9c5a9d7ef3d..d58ccaf0f39 100644 --- a/spec/frontend/gfm_auto_complete/mock_data.js +++ b/spec/frontend/gfm_auto_complete/mock_data.js @@ -37,8 +37,8 @@ export const crmContactsMock = [ { id: 1, email: 'contact.1@email.com', - firstName: 'Contact', - lastName: 'One', + first_name: 'Contact', + last_name: 'One', search: 'contact.1@email.com', state: 'active', set: false, @@ -46,8 +46,8 @@ export const crmContactsMock = [ { id: 2, email: 'contact.2@email.com', - firstName: 'Contact', - lastName: 'Two', + first_name: 'Contact', + last_name: 'Two', search: 'contact.2@email.com', state: 'active', set: false, @@ -55,8 +55,8 @@ export const crmContactsMock = [ { id: 3, email: 'contact.3@email.com', - firstName: 'Contact', - lastName: 'Three', + first_name: 'Contact', + last_name: 'Three', search: 'contact.3@email.com', state: 'inactive', set: false, @@ -64,8 +64,8 @@ export const crmContactsMock = [ { id: 4, email: 'contact.4@email.com', - firstName: 'Contact', - lastName: 'Four', + first_name: 'Contact', + last_name: 'Four', search: 'contact.4@email.com', state: 'inactive', set: true, @@ -73,8 +73,8 @@ export const crmContactsMock = [ { id: 5, email: 'contact.5@email.com', - firstName: 'Contact', - lastName: 'Five', + first_name: 'Contact', + last_name: 'Five', search: 'contact.5@email.com', state: 'active', set: true, @@ -82,8 +82,8 @@ export const crmContactsMock = [ { id: 5, email: 'contact.6@email.com', - firstName: 'Contact', - lastName: 'Six', + first_name: 'Contact', + last_name: 'Six', search: 'contact.6@email.com', state: 'active', set: undefined, // On purpose diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index eeef92d4183..cc2dc084e47 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GfmAutoComplete, { + escape, membersBeforeSave, highlighter, CONTACT_STATE_ACTIVE, @@ -21,6 +22,20 @@ import { crmContactsMock, } from 'ee_else_ce_jest/gfm_auto_complete/mock_data'; +describe('escape', () => { + it.each` + xssPayload | escapedPayload + ${'<script>alert(1)</script>'} | ${'<script>alert(1)</script>'} + ${'%3Cscript%3E alert(1) %3C%2Fscript%3E'} | ${'<script> alert(1) </script>'} + ${'%253Cscript%253E alert(1) %253C%252Fscript%253E'} | ${'<script> alert(1) </script>'} + `( + 'escapes the input string correctly accounting for multiple encoding', + ({ xssPayload, escapedPayload }) => { + expect(escape(xssPayload)).toBe(escapedPayload); + }, + ); +}); + describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); @@ -590,7 +605,7 @@ describe('GfmAutoComplete', () => { id: 5, title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string }), - ).toBe('<li><small>5</small> ${search}<script>oh no $</li>'); + ).toBe('<li><small>5</small> &dollar;{search}<script>oh no &dollar;</li>'); }); }); @@ -636,7 +651,7 @@ describe('GfmAutoComplete', () => { availabilityStatus: '', }), ).toBe( - '<li>IMG my-group <small>${search}<script>oh no $</small> <i class="icon"/></li>', + '<li>IMG my-group <small>&dollar;{search}<script>oh no &dollar;</small> <i class="icon"/></li>', ); }); @@ -813,7 +828,7 @@ describe('GfmAutoComplete', () => { const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string expect(GfmAutoComplete.Labels.templateFunction(color, title)).toBe( - '<li><span class="dropdown-label-box" style="background: #123456"></span> ${search}<script>oh no $</li>', + '<li><span class="dropdown-label-box" style="background: #123456"></span> &dollar;{search}<script>oh no &dollar;</li>', ); }); }); @@ -868,7 +883,7 @@ describe('GfmAutoComplete', () => { const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string expect(GfmAutoComplete.Milestones.templateFunction(title, expired)).toBe( - '<li>${search}<script>oh no $</li>', + '<li>&dollar;{search}<script>oh no &dollar;</li>', ); }); }); @@ -925,7 +940,9 @@ describe('GfmAutoComplete', () => { const expectContacts = ({ input, output }) => { triggerDropdown(input); - expect(getDropdownItems()).toEqual(output.map((contact) => contact.email)); + expect(getDropdownItems()).toEqual( + output.map((contact) => `${contact.first_name} ${contact.last_name} ${contact.email}`), + ); }; describe('with no contacts assigned', () => { diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js index 803df3df37f..3c4f2d624fe 100644 --- a/spec/frontend/jobs/components/table/jobs_table_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_spec.js @@ -2,14 +2,14 @@ import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { mockJobsNodes } from '../../mock_data'; describe('Jobs Table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); - const findStatusBadge = () => wrapper.findComponent(CiBadge); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findTableRows = () => wrapper.findAllByTestId('jobs-table-row'); const findJobStage = () => wrapper.findByTestId('job-stage-name'); const findJobName = () => wrapper.findByTestId('job-name'); @@ -43,7 +43,7 @@ describe('Jobs Table', () => { }); it('displays job status', () => { - expect(findStatusBadge().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); }); it('displays the job stage and name', () => { diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index b254cce4d72..3815064b3f6 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -4,11 +4,14 @@ import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; import { member } from '../../mock_data'; Vue.use(Vuex); +jest.mock('ee_else_ce/members/guest_overage_confirm_action'); describe('RoleDropdown', () => { let wrapper; @@ -63,12 +66,21 @@ describe('RoleDropdown', () => { const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); const findDropdown = () => wrapper.findComponent(GlDropdown); + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + gon.features = { showOverageOnRolePromotion: true }; + }); + afterEach(() => { + window.gon = originalGon; wrapper.destroy(); }); describe('when dropdown is open', () => { beforeEach(() => { + guestOverageConfirmAction.mockReturnValue(true); createComponent(); return findDropdownToggle().trigger('click'); @@ -117,8 +129,12 @@ describe('RoleDropdown', () => { await getDropdownItemByText('Developer').trigger('click'); expect(findDropdown().props('disabled')).toBe(true); + }); - await nextTick(); + it('enables dropdown after `updateMemberRole` resolves', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); expect(findDropdown().props('disabled')).toBe(false); }); @@ -148,4 +164,44 @@ describe('RoleDropdown', () => { expect(findDropdown().props('right')).toBe(false); }); + + describe('guestOverageConfirmAction', () => { + const mockConfirmAction = ({ confirmed }) => { + guestOverageConfirmAction.mockResolvedValueOnce(confirmed); + }; + + beforeEach(() => { + createComponent(); + + findDropdownToggle().trigger('click'); + }); + + afterEach(() => { + guestOverageConfirmAction.mockReset(); + }); + + describe('when guestOverageConfirmAction returns true', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: true }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('calls updateMemberRole', () => { + expect(actions.updateMemberRole).toHaveBeenCalled(); + }); + }); + + describe('when guestOverageConfirmAction returns false', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: false }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('does not call updateMemberRole', () => { + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/members/guest_overage_confirm_action_spec.js b/spec/frontend/members/guest_overage_confirm_action_spec.js new file mode 100644 index 00000000000..d7ab54fa13b --- /dev/null +++ b/spec/frontend/members/guest_overage_confirm_action_spec.js @@ -0,0 +1,7 @@ +import { guestOverageConfirmAction } from '~/members/guest_overage_confirm_action'; + +describe('guestOverageConfirmAction', () => { + it('returns true', () => { + expect(guestOverageConfirmAction()).toBe(true); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 740037a5ac8..9359bd9b95f 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -17,7 +17,7 @@ import { TRACKING_CATEGORIES, } from '~/pipelines/constants'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; jest.mock('~/pipelines/event_hub'); @@ -50,7 +50,7 @@ describe('Pipelines Table', () => { }; const findGlTableLite = () => wrapper.findComponent(GlTableLite); - const findStatusBadge = () => wrapper.findComponent(CiBadge); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); @@ -97,7 +97,7 @@ describe('Pipelines Table', () => { describe('status cell', () => { it('should render a status badge', () => { - expect(findStatusBadge().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); }); }); @@ -171,7 +171,7 @@ describe('Pipelines Table', () => { }); it('tracks status badge click', () => { - findStatusBadge().vm.$emit('ciStatusBadgeClick'); + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { label: TRACKING_CATEGORIES.table, diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js index de7c56f239a..b7343bf3a7e 100644 --- a/spec/frontend/repository/commits_service_spec.js +++ b/spec/frontend/repository/commits_service_spec.js @@ -4,6 +4,7 @@ import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/co import httpStatus from '~/lib/utils/http_status'; import { createAlert } from '~/flash'; import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants'; +import { refWithSpecialCharMock, encodedRefWithSpecialCharMock } from './mock_data'; jest.mock('~/flash'); @@ -39,10 +40,11 @@ describe('commits service', () => { expect(axios.get).toHaveBeenCalledWith(testUrl, { params: { format: 'json', offset } }); }); - it('encodes the path correctly', async () => { - await requestCommits(1, 'some-project', 'with $peci@l ch@rs/'); + it('encodes the path and ref', async () => { + const encodedUrl = `/some-project/-/refs/${encodedRefWithSpecialCharMock}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`; + + await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock); - const encodedUrl = '/some-project/-/refs/main/logs_tree/with%20%24peci%40l%20ch%40rs%2F'; expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index cda47a5b0a5..c1b5f89c37f 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -87,6 +87,8 @@ export const applicationInfoMock = { gitpodEnabled: true }; export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const refMock = 'default-ref'; +export const refWithSpecialCharMock = 'selected-#-ref'; +export const encodedRefWithSpecialCharMock = encodeURIComponent(refWithSpecialCharMock); export const blobControlsDataMock = { id: '1234', diff --git a/spec/frontend/repository/utils/ref_switcher_utils_spec.js b/spec/frontend/repository/utils/ref_switcher_utils_spec.js index 3335059554f..4d0250fffbf 100644 --- a/spec/frontend/repository/utils/ref_switcher_utils_spec.js +++ b/spec/frontend/repository/utils/ref_switcher_utils_spec.js @@ -1,5 +1,6 @@ import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { refWithSpecialCharMock, encodedRefWithSpecialCharMock } from '../mock_data'; const projectRootPath = 'root/Project1'; const currentRef = 'main'; @@ -19,4 +20,10 @@ describe('generateRefDestinationPath', () => { setWindowLocation(currentPath); expect(generateRefDestinationPath(projectRootPath, selectedRef)).toBe(result); }); + + it('encodes the selected ref', () => { + const result = `${projectRootPath}/-/tree/${encodedRefWithSpecialCharMock}`; + + expect(generateRefDestinationPath(projectRootPath, refWithSpecialCharMock)).toBe(result); + }); }); diff --git a/spec/frontend/users/profile/components/report_abuse_button_spec.js b/spec/frontend/users/profile/components/report_abuse_button_spec.js new file mode 100644 index 00000000000..bd39a089473 --- /dev/null +++ b/spec/frontend/users/profile/components/report_abuse_button_spec.js @@ -0,0 +1,72 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ReportAbuseButton from '~/users/profile/components/report_abuse_button.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +describe('ReportAbuseButton', () => { + let wrapper; + + const ACTION_PATH = '/abuse_reports/add_category'; + const USER_ID = '1'; + const REPORTED_FROM_URL = 'http://example.com'; + + const createComponent = (props) => { + wrapper = shallowMountExtended(ReportAbuseButton, { + propsData: { + ...props, + }, + provide: { + formSubmitPath: ACTION_PATH, + userId: USER_ID, + reportedFromUrl: REPORTED_FROM_URL, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findReportAbuseButton = () => wrapper.findComponent(GlButton); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); + + it('renders report abuse button', () => { + expect(findReportAbuseButton().exists()).toBe(true); + + expect(findReportAbuseButton().props()).toMatchObject({ + category: 'primary', + icon: 'error', + }); + + expect(findReportAbuseButton().attributes('aria-label')).toBe( + wrapper.vm.$options.i18n.reportAbuse, + ); + }); + + it('renders abuse category selector with the drawer initially closed', () => { + expect(findAbuseCategorySelector().exists()).toBe(true); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + await findReportAbuseButton().vm.$emit('click'); + }); + + it('opens the abuse category selector', () => { + expect(findAbuseCategorySelector().props('showDrawer')).toBe(true); + }); + + it('closes the abuse category selector', async () => { + await findAbuseCategorySelector().vm.$emit('close-drawer'); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap deleted file mode 100644 index 4077564486c..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ /dev/null @@ -1,163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MRWidgetAutoMergeEnabled template should have correct elements 1`] = ` -<div - class="mr-widget-body media gl-display-flex gl-align-items-center" -> - <div - class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3" - > - <div - class="gl-display-flex gl-m-auto" - > - <div - class="gl-mr-3 gl-p-2 gl-m-0! gl-text-blue-500 gl-w-6 gl-p-2" - > - <div - class="gl-rounded-full gl-relative gl-display-flex mr-widget-extension-icon" - > - <div - class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto" - > - <div - class="gl-display-flex gl-m-auto gl-translate-y-n50" - > - <svg - aria-label="Scheduled " - class="gl-display-block gl-icon s12" - data-qa-selector="status_scheduled_icon" - data-testid="status-scheduled-icon" - role="img" - > - <use - href="#status-scheduled" - /> - </svg> - </div> - </div> - </div> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-w-full" - > - <div - class="media-body gl-display-flex gl-align-items-center" - > - - <h4 - class="gl-mr-3" - data-testid="statusText" - > - Set by to be merged automatically when the pipeline succeeds - </h4> - - <div - class="gl-display-flex gl-font-size-0 gl-ml-auto gl-gap-3" - > - <div - class="gl-display-flex gl-align-items-flex-start" - > - <div - class="dropdown b-dropdown gl-dropdown gl-display-block gl-md-display-none! btn-group" - lazy="" - no-caret="" - title="Options" - > - <!----> - <button - aria-expanded="false" - aria-haspopup="true" - class="btn dropdown-toggle btn-default btn-sm gl-p-2! gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="dropdown-icon gl-icon s16" - data-testid="ellipsis_v-icon" - role="img" - > - <use - href="#ellipsis_v" - /> - </svg> - - <span - class="gl-dropdown-button-text gl-sr-only" - > - - </span> - - <svg - aria-hidden="true" - class="gl-button-icon dropdown-chevron gl-icon s16" - data-testid="chevron-down-icon" - role="img" - > - <use - href="#chevron-down" - /> - </svg> - </button> - <ul - class="dropdown-menu dropdown-menu-right" - role="menu" - tabindex="-1" - > - <!----> - </ul> - </div> - - <button - class="btn gl-display-none gl-md-display-block gl-float-left btn-confirm btn-sm gl-button btn-confirm-tertiary js-cancel-auto-merge" - data-qa-selector="cancel_auto_merge_button" - data-testid="cancelAutomaticMergeButton" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Cancel auto-merge - - </span> - </button> - </div> - </div> - </div> - - <div - class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" - > - <button - class="btn gl-vertical-align-top btn-default btn-sm gl-button btn-default-tertiary btn-icon" - title="Collapse merge details" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="chevron-lg-up-icon" - role="img" - > - <use - href="#chevron-lg-up" - /> - </svg> - - <!----> - </button> - </div> - </div> -</div> -`; diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 5b9f30dfb86..fef5fee5f19 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -128,14 +128,6 @@ describe('MRWidgetAutoMergeEnabled', () => { }); describe('template', () => { - it('should have correct elements', () => { - factory({ - ...defaultMrProps(), - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - it('should disable cancel auto merge button when the action is in progress', async () => { factory({ ...defaultMrProps(), diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index 07cbfe1e79b..4f24ec2d015 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,6 +1,6 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -79,7 +79,7 @@ describe('CI Badge Link Component', () => { const findIcon = () => wrapper.findComponent(CiIcon); const createComponent = (propsData) => { - wrapper = shallowMount(CiBadge, { propsData }); + wrapper = shallowMount(CiBadgeLink, { propsData }); }; afterEach(() => { diff --git a/spec/graphql/types/description_version_type_spec.rb b/spec/graphql/types/description_version_type_spec.rb new file mode 100644 index 00000000000..36bb1af7f7b --- /dev/null +++ b/spec/graphql/types/description_version_type_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['DescriptionVersion'], feature_category: :team_planning do + it { expect(described_class).to have_graphql_field(:id) } + it { expect(described_class).to have_graphql_field(:description) } + + specify { expect(described_class).to require_graphql_authorizations(:read_issuable) } +end diff --git a/spec/graphql/types/notes/note_type_spec.rb b/spec/graphql/types/notes/note_type_spec.rb index cbf7f086dbe..dd364be5ae2 100644 --- a/spec/graphql/types/notes/note_type_spec.rb +++ b/spec/graphql/types/notes/note_type_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe GitlabSchema.types['Note'] do +RSpec.describe GitlabSchema.types['Note'], feature_category: :team_planning do it 'exposes the expected fields' do expected_fields = %i[ author @@ -24,6 +24,7 @@ RSpec.describe GitlabSchema.types['Note'] do updated_at user_permissions url + system_note_metadata ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/notes/system_note_metadata_type_spec.rb b/spec/graphql/types/notes/system_note_metadata_type_spec.rb new file mode 100644 index 00000000000..d243e926ff5 --- /dev/null +++ b/spec/graphql/types/notes/system_note_metadata_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['SystemNoteMetadata'], feature_category: :team_planning do + it { expect(described_class).to have_graphql_field(:id) } + it { expect(described_class).to have_graphql_field(:action) } + it { expect(described_class).to have_graphql_field(:description_version) } + + specify { expect(described_class).to require_graphql_authorizations(:read_note) } +end diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb index 4a37e17fb08..adf784360c2 100644 --- a/spec/helpers/nav_helper_spec.rb +++ b/spec/helpers/nav_helper_spec.rb @@ -134,4 +134,62 @@ RSpec.describe NavHelper do it { is_expected.to eq(true) } end end + + describe '#show_super_sidebar?' do + shared_examples '#show_super_sidebar returns false' do + it 'returns false' do + expect(helper.show_super_sidebar?).to eq(false) + end + end + + it 'returns false by default' do + allow(helper).to receive(:current_user).and_return(nil) + + expect(helper.show_super_sidebar?).to be_falsy + end + + context 'when used is signed-in' do + let_it_be(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + stub_feature_flags(super_sidebar_nav: new_nav_ff) + user.update!(use_new_navigation: user_preference) + end + + context 'with feature flag off' do + let(:new_nav_ff) { false } + + context 'when user has new nav disabled' do + let(:user_preference) { false } + + it_behaves_like '#show_super_sidebar returns false' + end + + context 'when user has new nav enabled' do + let(:user_preference) { true } + + it_behaves_like '#show_super_sidebar returns false' + end + end + + context 'with feature flag on' do + let(:new_nav_ff) { true } + + context 'when user has new nav disabled' do + let(:user_preference) { false } + + it_behaves_like '#show_super_sidebar returns false' + end + + context 'when user has new nav enabled' do + let(:user_preference) { true } + + it 'returns true' do + expect(helper.show_super_sidebar?).to eq(true) + end + end + end + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 414cbb169b9..67252eed938 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -16,12 +16,14 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do let(:policy) { nil } let(:key) { 'some key' } let(:when_config) { nil } + let(:unprotect) { false } let(:config) do { key: key, untracked: true, - paths: ['some/path/'] + paths: ['some/path/'], + unprotect: unprotect }.tap do |config| config[:policy] = policy if policy config[:when] = when_config if when_config @@ -31,7 +33,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do describe '#value' do shared_examples 'hash key value' do it 'returns hash value' do - expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success') + expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success', unprotect: false) end end @@ -57,6 +59,14 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do end end + context 'with option `unprotect` specified' do + let(:unprotect) { true } + + it 'returns true' do + expect(entry.value).to match(a_hash_including(unprotect: true)) + end + end + context 'with `policy`' do where(:policy, :result) do 'pull-push' | 'pull-push' diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 69c0d05dcdd..c1b9bd58d98 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -631,7 +631,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho it 'overrides default config' do expect(entry[:image].value).to eq(name: 'some_image') - expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success']) + expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success', unprotect: false]) end end @@ -646,7 +646,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho it 'uses config from default entry' do expect(entry[:image].value).to eq 'specified' - expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success']) + expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success', unprotect: false]) end end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index c40589104cd..9722609aef6 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -127,7 +127,8 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', - cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', + unprotect: false }], job_variables: {}, root_variables_inheritance: true, ignore: false, @@ -142,7 +143,8 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', - cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', + unprotect: false }], job_variables: {}, root_variables_inheritance: true, ignore: false, @@ -158,7 +160,8 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, image: { name: "image:1.0" }, services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], - cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }], + cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success', + unprotect: false }], only: { refs: %w(branches tags) }, job_variables: { 'VAR' => { value: 'job' } }, root_variables_inheritance: true, @@ -206,7 +209,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', - cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false }], job_variables: {}, root_variables_inheritance: true, ignore: false, @@ -219,7 +222,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', - cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false }], job_variables: { 'VAR' => { value: 'job' } }, root_variables_inheritance: true, ignore: false, @@ -274,7 +277,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do describe '#cache_value' do it 'returns correct cache definition' do - expect(root.cache_value).to eq([key: 'a', policy: 'pull-push', when: 'on_success']) + expect(root.cache_value).to eq([key: 'a', policy: 'pull-push', when: 'on_success', unprotect: false]) end end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb index fb8020bf43e..c264ea3bece 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb @@ -212,6 +212,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do paths: ['vendor/ruby'], untracked: true, policy: 'push', + unprotect: true, when: 'on_success' } end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index ae98d2e0cad..41c51340eb6 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1699,7 +1699,8 @@ module Gitlab untracked: true, key: 'key', policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false ]) end @@ -1723,7 +1724,8 @@ module Gitlab untracked: true, key: { files: ['file'] }, policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false ]) end @@ -1749,14 +1751,16 @@ module Gitlab untracked: true, key: 'keya', policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false }, { paths: ['logs/', 'binaries/'], untracked: true, key: 'key', policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false } ] ) @@ -1783,7 +1787,8 @@ module Gitlab untracked: true, key: { files: ['file'] }, policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false ]) end @@ -1808,7 +1813,8 @@ module Gitlab untracked: true, key: { files: ['file'], prefix: 'prefix' }, policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false ]) end @@ -1831,7 +1837,8 @@ module Gitlab untracked: false, key: 'local', policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false ]) end end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 3871b18fdd5..32cf3b4c505 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe AbuseReport do +RSpec.describe AbuseReport, feature_category: :insider_threat do let_it_be(:report, reload: true) { create(:abuse_report) } let_it_be(:user, reload: true) { create(:admin) } @@ -24,6 +24,7 @@ RSpec.describe AbuseReport do it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:message) } it { is_expected.to validate_uniqueness_of(:user_id).with_message('has already been reported') } + it { is_expected.to validate_presence_of(:category) } end describe '#remove_user' do @@ -54,4 +55,21 @@ RSpec.describe AbuseReport do report.notify end end + + describe 'enums' do + let(:categories) do + { + spam: 1, + offensive: 2, + phishing: 3, + crypto: 4, + credentials: 5, + copyright: 6, + malware: 7, + other: 8 + } + end + + it { is_expected.to define_enum_for(:category).with_values(**categories) } + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index a9b322a1a16..534875a9bba 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1136,6 +1136,19 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration do it do is_expected.to all(a_hash_including(key: a_string_matching(/-protected$/))) end + + context 'and the cache has the `unprotect` option' do + let(:options) do + { cache: [ + { key: "key", paths: ["public"], policy: "pull-push", unprotect: true }, + { key: "key2", paths: ["public"], policy: "pull-push", unprotect: true } + ] } + end + + it do + is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/))) + end + end end context 'when pipeline is not on a protected ref' do diff --git a/spec/requests/abuse_reports_controller_spec.rb b/spec/requests/abuse_reports_controller_spec.rb index 510855d95e0..71ecf8444bf 100644 --- a/spec/requests/abuse_reports_controller_spec.rb +++ b/spec/requests/abuse_reports_controller_spec.rb @@ -40,6 +40,80 @@ RSpec.describe AbuseReportsController, feature_category: :users do end end + describe 'POST add_category', :aggregate_failures do + subject(:request) { post add_category_abuse_reports_path, params: request_params } + + let(:abuse_category) { 'spam' } + + context 'when user is reported for abuse' do + let(:ref_url) { 'http://example.com' } + let(:request_params) { { user_id: user.id, abuse_report: { category: abuse_category }, ref_url: ref_url } } + + it 'renders new template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:new) + end + + it 'sets the instance variables' do + subject + + expect(assigns(:abuse_report)).to be_kind_of(AbuseReport) + expect(assigns(:abuse_report)).to have_attributes( + user_id: user.id, + category: abuse_category + ) + expect(assigns(:ref_url)).to eq(ref_url) + end + end + + context 'when abuse_report is missing in params' do + let(:request_params) { { user_id: user.id } } + + it 'raises an error' do + expect { subject }.to raise_error(ActionController::ParameterMissing) + end + end + + context 'when user_id is missing in params' do + let(:request_params) { { abuse_report: { category: abuse_category } } } + + it 'redirects the reporter to root_path' do + subject + + expect(response).to redirect_to root_path + expect(flash[:alert]).to eq(_('Cannot create the abuse report. The user has been deleted.')) + end + end + + context 'when the user has already been deleted' do + let(:request_params) { { user_id: user.id, abuse_report: { category: abuse_category } } } + + it 'redirects the reporter to root_path' do + user.destroy! + + subject + + expect(response).to redirect_to root_path + expect(flash[:alert]).to eq(_('Cannot create the abuse report. The user has been deleted.')) + end + end + + context 'when the user has already been blocked' do + let(:request_params) { { user_id: user.id, abuse_report: { category: abuse_category } } } + + it 'redirects the reporter to the user\'s profile' do + user.block + + subject + + expect(response).to redirect_to user + expect(flash[:alert]).to eq(_('Cannot create the abuse report. This user has been blocked.')) + end + end + end + describe 'POST create' do context 'with valid attributes' do it 'saves the abuse report' do diff --git a/spec/requests/api/debian_project_packages_spec.rb b/spec/requests/api/debian_project_packages_spec.rb index c27e165b39b..5258d26be17 100644 --- a/spec/requests/api/debian_project_packages_spec.rb +++ b/spec/requests/api/debian_project_packages_spec.rb @@ -5,7 +5,17 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d include HttpBasicAuthHelpers include WorkhorseHelpers - include_context 'Debian repository shared context', :project, true do + include_context 'Debian repository shared context', :project, false do + shared_examples 'accept GET request on private project with access to package registry for everyone' do + include_context 'Debian repository access', :private, :anonymous, :basic do + before do + container.project_feature.reload.update!(package_registry_access_level: ProjectFeature::PUBLIC) + end + + it_behaves_like 'Debian packages GET request', :success + end + end + context 'with invalid parameter' do let(:url) { "/projects/1/packages/debian/dists/with+space/InRelease" } @@ -16,54 +26,63 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release.gpg" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNATURE-----/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/Release' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Codename: fixture-distribution\n$/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/InRelease' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/InRelease" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/:component/binary-:architecture/by-hash/SHA256/:file_sha256' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/by-hash/SHA256/#{component_file_older_sha256.file_sha256}" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end - describe 'GET projects/:id/packages/debian/dists/*distribution/source/Sources' do + describe 'GET projects/:id/packages/debian/dists/*distribution/:component/source/Sources' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/source/Sources" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Sources file/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end - describe 'GET projects/:id/packages/debian/dists/*distribution/source/by-hash/SHA256/:file_sha256' do + describe 'GET projects/:id/packages/debian/dists/*distribution/:component/source/by-hash/SHA256/:file_sha256' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/source/by-hash/SHA256/#{component_file_sources_older_sha256.file_sha256}" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete D-I Packages file/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/by-hash/SHA256/:file_sha256' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/by-hash/SHA256/#{component_file_di_older_sha256.file_sha256}" } it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/ + it_behaves_like 'accept GET request on private project with access to package registry for everyone' end describe 'GET projects/:id/packages/debian/pool/:codename/:letter/:package_name/:package_version/:file_name' do @@ -90,6 +109,10 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d end end end + + it_behaves_like 'accept GET request on private project with access to package registry for everyone' do + let(:file_name) { 'sample_1.2.3~alpha2.dsc' } + end end describe 'PUT projects/:id/packages/debian/:file_name' do diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb index a59da706a8a..de35c943749 100644 --- a/spec/requests/api/graphql/project/work_items_spec.rb +++ b/spec/requests/api/graphql/project/work_items_spec.rb @@ -263,7 +263,7 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team GRAPHQL end - before do + before_all do create_notes(item1, "some note1") create_notes(item2, "some note2") end diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb index 82c3d374636..f9640f99031 100644 --- a/spec/services/ci/create_pipeline_service/cache_spec.rb +++ b/spec/services/ci/create_pipeline_service/cache_spec.rb @@ -37,6 +37,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes paths: ['logs/', 'binaries/'], policy: 'pull-push', untracked: true, + unprotect: false, when: 'on_success' } @@ -69,7 +70,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes key: /[a-f0-9]{40}/, paths: ['logs/'], policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false } expect(pipeline).to be_persisted @@ -85,7 +87,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes key: /default/, paths: ['logs/'], policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false } expect(pipeline).to be_persisted @@ -118,7 +121,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes key: /\$ENV_VAR-[a-f0-9]{40}/, paths: ['logs/'], policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false } expect(pipeline).to be_persisted @@ -134,7 +138,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes key: /\$ENV_VAR-default/, paths: ['logs/'], policy: 'pull-push', - when: 'on_success' + when: 'on_success', + unprotect: false } expect(pipeline).to be_persisted diff --git a/workhorse/Makefile b/workhorse/Makefile index a0412f5e2e1..4236a1a0d8e 100644 --- a/workhorse/Makefile +++ b/workhorse/Makefile @@ -144,7 +144,7 @@ testdata/scratch: mkdir -p testdata/scratch .PHONY: verify -verify: lint vet detect-context detect-assert check-formatting staticcheck deps-check +verify: lint vet detect-context detect-assert detect-external-tests check-formatting staticcheck deps-check .PHONY: lint lint: @@ -167,6 +167,11 @@ detect-assert: $(call message,Verify: $@) _support/detect-assert.sh +.PHONY: detect-external-tests +detect-external-tests: + $(call message,Verify: $@) + _support/detect-external-tests.sh + .PHONY: check-formatting check-formatting: install-goimports $(call message,Verify: $@) diff --git a/workhorse/_support/detect-external-tests.sh b/workhorse/_support/detect-external-tests.sh new file mode 100755 index 00000000000..865bd1447e1 --- /dev/null +++ b/workhorse/_support/detect-external-tests.sh @@ -0,0 +1,11 @@ +#!/bin/sh +go list -f '{{join .XTestGoFiles "\n"}}' ./... | awk ' + { print } + END { + if(NR>0) { + print "Please avoid using external test packages (package foobar_test) in Workhorse." + print "See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107373." + exit(1) + } + } +' diff --git a/workhorse/internal/upload/destination/destination_test.go b/workhorse/internal/upload/destination/destination_test.go index 97645be168f..b355935e347 100644 --- a/workhorse/internal/upload/destination/destination_test.go +++ b/workhorse/internal/upload/destination/destination_test.go @@ -1,4 +1,4 @@ -package destination_test +package destination import ( "context" @@ -17,12 +17,11 @@ import ( "gitlab.com/gitlab-org/gitlab/workhorse/internal/config" "gitlab.com/gitlab-org/gitlab/workhorse/internal/testhelper" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) func testDeadline() time.Time { - return time.Now().Add(destination.DefaultObjectStoreTimeout) + return time.Now().Add(DefaultObjectStoreTimeout) } func requireFileGetsRemovedAsync(t *testing.T, filePath string) { @@ -44,10 +43,10 @@ func TestUploadWrongSize(t *testing.T) { tmpFolder := t.TempDir() - opts := &destination.UploadOpts{LocalTempPath: tmpFolder} - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, "upload", opts) + opts := &UploadOpts{LocalTempPath: tmpFolder} + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, "upload", opts) require.Error(t, err) - _, isSizeError := err.(destination.SizeError) + _, isSizeError := err.(SizeError) require.True(t, isSizeError, "Should fail with SizeError") require.Nil(t, fh) } @@ -58,10 +57,10 @@ func TestUploadWithKnownSizeExceedLimit(t *testing.T) { tmpFolder := t.TempDir() - opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) + opts := &UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) require.Error(t, err) - _, isSizeError := err.(destination.SizeError) + _, isSizeError := err.(SizeError) require.True(t, isSizeError, "Should fail with SizeError") require.Nil(t, fh) } @@ -72,9 +71,9 @@ func TestUploadWithUnknownSizeExceedLimit(t *testing.T) { tmpFolder := t.TempDir() - opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), -1, "upload", opts) - require.Equal(t, err, destination.ErrEntityTooLarge) + opts := &UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), -1, "upload", opts) + require.Equal(t, err, ErrEntityTooLarge) require.Nil(t, fh) } @@ -94,7 +93,7 @@ func TestUploadWrongETag(t *testing.T) { objectURL := ts.URL + test.ObjectPath - opts := &destination.UploadOpts{ + opts := &UploadOpts{ RemoteID: "test-file", RemoteURL: objectURL, PresignedPut: objectURL + "?Signature=ASignature", @@ -110,7 +109,7 @@ func TestUploadWrongETag(t *testing.T) { osStub.InitiateMultipartUpload(test.ObjectPath) } ctx, cancel := context.WithCancel(context.Background()) - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) require.Nil(t, fh) require.Error(t, err) require.Equal(t, 1, osStub.PutsCnt(), "File not uploaded") @@ -146,7 +145,7 @@ func TestUpload(t *testing.T) { for _, spec := range tests { t.Run(spec.name, func(t *testing.T) { - var opts destination.UploadOpts + var opts UploadOpts var expectedDeletes, expectedPuts int osStub, ts := test.StartObjectStore() @@ -187,7 +186,7 @@ func TestUpload(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) require.NoError(t, err) require.NotNil(t, fh) @@ -206,7 +205,7 @@ func TestUpload(t *testing.T) { } require.Equal(t, test.ObjectSize, fh.Size) - if destination.FIPSEnabled() { + if FIPSEnabled() { require.Empty(t, fh.MD5()) } else { require.Equal(t, test.ObjectMD5, fh.MD5()) @@ -255,7 +254,7 @@ func TestUploadWithS3WorkhorseClient(t *testing.T) { name: "unknown object size with limit", objectSize: -1, maxSize: test.ObjectSize - 1, - expectedErr: destination.ErrEntityTooLarge, + expectedErr: ErrEntityTooLarge, }, } @@ -269,12 +268,12 @@ func TestUploadWithS3WorkhorseClient(t *testing.T) { defer cancel() remoteObject := "tmp/test-file/1" - opts := destination.UploadOpts{ + opts := UploadOpts{ RemoteID: "test-file", Deadline: testDeadline(), UseWorkhorseClient: true, RemoteTempObjectID: remoteObject, - ObjectStorageConfig: destination.ObjectStorageConfig{ + ObjectStorageConfig: ObjectStorageConfig{ Provider: "AWS", S3Credentials: s3Creds, S3Config: s3Config, @@ -282,7 +281,7 @@ func TestUploadWithS3WorkhorseClient(t *testing.T) { MaximumSize: tc.maxSize, } - _, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, "upload", &opts) + _, err := Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, "upload", &opts) if tc.expectedErr == nil { require.NoError(t, err) @@ -302,19 +301,19 @@ func TestUploadWithAzureWorkhorseClient(t *testing.T) { defer cancel() remoteObject := "tmp/test-file/1" - opts := destination.UploadOpts{ + opts := UploadOpts{ RemoteID: "test-file", Deadline: testDeadline(), UseWorkhorseClient: true, RemoteTempObjectID: remoteObject, - ObjectStorageConfig: destination.ObjectStorageConfig{ + ObjectStorageConfig: ObjectStorageConfig{ Provider: "AzureRM", URLMux: mux, GoCloudConfig: config.GoCloudConfig{URL: "azblob://test-container"}, }, } - _, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) + _, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) require.NoError(t, err) test.GoCloudObjectExists(t, bucketDir, remoteObject) @@ -327,19 +326,19 @@ func TestUploadWithUnknownGoCloudScheme(t *testing.T) { mux := new(blob.URLMux) remoteObject := "tmp/test-file/1" - opts := destination.UploadOpts{ + opts := UploadOpts{ RemoteID: "test-file", Deadline: testDeadline(), UseWorkhorseClient: true, RemoteTempObjectID: remoteObject, - ObjectStorageConfig: destination.ObjectStorageConfig{ + ObjectStorageConfig: ObjectStorageConfig{ Provider: "SomeCloud", URLMux: mux, GoCloudConfig: config.GoCloudConfig{URL: "foo://test-container"}, }, } - _, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) + _, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) require.Error(t, err) } @@ -351,7 +350,7 @@ func TestUploadMultipartInBodyFailure(t *testing.T) { // this is the only way to get an in-body failure from our ObjectStoreStub objectPath := "/bucket-but-no-object-key" objectURL := ts.URL + objectPath - opts := destination.UploadOpts{ + opts := UploadOpts{ RemoteID: "test-file", RemoteURL: objectURL, PartSize: test.ObjectSize, @@ -365,7 +364,7 @@ func TestUploadMultipartInBodyFailure(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) + fh, err := Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) require.Nil(t, fh) require.Error(t, err) require.EqualError(t, err, test.MultipartUploadInternalError().Error()) @@ -405,20 +404,20 @@ func TestUploadRemoteFileWithLimit(t *testing.T) { testData: test.ObjectContent, objectSize: -1, maxSize: test.ObjectSize - 1, - expectedErr: destination.ErrEntityTooLarge, + expectedErr: ErrEntityTooLarge, }, { name: "large object with unknown size with limit", testData: string(make([]byte, 20000)), objectSize: -1, maxSize: 19000, - expectedErr: destination.ErrEntityTooLarge, + expectedErr: ErrEntityTooLarge, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - var opts destination.UploadOpts + var opts UploadOpts for _, remoteType := range remoteTypes { osStub, ts := test.StartObjectStore() @@ -454,7 +453,7 @@ func TestUploadRemoteFileWithLimit(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - fh, err := destination.Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, "upload", &opts) + fh, err := Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, "upload", &opts) if tc.expectedErr == nil { require.NoError(t, err) @@ -468,7 +467,7 @@ func TestUploadRemoteFileWithLimit(t *testing.T) { } } -func checkFileHandlerWithFields(t *testing.T, fh *destination.FileHandler, fields map[string]string, prefix string) { +func checkFileHandlerWithFields(t *testing.T, fh *FileHandler, fields map[string]string, prefix string) { key := func(field string) string { if prefix == "" { return field @@ -482,7 +481,7 @@ func checkFileHandlerWithFields(t *testing.T, fh *destination.FileHandler, field require.Equal(t, fh.RemoteURL, fields[key("remote_url")]) require.Equal(t, fh.RemoteID, fields[key("remote_id")]) require.Equal(t, strconv.FormatInt(test.ObjectSize, 10), fields[key("size")]) - if destination.FIPSEnabled() { + if FIPSEnabled() { require.Empty(t, fields[key("md5")]) } else { require.Equal(t, test.ObjectMD5, fields[key("md5")]) diff --git a/workhorse/internal/upload/destination/objectstore/gocloud_object_test.go b/workhorse/internal/upload/destination/objectstore/gocloud_object_test.go index 55d886087be..5a6a4b90b34 100644 --- a/workhorse/internal/upload/destination/objectstore/gocloud_object_test.go +++ b/workhorse/internal/upload/destination/objectstore/gocloud_object_test.go @@ -1,4 +1,4 @@ -package objectstore_test +package objectstore import ( "context" @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "gitlab.com/gitlab-org/gitlab/workhorse/internal/testhelper" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) @@ -22,8 +21,8 @@ func TestGoCloudObjectUpload(t *testing.T) { objectName := "test.png" testURL := "azuretest://azure.example.com/test-container" - p := &objectstore.GoCloudObjectParams{Ctx: ctx, Mux: mux, BucketURL: testURL, ObjectName: objectName} - object, err := objectstore.NewGoCloudObject(p) + p := &GoCloudObjectParams{Ctx: ctx, Mux: mux, BucketURL: testURL, ObjectName: objectName} + object, err := NewGoCloudObject(p) require.NotNil(t, object) require.NoError(t, err) @@ -48,8 +47,8 @@ func TestGoCloudObjectUpload(t *testing.T) { if exists { return fmt.Errorf("file %s is still present", objectName) - } else { - return nil } + + return nil }) } diff --git a/workhorse/internal/upload/destination/objectstore/multipart.go b/workhorse/internal/upload/destination/objectstore/multipart.go index df336d2d901..900ca040dad 100644 --- a/workhorse/internal/upload/destination/objectstore/multipart.go +++ b/workhorse/internal/upload/destination/objectstore/multipart.go @@ -11,6 +11,8 @@ import ( "os" "gitlab.com/gitlab-org/labkit/mask" + + "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/s3api" ) // ErrNotEnoughParts will be used when writing more than size * len(partURLs) @@ -51,7 +53,7 @@ func NewMultipart(partURLs []string, completeURL, abortURL, deleteURL string, pu } func (m *Multipart) Upload(ctx context.Context, r io.Reader) error { - cmu := &CompleteMultipartUpload{} + cmu := &s3api.CompleteMultipartUpload{} for i, partURL := range m.PartURLs { src := io.LimitReader(r, m.partSize) part, err := m.readAndUploadOnePart(ctx, partURL, m.PutHeaders, src, i+1) @@ -91,7 +93,7 @@ func (m *Multipart) Delete() { deleteURL(m.DeleteURL) } -func (m *Multipart) readAndUploadOnePart(ctx context.Context, partURL string, putHeaders map[string]string, src io.Reader, partNumber int) (*completeMultipartUploadPart, error) { +func (m *Multipart) readAndUploadOnePart(ctx context.Context, partURL string, putHeaders map[string]string, src io.Reader, partNumber int) (*s3api.CompleteMultipartUploadPart, error) { file, err := os.CreateTemp("", "part-buffer") if err != nil { return nil, fmt.Errorf("create temporary buffer file: %v", err) @@ -118,7 +120,7 @@ func (m *Multipart) readAndUploadOnePart(ctx context.Context, partURL string, pu if err != nil { return nil, fmt.Errorf("upload part %d: %v", partNumber, err) } - return &completeMultipartUploadPart{PartNumber: partNumber, ETag: etag}, nil + return &s3api.CompleteMultipartUploadPart{PartNumber: partNumber, ETag: etag}, nil } func (m *Multipart) uploadPart(ctx context.Context, url string, headers map[string]string, body io.Reader, size int64) (string, error) { @@ -142,7 +144,7 @@ func (m *Multipart) uploadPart(ctx context.Context, url string, headers map[stri return part.ETag(), nil } -func (m *Multipart) complete(ctx context.Context, cmu *CompleteMultipartUpload) error { +func (m *Multipart) complete(ctx context.Context, cmu *s3api.CompleteMultipartUpload) error { body, err := xml.Marshal(cmu) if err != nil { return fmt.Errorf("marshal CompleteMultipartUpload request: %v", err) diff --git a/workhorse/internal/upload/destination/objectstore/multipart_test.go b/workhorse/internal/upload/destination/objectstore/multipart_test.go index 2a5161e42e7..00244a5c50b 100644 --- a/workhorse/internal/upload/destination/objectstore/multipart_test.go +++ b/workhorse/internal/upload/destination/objectstore/multipart_test.go @@ -1,4 +1,4 @@ -package objectstore_test +package objectstore import ( "context" @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) @@ -48,7 +47,7 @@ func TestMultipartUploadWithUpcaseETags(t *testing.T) { deadline := time.Now().Add(testTimeout) - m, err := objectstore.NewMultipart( + m, err := NewMultipart( []string{ts.URL}, // a single presigned part URL ts.URL, // the complete multipart upload URL "", // no abort diff --git a/workhorse/internal/upload/destination/objectstore/object_test.go b/workhorse/internal/upload/destination/objectstore/object_test.go index 24117891b6d..2b94cd9e3b1 100644 --- a/workhorse/internal/upload/destination/objectstore/object_test.go +++ b/workhorse/internal/upload/destination/objectstore/object_test.go @@ -1,4 +1,4 @@ -package objectstore_test +package objectstore import ( "context" @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) @@ -35,7 +34,7 @@ func testObjectUploadNoErrors(t *testing.T, startObjectStore osFactory, useDelet defer cancel() deadline := time.Now().Add(testTimeout) - object, err := objectstore.NewObject(objectURL, deleteURL, putHeaders, test.ObjectSize) + object, err := NewObject(objectURL, deleteURL, putHeaders, test.ObjectSize) require.NoError(t, err) // copy data @@ -97,12 +96,12 @@ func TestObjectUpload404(t *testing.T) { deadline := time.Now().Add(testTimeout) objectURL := ts.URL + test.ObjectPath - object, err := objectstore.NewObject(objectURL, "", map[string]string{}, test.ObjectSize) + object, err := NewObject(objectURL, "", map[string]string{}, test.ObjectSize) require.NoError(t, err) _, err = object.Consume(ctx, strings.NewReader(test.ObjectContent), deadline) require.Error(t, err) - _, isStatusCodeError := err.(objectstore.StatusCodeError) + _, isStatusCodeError := err.(StatusCodeError) require.True(t, isStatusCodeError, "Should fail with StatusCodeError") require.Contains(t, err.Error(), "404") } @@ -140,7 +139,7 @@ func TestObjectUploadBrokenConnection(t *testing.T) { deadline := time.Now().Add(testTimeout) objectURL := ts.URL + test.ObjectPath - object, err := objectstore.NewObject(objectURL, "", map[string]string{}, -1) + object, err := NewObject(objectURL, "", map[string]string{}, -1) require.NoError(t, err) _, copyErr := object.Consume(ctx, &endlessReader{}, deadline) diff --git a/workhorse/internal/upload/destination/objectstore/s3_complete_multipart_api.go b/workhorse/internal/upload/destination/objectstore/s3_complete_multipart_api.go index b84f5757f49..02799d0b9b0 100644 --- a/workhorse/internal/upload/destination/objectstore/s3_complete_multipart_api.go +++ b/workhorse/internal/upload/destination/objectstore/s3_complete_multipart_api.go @@ -2,45 +2,15 @@ package objectstore import ( "encoding/xml" - "fmt" -) - -// CompleteMultipartUpload is the S3 CompleteMultipartUpload body -type CompleteMultipartUpload struct { - Part []*completeMultipartUploadPart -} -type completeMultipartUploadPart struct { - PartNumber int - ETag string -} - -// CompleteMultipartUploadResult is the S3 answer to CompleteMultipartUpload request -type CompleteMultipartUploadResult struct { - Location string - Bucket string - Key string - ETag string -} - -// CompleteMultipartUploadError is the in-body error structure -// https://docs.aws.amazon.com/AmazonS3/latest/API/mpUploadComplete.html#mpUploadComplete-examples -// the answer contains other fields we are not using -type CompleteMultipartUploadError struct { - XMLName xml.Name `xml:"Error"` - Code string - Message string -} - -func (c *CompleteMultipartUploadError) Error() string { - return fmt.Sprintf("CompleteMultipartUpload remote error %q: %s", c.Code, c.Message) -} + "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/s3api" +) // compoundCompleteMultipartUploadResult holds both CompleteMultipartUploadResult and CompleteMultipartUploadError // this allow us to deserialize the response body where the root element can either be Error orCompleteMultipartUploadResult type compoundCompleteMultipartUploadResult struct { - *CompleteMultipartUploadResult - *CompleteMultipartUploadError + *s3api.CompleteMultipartUploadResult + *s3api.CompleteMultipartUploadError // XMLName this overrides CompleteMultipartUploadError.XMLName tags XMLName xml.Name diff --git a/workhorse/internal/upload/destination/objectstore/s3_object_test.go b/workhorse/internal/upload/destination/objectstore/s3_object_test.go index 0ed14a2e844..c99712d18ad 100644 --- a/workhorse/internal/upload/destination/objectstore/s3_object_test.go +++ b/workhorse/internal/upload/destination/objectstore/s3_object_test.go @@ -1,4 +1,4 @@ -package objectstore_test +package objectstore import ( "context" @@ -17,7 +17,6 @@ import ( "gitlab.com/gitlab-org/gitlab/workhorse/internal/config" "gitlab.com/gitlab-org/gitlab/workhorse/internal/testhelper" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) @@ -50,7 +49,7 @@ func TestS3ObjectUpload(t *testing.T) { objectName := filepath.Join(tmpDir, "s3-test-data") ctx, cancel := context.WithCancel(context.Background()) - object, err := objectstore.NewS3Object(objectName, creds, config) + object, err := NewS3Object(objectName, creds, config) require.NoError(t, err) // copy data @@ -107,7 +106,7 @@ func TestConcurrentS3ObjectUpload(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - object, err := objectstore.NewS3Object(objectName, creds, config) + object, err := NewS3Object(objectName, creds, config) require.NoError(t, err) // copy data @@ -134,7 +133,7 @@ func TestS3ObjectUploadCancel(t *testing.T) { objectName := filepath.Join(tmpDir, "s3-test-data") - object, err := objectstore.NewS3Object(objectName, creds, config) + object, err := NewS3Object(objectName, creds, config) require.NoError(t, err) @@ -155,7 +154,7 @@ func TestS3ObjectUploadLimitReached(t *testing.T) { tmpDir := t.TempDir() objectName := filepath.Join(tmpDir, "s3-test-data") - object, err := objectstore.NewS3Object(objectName, creds, config) + object, err := NewS3Object(objectName, creds, config) require.NoError(t, err) _, err = object.Consume(context.Background(), &failedReader{}, deadline) diff --git a/workhorse/internal/upload/destination/objectstore/s3api/s3api.go b/workhorse/internal/upload/destination/objectstore/s3api/s3api.go new file mode 100644 index 00000000000..49ab9347911 --- /dev/null +++ b/workhorse/internal/upload/destination/objectstore/s3api/s3api.go @@ -0,0 +1,37 @@ +package s3api + +import ( + "encoding/xml" + "fmt" +) + +// CompleteMultipartUploadError is the in-body error structure +// https://docs.aws.amazon.com/AmazonS3/latest/API/mpUploadComplete.html#mpUploadComplete-examples +// the answer contains other fields we are not using +type CompleteMultipartUploadError struct { + XMLName xml.Name `xml:"Error"` + Code string + Message string +} + +func (c *CompleteMultipartUploadError) Error() string { + return fmt.Sprintf("CompleteMultipartUpload remote error %q: %s", c.Code, c.Message) +} + +// CompleteMultipartUploadResult is the S3 answer to CompleteMultipartUpload request +type CompleteMultipartUploadResult struct { + Location string + Bucket string + Key string + ETag string +} + +// CompleteMultipartUpload is the S3 CompleteMultipartUpload body +type CompleteMultipartUpload struct { + Part []*CompleteMultipartUploadPart +} + +type CompleteMultipartUploadPart struct { + PartNumber int + ETag string +} diff --git a/workhorse/internal/upload/destination/objectstore/test/objectstore_stub.go b/workhorse/internal/upload/destination/objectstore/test/objectstore_stub.go index 1a380bd5083..8fbb746d6ce 100644 --- a/workhorse/internal/upload/destination/objectstore/test/objectstore_stub.go +++ b/workhorse/internal/upload/destination/objectstore/test/objectstore_stub.go @@ -12,7 +12,7 @@ import ( "strings" "sync" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore" + "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/s3api" ) type partsEtagMap map[int]string @@ -190,8 +190,8 @@ func (o *ObjectstoreStub) putObject(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } -func MultipartUploadInternalError() *objectstore.CompleteMultipartUploadError { - return &objectstore.CompleteMultipartUploadError{Code: "InternalError", Message: "malformed object path"} +func MultipartUploadInternalError() *s3api.CompleteMultipartUploadError { + return &s3api.CompleteMultipartUploadError{Code: "InternalError", Message: "malformed object path"} } func (o *ObjectstoreStub) completeMultipartUpload(w http.ResponseWriter, r *http.Request) { @@ -212,7 +212,7 @@ func (o *ObjectstoreStub) completeMultipartUpload(w http.ResponseWriter, r *http return } - var msg objectstore.CompleteMultipartUpload + var msg s3api.CompleteMultipartUpload err = xml.Unmarshal(buf, &msg) if err != nil { http.Error(w, err.Error(), 400) @@ -245,7 +245,7 @@ func (o *ObjectstoreStub) completeMultipartUpload(w http.ResponseWriter, r *http bucket := split[0] key := split[1] - answer := objectstore.CompleteMultipartUploadResult{ + answer := s3api.CompleteMultipartUploadResult{ Location: r.URL.String(), Bucket: bucket, Key: key, diff --git a/workhorse/internal/upload/destination/upload_opts_test.go b/workhorse/internal/upload/destination/upload_opts_test.go index fd9e56db194..a420e842e4d 100644 --- a/workhorse/internal/upload/destination/upload_opts_test.go +++ b/workhorse/internal/upload/destination/upload_opts_test.go @@ -1,4 +1,4 @@ -package destination_test +package destination import ( "testing" @@ -8,7 +8,6 @@ import ( "gitlab.com/gitlab-org/gitlab/workhorse/internal/api" "gitlab.com/gitlab-org/gitlab/workhorse/internal/config" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination" "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload/destination/objectstore/test" ) @@ -43,7 +42,7 @@ func TestUploadOptsLocalAndRemote(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - opts := destination.UploadOpts{ + opts := UploadOpts{ LocalTempPath: test.localTempPath, PresignedPut: test.presignedPut, PartSize: test.partSize, @@ -106,7 +105,7 @@ func TestGetOpts(t *testing.T) { }, } deadline := time.Now().Add(time.Duration(apiResponse.RemoteObject.Timeout) * time.Second) - opts, err := destination.GetOpts(apiResponse) + opts, err := GetOpts(apiResponse) require.NoError(t, err) require.Equal(t, apiResponse.TempPath, opts.LocalTempPath) @@ -155,22 +154,22 @@ func TestGetOptsFail(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - _, err := destination.GetOpts(tc.in) + _, err := GetOpts(tc.in) require.Error(t, err, "expect input to be rejected") }) } } func TestGetOptsDefaultTimeout(t *testing.T) { - deadline := time.Now().Add(destination.DefaultObjectStoreTimeout) - opts, err := destination.GetOpts(&api.Response{TempPath: "/foo/bar"}) + deadline := time.Now().Add(DefaultObjectStoreTimeout) + opts, err := GetOpts(&api.Response{TempPath: "/foo/bar"}) require.NoError(t, err) require.WithinDuration(t, deadline, opts.Deadline, time.Minute) } func TestUseWorkhorseClientEnabled(t *testing.T) { - cfg := destination.ObjectStorageConfig{ + cfg := ObjectStorageConfig{ Provider: "AWS", S3Config: config.S3Config{ Bucket: "test-bucket", @@ -195,7 +194,7 @@ func TestUseWorkhorseClientEnabled(t *testing.T) { name string UseWorkhorseClient bool remoteTempObjectID string - objectStorageConfig destination.ObjectStorageConfig + objectStorageConfig ObjectStorageConfig expected bool }{ { @@ -243,7 +242,7 @@ func TestUseWorkhorseClientEnabled(t *testing.T) { name: "missing S3 bucket", UseWorkhorseClient: true, remoteTempObjectID: "test-object", - objectStorageConfig: destination.ObjectStorageConfig{ + objectStorageConfig: ObjectStorageConfig{ Provider: "AWS", S3Config: config.S3Config{}, }, @@ -269,7 +268,7 @@ func TestUseWorkhorseClientEnabled(t *testing.T) { }, } deadline := time.Now().Add(time.Duration(apiResponse.RemoteObject.Timeout) * time.Second) - opts, err := destination.GetOpts(apiResponse) + opts, err := GetOpts(apiResponse) require.NoError(t, err) opts.ObjectStorageConfig = test.objectStorageConfig @@ -322,7 +321,7 @@ func TestGoCloudConfig(t *testing.T) { }, } deadline := time.Now().Add(time.Duration(apiResponse.RemoteObject.Timeout) * time.Second) - opts, err := destination.GetOpts(apiResponse) + opts, err := GetOpts(apiResponse) require.NoError(t, err) opts.ObjectStorageConfig.URLMux = mux diff --git a/workhorse/internal/upload/object_storage_preparer_test.go b/workhorse/internal/upload/object_storage_preparer_test.go index 56de6bbf7d6..b983d68f1ad 100644 --- a/workhorse/internal/upload/object_storage_preparer_test.go +++ b/workhorse/internal/upload/object_storage_preparer_test.go @@ -1,4 +1,4 @@ -package upload_test +package upload import ( "testing" @@ -7,7 +7,6 @@ import ( "gitlab.com/gitlab-org/gitlab/workhorse/internal/api" "gitlab.com/gitlab-org/gitlab/workhorse/internal/config" - "gitlab.com/gitlab-org/gitlab/workhorse/internal/upload" "github.com/stretchr/testify/require" ) @@ -38,7 +37,7 @@ func TestPrepareWithS3Config(t *testing.T) { }, } - p := upload.NewObjectStoragePreparer(c) + p := NewObjectStoragePreparer(c) opts, err := p.Prepare(r) require.NoError(t, err) @@ -51,7 +50,7 @@ func TestPrepareWithS3Config(t *testing.T) { func TestPrepareWithNoConfig(t *testing.T) { c := config.Config{} r := &api.Response{RemoteObject: api.RemoteObject{ID: "id"}} - p := upload.NewObjectStoragePreparer(c) + p := NewObjectStoragePreparer(c) opts, err := p.Prepare(r) require.NoError(t, err) diff --git a/workhorse/internal/zipartifacts/metadata_test.go b/workhorse/internal/zipartifacts/metadata_test.go index e4799ba4a59..6bde56ef27d 100644 --- a/workhorse/internal/zipartifacts/metadata_test.go +++ b/workhorse/internal/zipartifacts/metadata_test.go @@ -1,4 +1,4 @@ -package zipartifacts_test +package zipartifacts import ( "bytes" @@ -11,8 +11,6 @@ import ( "github.com/stretchr/testify/require" zip "gitlab.com/gitlab-org/golang-archive-zip" - - "gitlab.com/gitlab-org/gitlab/workhorse/internal/zipartifacts" ) func generateTestArchive(w io.Writer) error { @@ -72,10 +70,10 @@ func TestGenerateZipMetadataFromFile(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - archive, err := zipartifacts.OpenArchive(ctx, f.Name()) + archive, err := OpenArchive(ctx, f.Name()) require.NoError(t, err, "zipartifacts: OpenArchive failed") - err = zipartifacts.GenerateZipMetadata(&metaBuffer, archive) + err = GenerateZipMetadata(&metaBuffer, archive) require.NoError(t, err, "zipartifacts: GenerateZipMetadata failed") err = validateMetadata(&metaBuffer) @@ -96,6 +94,6 @@ func TestErrNotAZip(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err = zipartifacts.OpenArchive(ctx, f.Name()) - require.Equal(t, zipartifacts.ErrorCode[zipartifacts.CodeNotZip], err, "OpenArchive requires a zip file") + _, err = OpenArchive(ctx, f.Name()) + require.Equal(t, ErrorCode[CodeNotZip], err, "OpenArchive requires a zip file") } diff --git a/yarn.lock b/yarn.lock index f158fd9ec26..0184cfbdfc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1136,10 +1136,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.14.0.tgz#b32a673f08bbd5ba6d406bcf3abb6e7276271b6c" integrity sha512-mQYtW9eGHY7cF6elsWd76hUF7F3NznyzrJJy5eXBHjvRdYBtyHmwkVmh1Cwr3S/2Sl8fPC+qk41a+Nm6n+1mRQ== -"@gitlab/ui@52.6.0": - version "52.6.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-52.6.0.tgz#3a2a8a1640dd92013784281929ecde37518de433" - integrity sha512-1s2LzOJWEGm0ik3NtjC3IE9wTA/1JlRnlTP/lpVD1HzHeQW9bgbdl1U/dNoZT5NTTcBoP4bdQusD+gdB5K7CfQ== +"@gitlab/ui@52.6.1": + version "52.6.1" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-52.6.1.tgz#542c32802c63d071a7bcfa737d984fa66c53ea47" + integrity sha512-k0R7wLHiI3UoEEMpGK/CFhZNBwsvyxk6OiALYa0yZ5PAiYokEDQE96G3VshG3qc9wTzfIw6KSh6I20SU60tVsQ== dependencies: "@popperjs/core" "^2.11.2" bootstrap-vue "2.20.1" |