diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-08 12:13:04 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-08 12:13:04 +0000 |
commit | 886ecba0bd2d964504b43303a39cfa2386f0feed (patch) | |
tree | e814b9f24f3df16bc1a8c8725a168fac3844d719 | |
parent | cb09086128f2923126d009a88b478ff3919c8309 (diff) | |
download | gitlab-ce-886ecba0bd2d964504b43303a39cfa2386f0feed.tar.gz |
Add latest changes from gitlab-org/gitlab@master
111 files changed, 1260 insertions, 323 deletions
diff --git a/.ruby-version b/.ruby-version index a4dd9dba4fb..a603bb50a29 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.4 +2.7.5 diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index a0263e681f1..de82148a9ff 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -3ef55853e9e161204464868390d97d1a1577042d +fe6bcc9ca347b59714c46adf65d100dd93abde52 diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index f71c896d82f..f0ef55f73eb 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -62,7 +62,7 @@ const createFlashEl = (message, type) => ` </div> `; -const removeFlashClickListener = (flashEl, fadeTransition) => { +const addDismissFlashClickListener = (flashEl, fadeTransition) => { // There are some flash elements which do not have a closeEl. // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); @@ -113,7 +113,7 @@ const createFlash = function createFlash({ } } - removeFlashClickListener(flashEl, fadeTransition); + addDismissFlashClickListener(flashEl, fadeTransition); flashContainer.classList.add('gl-display-block'); @@ -130,9 +130,8 @@ const createFlash = function createFlash({ export { createFlash as default, - createAction, hideFlash, - removeFlashClickListener, + addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, }; diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js index 1bb5e214c2e..cce903d388d 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/issuable/issuable_template_selector.js @@ -1,9 +1,7 @@ -/* eslint-disable no-useless-return */ - import $ from 'jquery'; +import TemplateSelector from '~/blob/template_selector'; import { __ } from '~/locale'; import Api from '../api'; -import TemplateSelector from '../blob/template_selector'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { @@ -109,7 +107,5 @@ export default class IssuableTemplateSelector extends TemplateSelector { } else { this.setEditorContent(this.currentTemplate, { skipFocus: false }); } - - return; } } diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/issuable/issuable_template_selectors.js index 443b3084113..92f825e55d3 100644 --- a/app/assets/javascripts/templates/issuable_template_selectors.js +++ b/app/assets/javascripts/issuable/issuable_template_selectors.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new, class-methods-use-this */ - import $ from 'jquery'; import IssuableTemplateSelector from './issuable_template_selector'; @@ -10,6 +8,8 @@ export default class IssuableTemplateSelectors { this.$dropdowns.each((i, dropdown) => { const $dropdown = $(dropdown); + + // eslint-disable-next-line no-new new IssuableTemplateSelector({ pattern: /(\.md)/, data: $dropdown.data('data'), @@ -21,6 +21,7 @@ export default class IssuableTemplateSelectors { }); } + // eslint-disable-next-line class-methods-use-this initEditor() { const editor = $('.markdown-area'); // Proxy ace-editor's .setValue to jQuery's .val diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/issues/filtered_search_service_desk.js index bec207aa439..bec207aa439 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js +++ b/app/assets/javascripts/issues/filtered_search_service_desk.js diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/issues/form.js index adccdda3475..20a8c251304 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/issues/form.js @@ -8,7 +8,7 @@ import initSuggestions from '~/issues/suggestions'; import initIssuableTypeSelector from '~/issues/type_selector'; import LabelsSelect from '~/labels/labels_select'; import MilestoneSelect from '~/milestones/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new ShortcutsNavigation(); diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js new file mode 100644 index 00000000000..1901802c11c --- /dev/null +++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search_service_desk'; + +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issues/issue.js index 1e053d7daaa..c471875654b 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import createFlash from './flash'; -import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants'; -import axios from './lib/utils/axios_utils'; -import { addDelimiter } from './lib/utils/text_utility'; -import { __ } from './locale'; +import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import createFlash from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import axios from '~/lib/utils/axios_utils'; +import { addDelimiter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; export default class Issue { constructor() { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 9613246d6a6..9613246d6a6 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/issues/show.js index 5a48f463acc..33b1c47b4fe 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/issues/show.js @@ -2,7 +2,7 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/issuable/init_issuable_sidebar'; import { IssuableType } from '~/vue_shared/issuable/show/constants'; -import Issue from '~/issue'; +import Issue from '~/issues/issue'; import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; import { parseIssuableData } from '~/issues/show/utils/parse_data'; diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue index 45a05a97d6e..9ce49b65a1a 100644 --- a/app/assets/javascripts/issues/show/components/fields/description_template.vue +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default { components: { diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 73dba056e85..516a48aaa5b 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils'; import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { sortOrderMap, diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e422d9b1a32..251086363c7 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -16,7 +16,7 @@ import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; import { initHeaderSearchApp } from '~/header_search'; import initAlertHandler from './alert_handler'; -import { removeFlashClickListener } from './flash'; +import { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { logHelloDeferred } from './lib/logger/hello_deferred'; @@ -259,7 +259,7 @@ if (flashContainer && flashContainer.children.length) { flashContainer .querySelectorAll('.flash-alert, .flash-notice, .flash-success') .forEach((flashEl) => { - removeFlashClickListener(flashEl); + addDismissFlashClickListener(flashEl); }); } diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index f36b9fdc60a..d0903ad53bc 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -1,5 +1,5 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 623e665ff86..966d55e5587 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,7 +1,7 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index a75b68873ef..4633eaef8f9 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -1,6 +1,6 @@ import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../../issues/show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index e35a48e3474..d34536015e0 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -4,7 +4,7 @@ import initCsvImportExportButtons from '~/issuable/init_csv_import_export_button import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import IssuableIndex from '~/issuable/issuable_index'; import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index d906c579697..69639d17f8a 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,14 +1,7 @@ import { mountIssuablesListApp } from '~/issues_list'; -import FilteredSearchServiceDesk from './filtered_search'; +import { initFilteredSearchServiceDesk } from '~/issues/init_filtered_search_service_desk'; -const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, -); - -if (document.querySelector('.filtered-search')) { - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); -} +initFilteredSearchServiceDesk(); if (gon.features?.vueIssuablesList) { mountIssuablesListApp(); diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 1282d2aa303..d0b1942f2a4 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,7 +1,7 @@ import { store } from '~/notes/stores'; import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(store); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 49197881731..ebf7c266482 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -7,7 +7,7 @@ import Diff from '~/diff'; import GLForm from '~/gl_form'; import LabelsSelect from '~/labels/labels_select'; import MilestoneSelect from '~/milestones/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new Diff(); diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index cea95645fa4..f3fa4526999 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -157,11 +157,18 @@ export default { }, canLock() { const { pushCode, downloadCode } = this.project.userPermissions; + const currentUsername = window.gon?.current_username; + + if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) { + return false; + } return pushCode && downloadCode; }, - isLocked() { - return this.project.pathLocks.nodes.some((node) => node.path === this.path); + pathLockedByUser() { + const pathLock = this.project.pathLocks.nodes.find((node) => node.path === this.path); + + return pathLock ? pathLock.user : null; }, showForkSuggestion() { const { createMergeRequestIn, forkProject } = this.project.userPermissions; @@ -270,7 +277,7 @@ export default { :can-push-to-branch="blobInfo.canCurrentUserPushToBranch" :empty-repo="project.repository.empty" :project-path="projectPath" - :is-locked="isLocked" + :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" /> </template> diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 1679d6f9f5d..45d1ba80917 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -11,6 +11,10 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { nodes { id path + user { + id + username + } } } repository { diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 9c80506549e..bc318262b27 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -156,27 +156,23 @@ export const securityFeatures = [ // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 canEnableByMergeRequest: true, }, - ...(gon?.features?.configureIacScanningViaMr - ? [ - { - name: SAST_IAC_NAME, - shortName: SAST_IAC_SHORT_NAME, - description: SAST_IAC_DESCRIPTION, - helpPath: SAST_IAC_HELP_PATH, - configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST_IAC, + { + name: SAST_IAC_NAME, + shortName: SAST_IAC_SHORT_NAME, + description: SAST_IAC_DESCRIPTION, + helpPath: SAST_IAC_HELP_PATH, + configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST_IAC, - // This field is currently hardcoded because SAST IaC is always available. - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 - available: true, + // This field is currently hardcoded because SAST IaC is always available. + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 + available: true, - // This field will eventually come from the backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, - }, - ] - : []), + // This field will eventually come from the backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, + }, { name: DAST_NAME, shortName: DAST_SHORT_NAME, @@ -278,21 +274,17 @@ export const featureToMutationMap = { }, }), }, - ...(gon?.features?.configureIacScanningViaMr - ? { - [REPORT_TYPE_SAST_IAC]: { - mutationId: 'configureSastIac', - getMutationPayload: (projectPath) => ({ - mutation: configureSastIacMutation, - variables: { - input: { - projectPath, - }, - }, - }), + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, }, - } - : {}), + }, + }), + }, [REPORT_TYPE_SECRET_DETECTION]: { mutationId: 'configureSecretDetection', getMutationPayload: (projectPath) => ({ diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 5dc93476120..86e46016534 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -5,7 +5,7 @@ import { GlLoadingIcon, GlTooltip, GlSprintf, - GlLink, + GlButton, } from '@gitlab/ui'; import createFlash from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; @@ -20,7 +20,7 @@ export default { GlSprintf, GlDropdown, GlDropdownItem, - GlLink, + GlButton, SeverityToken, }, inject: ['canUpdate'], @@ -150,23 +150,25 @@ export default { <div class="hide-collapsed"> <p - class="gl-line-height-20 gl-mb-0 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" + class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" > {{ $options.i18n.SEVERITY }} - <gl-link + <gl-button v-if="canUpdate" + category="tertiary" + size="small" data-testid="editButton" - href="#" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ $options.i18n.EDIT }} - </gl-link> + </gl-button> </p> <gl-dropdown :class="dropdownClass" block + :header-text="__('Assign severity')" :text="selectedItem.label" toggle-class="dropdown-menu-toggle gl-mb-2" @keydown.esc.native="hideDropdown" diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 86580744ccc..a49ddac8c89 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -79,6 +79,20 @@ export default class SidebarMediator { }), ); } else { + const currentUserId = gon.current_user_id; + + if (currentUserId !== user.id) { + const currentUserReviewerOrAssignee = isReviewer + ? this.store.findReviewer({ id: currentUserId }) + : this.store.findAssignee({ id: currentUserId }); + + if (currentUserReviewerOrAssignee?.attention_requested) { + // Update current users attention_requested state + this.store.updateReviewer(currentUserId, 'attention_requested'); + this.store.updateAssignee(currentUserId, 'attention_requested'); + } + } + toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); } diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index c24318cb9ad..489d4afa41f 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -220,16 +220,17 @@ export default { class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" > {{ __('Assignee') }} - <a + <gl-button v-if="isEditable" ref="editButton" - class="btn-link" - href="#" + category="tertiary" + size="small" + class="gl-text-black-normal!" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ __('Edit') }} - </a> + </gl-button> </p> <gl-dropdown diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index eaa5fc5af04..c512585b980 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -100,7 +100,8 @@ export default { <gl-button v-if="isEditable" class="gl-text-black-normal!" - variant="link" + category="tertiary" + size="small" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue new file mode 100644 index 00000000000..5e9e50a94f0 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -0,0 +1,71 @@ +<script> +import { escape } from 'lodash'; +import { __ } from '~/locale'; + +export default { + props: { + initialTitle: { + type: String, + required: false, + default: '', + }, + placeholder: { + type: String, + required: false, + default: __('Add a title...'), + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + title: this.initialTitle, + }; + }, + methods: { + getSanitizedTitle(inputEl) { + const { innerText } = inputEl; + return escape(innerText); + }, + handleBlur({ target }) { + this.$emit('title-changed', this.getSanitizedTitle(target)); + }, + handleInput({ target }) { + this.$emit('title-input', this.getSanitizedTitle(target)); + }, + handleSubmit() { + this.$refs.titleEl.blur(); + }, + }, +}; +</script> + +<template> + <h2 + class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block" + :class="{ 'gl-cursor-not-allowed': disabled }" + data-testid="title" + aria-labelledby="item-title" + > + <span + id="item-title" + ref="titleEl" + role="textbox" + :aria-label="__('Title')" + :data-placeholder="placeholder" + :contenteditable="!disabled" + class="gl-pseudo-placeholder" + @blur="handleBlur" + @keyup="handleInput" + @keydown.enter.exact="handleSubmit" + @keydown.ctrl.u.prevent + @keydown.meta.u.prevent + @keydown.ctrl.b.prevent + @keydown.meta.b.prevent + >{{ title }}</span + > + </h2> +</template> diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js index bab0147f4b8..8005f334314 100644 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ b/app/assets/javascripts/work_items/graphql/resolvers.js @@ -29,5 +29,30 @@ export const resolvers = { workItem, }; }, + + updateWorkItem(_, { input }, { cache }) { + const workItemTitle = { + __typename: 'TitleWidget', + type: 'TITLE', + enabled: true, + contentText: input.title, + }; + const workItem = { + __typename: 'WorkItem', + type: 'FEATURE', + id: input.id, + widgets: { + __typename: 'WorkItemWidgetConnection', + nodes: [workItemTitle], + }, + }; + + cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } }); + + return { + __typename: 'UpdateWorkItemPayload', + workItem, + }; + }, }, }; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 2a9cd52c18e..dd7ea7c26cc 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -37,14 +37,24 @@ type CreateWorkItemInput { title: String! } +type UpdateWorkItemInput { + id: ID! + title: String +} + type CreateWorkItemPayload { workItem: WorkItem! } +type UpdateWorkItemPayload { + workItem: WorkItem! +} + extend type Query { workItem(id: ID!): WorkItem! } extend type Mutation { createWorkItem(input: CreateWorkItemInput!): CreateWorkItemPayload! + updateWorkItem(input: UpdateWorkItemInput!): UpdateWorkItemPayload! } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql new file mode 100644 index 00000000000..fc140954fbe --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation updateWorkItem($input: UpdateWorkItemInput) { + updateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on TitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 190e50f903c..43cbee019c1 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -2,10 +2,13 @@ import { GlButton, GlAlert } from '@gitlab/ui'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; +import ItemTitle from '../components/item_title.vue'; + export default { components: { GlButton, GlAlert, + ItemTitle, }, data() { return { @@ -37,6 +40,9 @@ export default { this.error = true; } }, + handleTitleInput(title) { + this.title = title; + }, }, }; </script> @@ -46,15 +52,7 @@ export default { <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ __('Something went wrong when creating a work item. Please try again') }}</gl-alert> - <label for="title" class="gl-sr-only">{{ __('Title') }}</label> - <input - id="title" - v-model.trim="title" - type="text" - class="gl-font-size-h-display gl-font-weight-bold gl-my-5 gl-border-none gl-w-full gl-pl-2" - data-testid="title-input" - :placeholder="__('Add a title…')" - /> + <item-title data-testid="title-input" @title-input="handleTitleInput" /> <div class="gl-bg-gray-10 gl-py-5 gl-px-6"> <gl-button variant="confirm" diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 493ee0aba01..479274baf3a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,8 +1,16 @@ <script> +import { GlAlert } from '@gitlab/ui'; import workItemQuery from '../graphql/work_item.query.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { widgetTypes } from '../constants'; +import ItemTitle from '../components/item_title.vue'; + export default { + components: { + ItemTitle, + GlAlert, + }, props: { id: { type: String, @@ -12,6 +20,7 @@ export default { data() { return { workItem: null, + error: false, }; }, apollo: { @@ -29,20 +38,39 @@ export default { return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); }, }, + methods: { + async updateWorkItem(title) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.id, + title, + }, + }, + }); + } catch { + this.error = true; + } + }, + }, }; </script> <template> <section> + <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ + __('Something went wrong while updating work item. Please try again') + }}</gl-alert> <!-- Title widget placeholder --> <div> - <h2 + <item-title v-if="titleWidgetData" - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5" + :initial-title="titleWidgetData.contentText" data-testid="title" - > - {{ titleWidgetData.contentText }} - </h2> + @title-changed="updateWorkItem" + /> </div> </section> </template> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 354d2737894..36a0d3ca3ca 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -479,6 +479,13 @@ img.emoji { border-top: 1px solid $border-color; } +.gl-pseudo-placeholder:empty::before { + content: attr(data-placeholder); + font-weight: $gl-font-weight-normal; + color: $gl-text-color-secondary; + cursor: text; +} + /** 🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 0680de32e86..9914e573247 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -1831,6 +1831,9 @@ body.gl-dark .navbar-gitlab .search form:active { background-color: var(--gray-100); box-shadow: inset 0 0 0 1px var(--blue-200); } +body.gl-dark .navbar-gitlab .search form .search-input { + color: var(--gl-text-color); +} body.gl-dark { --gray-10: #1f1f1f; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 2b5751cab36..bb9a9cf0497 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -122,6 +122,10 @@ body.gl-dark { background-color: var(--gray-100); box-shadow: inset 0 0 0 1px var(--blue-200); } + + .search-input { + color: var(--gl-text-color); + } } } } diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 1696eef09a8..dc5b22e1606 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -9,7 +9,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController after_action :verify_known_sign_in - protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true + protect_from_forgery except: [:kerberos, :saml, :cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true feature_category :authentication_and_authorization diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 392a6afc636..6f12e3940dd 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -23,7 +23,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController def setup_walkthrough_experiment experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e| e.candidate {} - e.record! + e.publish_to_database end end end diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb index d5675618da5..177533b89c8 100644 --- a/app/controllers/projects/learn_gitlab_controller.rb +++ b/app/controllers/projects/learn_gitlab_controller.rb @@ -21,7 +21,7 @@ class Projects::LearnGitlabController < Projects::ApplicationController experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e| e.candidate {} - e.record! + e.publish_to_database end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index d49539c56fb..79935300fb6 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -312,7 +312,7 @@ class Projects::PipelinesController < Projects::ApplicationController e.control {} e.candidate {} - e.record! + e.publish_to_database end end @@ -325,7 +325,7 @@ class Projects::PipelinesController < Projects::ApplicationController e.control {} e.candidate {} - e.record! + e.publish_to_database end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 36f69028d6a..04c40826d13 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml) push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml) push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) + push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) end layout :determine_layout diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 32dd6960929..bf8d43de6aa 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -13,7 +13,6 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp super publish_to_client - publish_to_database if @record end def publish_to_client @@ -25,6 +24,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp end def publish_to_database + ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore') + return unless should_track? # if the context contains a namespace, group, project, user, or actor @@ -36,10 +37,6 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp Experiment.add_subject(name, variant: variant_name || :control, subject: subject) end - def record! - @record = true - end - def control_behavior # define a default nil control behavior so we can omit it when not needed end diff --git a/app/experiments/new_project_readme_content_experiment.rb b/app/experiments/new_project_readme_content_experiment.rb index d9f0fb3b93e..1de7632268d 100644 --- a/app/experiments/new_project_readme_content_experiment.rb +++ b/app/experiments/new_project_readme_content_experiment.rb @@ -6,7 +6,7 @@ class NewProjectReadmeContentExperiment < ApplicationExperiment # rubocop:disabl def run_with(project, variant: nil) @project = project - record! + publish_to_database run(variant) end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index a0f00ddc3c6..8ad313758e5 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -96,7 +96,7 @@ module Types description: 'Rebase commit SHA of the merge request.' field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true, description: 'Indicates if there is a rebase currently in progress for the merge request.' - field :default_merge_commit_message, GraphQL::Types::String, null: true, + field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Default merge commit message of the merge request.' field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true, description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.', diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 2032b7e8bb7..c1a74382d46 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -86,6 +86,17 @@ module AuthHelper auth_providers.select { |provider| form_based_provider?(provider) } end + def saml_providers + auth_providers.select { |provider| auth_strategy_class(provider) == 'OmniAuth::Strategies::SAML' } + end + + def auth_strategy_class(provider) + config = Gitlab::Auth::OAuth::Provider.config_for(provider) + return if config.nil? || config['args'].blank? + + config.args['strategy_class'] + end + def any_form_based_providers_enabled? form_based_providers.any? { |provider| form_enabled_for_sign_in?(provider) } end diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index f04ac6f1722..98490a13351 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -4,6 +4,8 @@ module Clusters class Agent < ApplicationRecord self.table_name = 'cluster_agents' + INACTIVE_AFTER = 1.hour.freeze + belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project @@ -33,5 +35,9 @@ module Clusters def has_access_to?(requested_project) requested_project == project end + + def active? + agent_tokens.where("last_used_at > ?", INACTIVE_AFTER.ago).exists? + end end end diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index 27a3cd8d13d..87dba50cd69 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -28,8 +28,12 @@ module Clusters cache_attributes(track_values) - # Use update_column so updated_at is skipped - update_columns(track_values) if can_update_track_values? + if can_update_track_values? + log_activity_event!(track_values[:last_used_at]) unless agent.active? + + # Use update_column so updated_at is skipped + update_columns(track_values) + end end private @@ -44,5 +48,14 @@ module Clusters real_last_used_at.nil? || (Time.current - real_last_used_at) >= last_used_at_max_age end + + def log_activity_event!(recorded_at) + agent.activity_events.create!( + kind: :agent_connected, + level: :info, + recorded_at: recorded_at, + agent_token: self + ) + end end end diff --git a/app/models/clusters/agents/activity_event.rb b/app/models/clusters/agents/activity_event.rb index 668aba74821..5d9c885c923 100644 --- a/app/models/clusters/agents/activity_event.rb +++ b/app/models/clusters/agents/activity_event.rb @@ -18,7 +18,10 @@ module Clusters nullify_if_blank :detail enum kind: { - token_created: 0 + token_created: 0, + token_revoked: 1, + agent_connected: 2, + agent_disconnected: 3 }, _prefix: true enum level: { diff --git a/app/models/member.rb b/app/models/member.rb index a2d53f006d4..90fb281abf4 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,6 +52,7 @@ class Member < ApplicationRecord message: _('project bots cannot be added to other groups / projects') }, if: :project_bot? + validate :access_level_inclusion scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -382,6 +383,12 @@ class Member < ApplicationRecord private + def access_level_inclusion + return if access_level.in?(Gitlab::Access.all_values) + + errors.add(:access_level, "is not included in the list") + end + def send_invite # override in subclass end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 9062a405218..1ad4cb6d368 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -6,6 +6,7 @@ class GroupMember < Member include CreatedAtFilterable SOURCE_TYPE = 'Namespace' + SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze belongs_to :group, foreign_key: 'source_id' alias_attribute :namespace_id, :source_id @@ -13,9 +14,7 @@ class GroupMember < Member # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE - validates :source_type, format: { with: /\ANamespace\z/ } - validates :access_level, presence: true - validate :access_level_inclusion + validates :source_type, format: { with: SOURCE_TYPE_FORMAT } default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope @@ -65,12 +64,6 @@ class GroupMember < Member super end - def access_level_inclusion - return if access_level.in?(Gitlab::Access.all_values) - - errors.add(:access_level, "is not included in the list") - end - def send_invite run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) } diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 89b72508e84..6fc665cb87a 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -3,6 +3,7 @@ class ProjectMember < Member extend ::Gitlab::Utils::Override SOURCE_TYPE = 'Project' + SOURCE_TYPE_FORMAT = /\AProject\z/.freeze belongs_to :project, foreign_key: 'source_id' @@ -10,8 +11,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE - validates :source_type, format: { with: /\AProject\z/ } - validates :access_level, inclusion: { in: Gitlab::Access.values } + validates :source_type, format: { with: SOURCE_TYPE_FORMAT } default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope scope :in_project, ->(project) { where(source_id: project.id) } @@ -92,6 +92,13 @@ class ProjectMember < Member private + override :access_level_inclusion + def access_level_inclusion + return if access_level.in?(Gitlab::Access.values) + + errors.add(:access_level, "is not included in the list") + end + override :refresh_member_authorized_projects def refresh_member_authorized_projects(blocking:) return unless user diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 62e599e3e27..3f39b2742c6 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -14,6 +14,7 @@ module MergeRequests create_approval_note(merge_request) mark_pending_todos_as_done(merge_request) execute_approval_hooks(merge_request, current_user) + remove_attention_requested(merge_request, current_user) merge_request_activity_counter.track_approve_mr_action(user: current_user) success diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 0a652c58aab..d744881549a 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -58,6 +58,8 @@ module MergeRequests new_reviewers = merge_request.reviewers - old_reviewers merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) + + remove_attention_requested(merge_request, current_user) end def cleanup_environments(merge_request) @@ -238,6 +240,18 @@ module MergeRequests Milestones::MergeRequestsCountService.new(milestone).delete_cache end + + def remove_all_attention_requests(merge_request) + return unless merge_request.attention_requested_enabled? + + ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute + end + + def remove_attention_requested(merge_request, user) + return unless merge_request.attention_requested_enabled? + + ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute + end end end diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb new file mode 100644 index 00000000000..dd2ff741ba6 --- /dev/null +++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module MergeRequests + class BulkRemoveAttentionRequestedService < MergeRequests::BaseService + attr_accessor :merge_request + + def initialize(project:, current_user:, merge_request:) + super(project: project, current_user: current_user) + + @merge_request = merge_request + end + + def execute + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + merge_request.merge_request_assignees.update_all(state: :reviewed) + merge_request.merge_request_reviewers.update_all(state: :reviewed) + + success + end + end +end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index f83b14c7269..e9b253129b4 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -17,6 +17,7 @@ module MergeRequests create_note(merge_request) notification_service.async.close_mr(merge_request, current_user) todo_service.close_merge_request(merge_request, current_user) + remove_all_attention_requests(merge_request) execute_hooks(merge_request, 'close') invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 87cd6544406..1d9f7ab59f4 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -22,6 +22,8 @@ module MergeRequests merge_request_activity_counter.track_assignees_changed_action(user: current_user) execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] + + remove_attention_requested(merge_request, current_user) end private diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index ea3071b3c2d..e475b57e4a2 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -28,6 +28,7 @@ module MergeRequests notification_service.merge_mr(merge_request, current_user) invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches + remove_all_attention_requests(merge_request) delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb new file mode 100644 index 00000000000..b727c24415e --- /dev/null +++ b/app/services/merge_requests/remove_attention_requested_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module MergeRequests + class RemoveAttentionRequestedService < MergeRequests::BaseService + attr_accessor :merge_request, :user + + def initialize(project:, current_user:, merge_request:, user:) + super(project: project, current_user: current_user) + + @merge_request = merge_request + @user = user + end + + def execute + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + if reviewer || assignee + update_state(reviewer) + update_state(assignee) + + success + else + error("User is not a reviewer or assignee of the merge request") + end + end + + private + + def assignee + merge_request.find_assignee(user) + end + + def reviewer + merge_request.find_reviewer(user) + end + + def update_state(reviewer_or_assignee) + reviewer_or_assignee&.update(state: :reviewed) + end + end +end diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb index fd24e87454c..d9f81ac310f 100644 --- a/app/services/merge_requests/toggle_attention_requested_service.rb +++ b/app/services/merge_requests/toggle_attention_requested_service.rb @@ -21,6 +21,10 @@ module MergeRequests if reviewer&.attention_requested? || assignee&.attention_requested? create_attention_request_note notity_user + + if current_user.id != user.id + remove_attention_requested(merge_request, current_user) + end else create_remove_attention_request_note end diff --git a/app/services/namespaces/invite_team_email_service.rb b/app/services/namespaces/invite_team_email_service.rb index 45975d1953a..78edc205990 100644 --- a/app/services/namespaces/invite_team_email_service.rb +++ b/app/services/namespaces/invite_team_email_service.rb @@ -29,13 +29,12 @@ module Namespaces return if email_for_track_sent_to_user? experiment(:invite_team_email, group: group) do |e| + e.publish_to_database e.candidate do send_email(user, group) sent_email_records.add(user, track, series) sent_email_records.save! end - - e.record! end end diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index 5c0044ed825..73a437a0702 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -6,11 +6,13 @@ - providers.each do |provider| - unlink_allowed = unlink_provider_allowed?(provider) - link_allowed = link_provider_allowed?(provider) + - has_icon = provider_has_icon?(provider) - if unlink_allowed || link_allowed - if auth_active?(provider) - if unlink_allowed = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do - .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + - if has_icon + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) .gl-button-text = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) } - else @@ -19,7 +21,8 @@ = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) } - elsif link_allowed = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do - .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + - if has_icon + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) .gl-button-text = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) } = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/config/feature_flags/development/configure_iac_scanning_via_mr.yml b/config/feature_flags/development/configure_iac_scanning_via_mr.yml deleted file mode 100644 index cef22644b8f..00000000000 --- a/config/feature_flags/development/configure_iac_scanning_via_mr.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: configure_iac_scanning_via_mr -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73155 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/343966 -milestone: '14.5' -type: development -group: group::static analysis -default_enabled: true diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index b16ea44f6c0..36b5c857cf8 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -2128,9 +2128,7 @@ from the latest pipeline that completed successfully. **Possible inputs**: -- `needs:project`: A full project path, including namespace and group. If the - project is in the same group or namespace, you can omit them from the `project` - keyword. For example: `project: group/project-name` or `project: project-name`. +- `needs:project`: A full project path, including namespace and group. - `job`: The job to download artifacts from. - `ref`: The ref to download artifacts from. - `artifacts`: Must be `true` to download artifacts. diff --git a/doc/development/experiment_guide/gitlab_experiment.md b/doc/development/experiment_guide/gitlab_experiment.md index d81ac5372e3..288823bb41f 100644 --- a/doc/development/experiment_guide/gitlab_experiment.md +++ b/doc/development/experiment_guide/gitlab_experiment.md @@ -394,26 +394,6 @@ You may be asked from time to time to track a specific record ID in experiments. The approach is largely up to the PM and engineer creating the implementation. No recommendations are provided here at this time. -### Record experiment subjects - -Snowplow tracking of identifiable users or groups is prohibited, but you can still -determine if an experiment is successful or not. We're allowed to record the ID of -a namespace, project or user in our database. Therefore, we can tell the experiment -to record their ID together with the assigned experiment variant in the -`experiment_subjects` database table for later analysis. - -For the recording to work, the experiment's context must include a `namespace`, -`group`, `project`, `user`, or `actor`. - -To record the experiment subject when you first assign a variant, call `record!` in -the experiment's block: - -```ruby -experiment(:pill_color, actor: current_user) do |e| - e.record! -end -``` - ## Test with RSpec This gem provides some RSpec helpers and custom matchers. These are in flux as of GitLab 13.10. diff --git a/doc/integration/saml.md b/doc/integration/saml.md index d5f01aeb3b4..70d6932b9eb 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -163,6 +163,74 @@ On the sign in page there should now be a SAML button below the regular sign in Click the icon to begin the authentication process. If everything goes well the user is returned to GitLab and signed in. +### Use multiple SAML identity providers + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14361) in GitLab 14.6. + +You can configure GitLab to use multiple SAML identity providers if: + +- Each provider has a unique name set that matches a name set in `args`. +- The providers' names are: + - Used in OmniAuth configuration for properties based on the provider name. For example, `allowBypassTwoFactor`, `allowSingleSignOn`, and + `syncProfileFromProvider`. + - Used for association to each existing user as an additional identity. +- The `assertion_consumer_service_url` matches the provider name. +- The `strategy_class` is explicitly set because it cannot be inferred from provider name. + +Example multiple providers configuration for Omnibus GitLab: + +```ruby +gitlab_rails['omniauth_providers'] = [ + { + name: 'saml_1', + args: { + name: 'saml_1', # This is mandatory and must match the provider name + strategy_class: 'OmniAuth::Strategies::SAML' + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_1/callback', # URL must match the name of the provider + ... # Put here all the required arguments similar to a single provider + }, + label: 'Provider 1' # Differentiate the two buttons and providers in the UI + }, + { + name: 'saml_2', + args: { + name: 'saml_2', # This is mandatory and must match the provider name + strategy_class: 'OmniAuth::Strategies::SAML' + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_2/callback', # URL must match the name of the provider + ... # Put here all the required arguments similar to a single provider + }, + label: 'Provider 2' # Differentiate the two buttons and providers in the UI + } +] +``` + +Example providers configuration for installations from source: + +```yaml +omniauth: + providers: + - { + name: 'saml_1', + args: { + name: 'saml_1', # This is mandatory and must match the provider name + strategy_class: 'OmniAuth::Strategies::SAML', + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_1/callback', # URL must match the name of the provider + ... # Put here all the required arguments similar to a single provider + }, + label: 'Provider 1' # Differentiate the two buttons and providers in the UI + } + - { + name: 'saml_2', + args: { + name: 'saml_2', # This is mandatory and must match the provider name + strategy_class: 'OmniAuth::Strategies::SAML', + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_2/callback', # URL must match the name of the provider + ... # Put here all the required arguments similar to a single provider + }, + label: 'Provider 2' # Differentiate the two buttons and providers in the UI + } +``` + ### Notes on configuring your identity provider When configuring a SAML app on the IdP, you need at least: diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md index 243ff8ad76b..fc707c266f9 100644 --- a/doc/user/admin_area/settings/account_and_limit_settings.md +++ b/doc/user/admin_area/settings/account_and_limit_settings.md @@ -194,12 +194,13 @@ To set a limit on how long these sessions are valid: ## Limit the lifetime of SSH keys **(ULTIMATE SELF)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6 [with a flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. Disabled by default. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6 [with a flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. Disabled by default. +> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/346753) in GitLab 14.6. FLAG: -On self-managed GitLab, by default this feature is not available. To make it available, -ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. -On GitLab.com, this feature is not available. The feature is not ready for production use. +On self-managed GitLab, by default this feature is available. To hide the feature, +ask an administrator to [disable the feature flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. +On GitLab.com, this feature is not available. Users can optionally specify a lifetime for [SSH keys](../../../ssh/index.md). diff --git a/doc/user/project/merge_requests/commit_templates.md b/doc/user/project/merge_requests/commit_templates.md index abe17f03288..bffb66755e0 100644 --- a/doc/user/project/merge_requests/commit_templates.md +++ b/doc/user/project/merge_requests/commit_templates.md @@ -65,6 +65,9 @@ GitLab creates a squash commit message with this template: ## Supported variables in commit templates +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/20263) in GitLab 14.5. +> - [Added](https://gitlab.com/gitlab-org/gitlab/-/issues/346805) `first_commit` and `first_multiline_commit` variables in GitLab 14.6. + Commit message templates support these variables: | Variable | Description | Output example | @@ -73,8 +76,10 @@ Commit message templates support these variables: | `%{target_branch}` | The name of the branch that the changes are applied to. | `main` | | `%{title}` | Title of the merge request. | `Fix tests and translations` | | `%{issues}` | String with phrase `Closes <issue numbers>`. Contains all issues mentioned in the merge request description that match [issue closing patterns](../issues/managing_issues.md#closing-issues-automatically). Empty if no issues are mentioned. | `Closes #465, #190 and #400` | -| `%{description}` | Description of the merge request. | `Merge request description.<br>Can be multiline.` | +| `%{description}` | Description of the merge request. | `Merge request description.`<br>`Can be multiline.` | | `%{reference}` | Reference to the merge request. | `group-name/project-name!72359` | +| `%{first_commit}` | Full message of the first commit in merge request diff. | `Update README.md` | +| `%{first_multiline_commit}` | Full message of the first commit that's not a merge commit and has more than one line in message body. Merge Request title if all commits aren't multiline. | `Update README.md`<br><br>`Improved project description in readme file.` | Empty variables that are the only word in a line are removed, along with all newline characters preceding it. diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb index cf4b2348458..0709a8c2036 100644 --- a/lib/api/discussions.rb +++ b/lib/api/discussions.rb @@ -8,6 +8,13 @@ module API before { authenticate! } + urgency :low, [ + '/projects/:id/merge_requests/:noteable_id/discussions', + '/projects/:id/merge_requests/:noteable_id/discussions/:discussion_id', + '/projects/:id/merge_requests/:noteable_id/discussions/:discussion_id/notes', + '/projects/:id/merge_requests/:noteable_id/discussions/:discussion_id/notes/:note_id' + ] + Helpers::DiscussionsHelpers.feature_category_per_noteable_type.each do |noteable_type, feature_category| parent_type = noteable_type.parent_class.to_s.underscore noteables_str = noteable_type.to_s.underscore.pluralize diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb index 8fa7138af42..87623568a04 100644 --- a/lib/api/merge_request_diffs.rb +++ b/lib/api/merge_request_diffs.rb @@ -38,7 +38,7 @@ module API requires :version_id, type: Integer, desc: 'The ID of a merge request diff version' end - get ":id/merge_requests/:merge_request_iid/versions/:version_id" do + get ":id/merge_requests/:merge_request_iid/versions/:version_id", urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) present_cached merge_request.merge_request_diffs.find(params[:version_id]), with: Entities::MergeRequestDiffFull, cache_context: nil diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 12187e497ba..063fff4023a 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -134,7 +134,7 @@ module API use :merge_requests_params use :optional_scope_param end - get feature_category: :code_review do + get feature_category: :code_review, urgency: :low do authenticate! unless params[:scope] == 'all' validate_anonymous_search_access! if params[:search].present? merge_requests = find_merge_requests @@ -155,7 +155,7 @@ module API optional :non_archived, type: Boolean, desc: 'Return merge requests from non archived projects', default: true end - get ":id/merge_requests", feature_category: :code_review do + get ":id/merge_requests", feature_category: :code_review, urgency: :low do validate_anonymous_search_access! if declared_params[:search].present? merge_requests = find_merge_requests(group_id: user_group.id, include_subgroups: true) @@ -195,7 +195,7 @@ module API use :merge_requests_params optional :iids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'The IID array of merge requests' end - get ":id/merge_requests", feature_category: :code_review do + get ":id/merge_requests", feature_category: :code_review, urgency: :low do authorize! :read_merge_request, user_project validate_anonymous_search_access! if declared_params[:search].present? @@ -222,7 +222,7 @@ module API desc: 'The target project of the merge request defaults to the :id of the project' use :optional_params end - post ":id/merge_requests", feature_category: :code_review do + post ":id/merge_requests", feature_category: :code_review, urgency: :low do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20770') authorize! :create_merge_request_from, user_project @@ -244,7 +244,7 @@ module API params do requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' end - delete ":id/merge_requests/:merge_request_iid", feature_category: :code_review do + delete ":id/merge_requests/:merge_request_iid", feature_category: :code_review, urgency: :low do merge_request = find_project_merge_request(params[:merge_request_iid]) authorize!(:destroy_merge_request, merge_request) @@ -263,7 +263,7 @@ module API desc 'Get a single merge request' do success Entities::MergeRequest end - get ':id/merge_requests/:merge_request_iid', feature_category: :code_review do + get ':id/merge_requests/:merge_request_iid', feature_category: :code_review, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, @@ -279,7 +279,7 @@ module API desc 'Get the participants of a merge request' do success Entities::UserBasic end - get ':id/merge_requests/:merge_request_iid/participants', feature_category: :code_review do + get ':id/merge_requests/:merge_request_iid/participants', feature_category: :code_review, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) participants = ::Kaminari.paginate_array(merge_request.participants) @@ -290,7 +290,7 @@ module API desc 'Get the commits of a merge request' do success Entities::Commit end - get ':id/merge_requests/:merge_request_iid/commits', feature_category: :code_review do + get ':id/merge_requests/:merge_request_iid/commits', feature_category: :code_review, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) commits = @@ -371,7 +371,7 @@ module API desc 'Show the merge request changes' do success Entities::MergeRequestChanges end - get ':id/merge_requests/:merge_request_iid/changes', feature_category: :code_review do + get ':id/merge_requests/:merge_request_iid/changes', feature_category: :code_review, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, @@ -422,7 +422,7 @@ module API use :optional_params at_least_one_of(*::API::MergeRequests.update_params_at_least_one_of) end - put ':id/merge_requests/:merge_request_iid', feature_category: :code_review do + put ':id/merge_requests/:merge_request_iid', feature_category: :code_review, urgency: :low do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20772') merge_request = find_merge_request_with_access(params.delete(:merge_request_iid), :update_merge_request) @@ -454,7 +454,7 @@ module API optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' optional :squash, type: Grape::API::Boolean, desc: 'When true, the commits will be squashed into a single commit on merge' end - put ':id/merge_requests/:merge_request_iid/merge', feature_category: :code_review do + put ':id/merge_requests/:merge_request_iid/merge', feature_category: :code_review, urgency: :low do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/4796') merge_request = find_project_merge_request(params[:merge_request_iid]) @@ -524,7 +524,7 @@ module API params do optional :skip_ci, type: Boolean, desc: 'Do not create CI pipeline' end - put ':id/merge_requests/:merge_request_iid/rebase', feature_category: :code_review do + put ':id/merge_requests/:merge_request_iid/rebase', feature_category: :code_review, urgency: :low do merge_request = find_project_merge_request(params[:merge_request_iid]) authorize_push_to_merge_request!(merge_request) @@ -543,7 +543,7 @@ module API params do use :pagination end - get ':id/merge_requests/:merge_request_iid/closes_issues', feature_category: :code_review do + get ':id/merge_requests/:merge_request_iid/closes_issues', feature_category: :code_review, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) issues = ::Kaminari.paginate_array(merge_request.visible_closing_issues_for(current_user)) issues = paginate(issues) diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 656eaa2b2bb..7629f84cec2 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -7,6 +7,11 @@ module API before { authenticate! } + urgency :low, [ + '/projects/:id/merge_requests/:noteable_id/notes', + '/projects/:id/merge_requests/:noteable_id/notes/:note_id' + ] + Helpers::NotesHelpers.feature_category_per_noteable_type.each do |noteable_type, feature_category| parent_type = noteable_type.parent_class.to_s.underscore noteables_str = noteable_type.to_s.underscore.pluralize diff --git a/lib/api/resource_label_events.rb b/lib/api/resource_label_events.rb index 33589f6c393..cd56809f45a 100644 --- a/lib/api/resource_label_events.rb +++ b/lib/api/resource_label_events.rb @@ -24,7 +24,7 @@ module API use :pagination end - get ":id/#{eventables_str}/:eventable_id/resource_label_events", feature_category: feature_category do + get ":id/#{eventables_str}/:eventable_id/resource_label_events", feature_category: feature_category, urgency: :low do eventable = find_noteable(eventable_type, params[:eventable_id]) events = eventable.resource_label_events.inc_relations diff --git a/lib/api/resource_milestone_events.rb b/lib/api/resource_milestone_events.rb index c0483ca59c2..04d71faa56a 100644 --- a/lib/api/resource_milestone_events.rb +++ b/lib/api/resource_milestone_events.rb @@ -26,7 +26,7 @@ module API use :pagination end - get ":id/#{eventables_str}/:eventable_id/resource_milestone_events", feature_category: feature_category do + get ":id/#{eventables_str}/:eventable_id/resource_milestone_events", feature_category: feature_category, urgency: :low do eventable = find_noteable(eventable_type, params[:eventable_id]) events = ResourceMilestoneEventFinder.new(current_user, eventable).execute diff --git a/lib/api/resource_state_events.rb b/lib/api/resource_state_events.rb index 9b6f6a954b4..4b92f320d6f 100644 --- a/lib/api/resource_state_events.rb +++ b/lib/api/resource_state_events.rb @@ -25,7 +25,7 @@ module API use :pagination end - get ":id/#{eventable_name.pluralize}/:eventable_iid/resource_state_events", feature_category: feature_category do + get ":id/#{eventable_name.pluralize}/:eventable_iid/resource_state_events", feature_category: feature_category, urgency: :low do eventable = find_noteable(eventable_class, params[:eventable_iid]) events = ResourceStateEventFinder.new(current_user, eventable).execute diff --git a/lib/api/suggestions.rb b/lib/api/suggestions.rb index 7921700e365..0697169b49a 100644 --- a/lib/api/suggestions.rb +++ b/lib/api/suggestions.rb @@ -14,7 +14,7 @@ module API requires :id, type: String, desc: 'The suggestion ID' optional :commit_message, type: String, desc: "A custom commit message to use instead of the default generated message or the project's default message" end - put ':id/apply' do + put ':id/apply', urgency: :low do suggestion = Suggestion.find_by_id(params[:id]) if suggestion @@ -31,7 +31,7 @@ module API requires :ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: "An array of suggestion ID's" optional :commit_message, type: String, desc: "A custom commit message to use instead of the default generated message or the project's default message" end - put 'batch_apply' do + put 'batch_apply', urgency: :low do ids = params[:ids] suggestions = Suggestion.id_in(ids) diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml index f47c3decd3c..fddcc1492a8 100644 --- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @@ -54,8 +54,7 @@ variables: # KUBE_INGRESS_BASE_DOMAIN: domain.example.com # Allows Container-Scanning to correctly correlate image names when using Jobs/Build.gitlab-ci.yml - CI_APPLICATION_TAG: $CI_COMMIT_SHA - CS_DEFAULT_BRANCH_IMAGE: $CI_REGISTRY_IMAGE/$CI_DEFAULT_BRANCH:$CI_APPLICATION_TAG + CS_DEFAULT_BRANCH_IMAGE: $CI_REGISTRY_IMAGE/$CI_DEFAULT_BRANCH:$CI_COMMIT_SHA POSTGRES_USER: user POSTGRES_PASSWORD: testing-password diff --git a/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb b/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb index 5266afe297f..8ce203d4585 100644 --- a/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb +++ b/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb @@ -67,16 +67,7 @@ module Gitlab end def get_wal_locations(job) - job['dedup_wal_locations'] || job['wal_locations'] || legacy_wal_location(job) - end - - # Already scheduled jobs could still contain legacy database write location. - # TODO: remove this in the next iteration - # https://gitlab.com/gitlab-org/gitlab/-/issues/338213 - def legacy_wal_location(job) - wal_location = job['database_write_location'] || job['database_replica_location'] - - { ::Gitlab::Database::MAIN_DATABASE_NAME.to_sym => wal_location } if wal_location + job['dedup_wal_locations'] || job['wal_locations'] end def load_balancing_available?(worker_class) diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 9ad902efb3a..bb3ba1129fc 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -56,7 +56,6 @@ module Gitlab push_frontend_feature_flag(:security_auto_fix, default_enabled: false) push_frontend_feature_flag(:improved_emoji_picker, default_enabled: :yaml) push_frontend_feature_flag(:new_header_search, default_enabled: :yaml) - push_frontend_feature_flag(:configure_iac_scanning_via_mr, current_user, default_enabled: :yaml) push_frontend_feature_flag(:bootstrap_confirmation_modals, default_enabled: :yaml) end diff --git a/lib/gitlab/merge_requests/commit_message_generator.rb b/lib/gitlab/merge_requests/commit_message_generator.rb index c420385b7c1..0e9ec6f5cb3 100644 --- a/lib/gitlab/merge_requests/commit_message_generator.rb +++ b/lib/gitlab/merge_requests/commit_message_generator.rb @@ -35,7 +35,9 @@ module Gitlab "Closes #{closes_issues_references.to_sentence}" end, 'description' => ->(merge_request) { merge_request.description.presence || '' }, - 'reference' => ->(merge_request) { merge_request.to_reference(full: true) } + 'reference' => ->(merge_request) { merge_request.to_reference(full: true) }, + 'first_commit' => -> (merge_request) { merge_request.first_commit&.safe_message&.strip.presence || '' }, + 'first_multiline_commit' => -> (merge_request) { merge_request.first_multiline_commit&.safe_message&.strip.presence || merge_request.title } }.freeze PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map do |key| diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 79bd3329e20..6f4eeb23d3b 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -243,7 +243,9 @@ namespace :gitlab do # Only for development environments, # we execute pending data migrations inline for convenience. Rake::Task['db:migrate'].enhance do - Rake::Task['gitlab:db:execute_batched_migrations'].invoke if Rails.env.development? + if Rails.env.development? && Gitlab::Database::BackgroundMigration::BatchedMigration.table_exists? + Rake::Task['gitlab:db:execute_batched_migrations'].invoke + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d666d45d237..a4518e914ec 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2036,7 +2036,7 @@ msgstr "" msgid "Add a task list" msgstr "" -msgid "Add a title…" +msgid "Add a title..." msgstr "" msgid "Add a to do" @@ -4769,6 +4769,9 @@ msgstr "" msgid "Assign reviewer(s)" msgstr "" +msgid "Assign severity" +msgstr "" + msgid "Assign some issues to this milestone." msgstr "" @@ -32840,6 +32843,9 @@ msgstr "" msgid "Something went wrong while updating assignees" msgstr "" +msgid "Something went wrong while updating work item. Please try again" +msgstr "" + msgid "Something went wrong while updating your list settings" msgstr "" diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index 20936977612..e01c5522b51 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -79,14 +79,6 @@ RSpec.describe ApplicationExperiment, :experiment do application_experiment.publish end - it "publishes to the database if we've opted for that" do - application_experiment.record! - - expect(application_experiment).to receive(:publish_to_database) - - application_experiment.publish - end - context 'when we should not track' do let(:should_track) { false } diff --git a/spec/factories/sequences.rb b/spec/factories/sequences.rb index 56f1fa162bf..893865962d8 100644 --- a/spec/factories/sequences.rb +++ b/spec/factories/sequences.rb @@ -2,7 +2,7 @@ FactoryBot.define do sequence(:username) { |n| "user#{n}" } - sequence(:name) { |n| "John Doe#{n}" } + sequence(:name) { |n| "Sidney Jones#{n}" } sequence(:email) { |n| "user#{n}@example.org" } sequence(:email_alias) { |n| "user.alias#{n}@example.org" } sequence(:title) { |n| "My title #{n}" } diff --git a/spec/features/alert_management/alert_details_spec.rb b/spec/features/alert_management/alert_details_spec.rb index ce82b5adf8d..579b8221041 100644 --- a/spec/features/alert_management/alert_details_spec.rb +++ b/spec/features/alert_management/alert_details_spec.rb @@ -60,7 +60,7 @@ RSpec.describe 'Alert details', :js do expect(alert_status).to have_content('Triggered') - find('.btn-link').click + find('.gl-button').click find('.gl-new-dropdown-item', text: 'Acknowledged').click wait_for_requests @@ -79,7 +79,7 @@ RSpec.describe 'Alert details', :js do wait_for_requests - expect(alert_assignee).to have_content('Assignee Edit John Doe') + expect(alert_assignee).to have_content('Assignee Edit Sidney Jones') end end end diff --git a/spec/features/gitlab_experiments_spec.rb b/spec/features/gitlab_experiments_spec.rb index 76b418adcea..ca772680ff6 100644 --- a/spec/features/gitlab_experiments_spec.rb +++ b/spec/features/gitlab_experiments_spec.rb @@ -31,9 +31,10 @@ RSpec.describe "Gitlab::Experiment", :js do expect(page).to have_content('Abuse Reports') - published_experiments = page.evaluate_script('window.gon.experiment') + published_experiments = page.evaluate_script('window.gl.experiments') expect(published_experiments).to include({ 'null_hypothesis' => { + 'excluded' => false, 'experiment' => 'null_hypothesis', 'key' => anything, 'variant' => 'candidate' diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 0cefbae4d37..b0e4729db8b 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -180,7 +180,7 @@ RSpec.describe 'GFM autocomplete', :js do describe 'assignees' do it 'does not wrap with quotes for assignee values' do - fill_in 'Comment', with: "@#{user.username[0]}" + fill_in 'Comment', with: "@#{user.username}" find_highlighted_autocomplete_item.click diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 9df430c0f78..aae5ab58b5d 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Pipeline Schedules', :js do click_link 'Take ownership' page.within('.pipeline-schedule-table-row') do expect(page).not_to have_content('No owner') - expect(page).to have_link('John Doe') + expect(page).to have_link('Sidney Jones') end end diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 6cccdd2989f..fc736f2d155 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,11 +1,13 @@ +import * as Sentry from '@sentry/browser'; import createFlash, { - createAction, hideFlash, - removeFlashClickListener, + addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, } from '~/flash'; +jest.mock('@sentry/browser'); + describe('Flash', () => { describe('hideFlash', () => { let el; @@ -66,49 +68,6 @@ describe('Flash', () => { }); }); - describe('createAction', () => { - let el; - - beforeEach(() => { - el = document.createElement('div'); - }); - - it('creates link with href', () => { - el.innerHTML = createAction({ - href: 'testing', - title: 'test', - }); - - expect(el.querySelector('.flash-action').href).toContain('testing'); - }); - - it('uses hash as href when no href is present', () => { - el.innerHTML = createAction({ - title: 'test', - }); - - expect(el.querySelector('.flash-action').href).toContain('#'); - }); - - it('adds role when no href is present', () => { - el.innerHTML = createAction({ - title: 'test', - }); - - expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button'); - }); - - it('escapes the title text', () => { - el.innerHTML = createAction({ - title: '<script>alert("a")</script>', - }); - - expect(el.querySelector('.flash-action').textContent.trim()).toBe( - '<script>alert("a")</script>', - ); - }); - }); - describe('createFlash', () => { const message = 'test'; const fadeTransition = false; @@ -194,7 +153,26 @@ describe('Flash', () => { expect(document.body.className).not.toContain('flash-shown'); }); + it('does not capture error using Sentry', () => { + createFlash({ ...defaultParams, captureError: false, error: new Error('Error!') }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('captures error using Sentry', () => { + createFlash({ ...defaultParams, captureError: true, error: new Error('Error!') }); + + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error!', + }), + ); + }); + describe('with actionConfig', () => { + const findFlashAction = () => document.querySelector('.flash-container .flash-action'); + it('adds action link', () => { createFlash({ ...defaultParams, @@ -203,20 +181,69 @@ describe('Flash', () => { }, }); - expect(document.querySelector('.flash-action')).not.toBeNull(); + expect(findFlashAction()).not.toBeNull(); + }); + + it('creates link with href', () => { + createFlash({ + ...defaultParams, + actionConfig: { + href: 'testing', + title: 'test', + }, + }); + + expect(findFlashAction().href).toBe(`${window.location}testing`); + expect(findFlashAction().textContent.trim()).toBe('test'); + }); + + it('uses hash as href when no href is present', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + }, + }); + + expect(findFlashAction().href).toBe(`${window.location}#`); + }); + + it('adds role when no href is present', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + }, + }); + + expect(findFlashAction().getAttribute('role')).toBe('button'); + }); + + it('escapes the title text', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: '<script>alert("a")</script>', + }, + }); + + expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>'); }); it('calls actionConfig clickHandler on click', () => { - const actionConfig = { - title: 'test', - clickHandler: jest.fn(), - }; + const clickHandler = jest.fn(); - createFlash({ ...defaultParams, actionConfig }); + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + clickHandler, + }, + }); - document.querySelector('.flash-action').click(); + findFlashAction().click(); - expect(actionConfig.clickHandler).toHaveBeenCalled(); + expect(clickHandler).toHaveBeenCalled(); }); }); @@ -236,7 +263,7 @@ describe('Flash', () => { }); }); - describe('removeFlashClickListener', () => { + describe('addDismissFlashClickListener', () => { let el; describe('with close icon', () => { @@ -252,7 +279,7 @@ describe('Flash', () => { }); it('removes global flash on click', (done) => { - removeFlashClickListener(el, false); + addDismissFlashClickListener(el, false); el.querySelector('.js-close-icon').click(); @@ -276,7 +303,7 @@ describe('Flash', () => { }); it('does not throw', () => { - expect(() => removeFlashClickListener(el, false)).not.toThrow(); + expect(() => addDismissFlashClickListener(el, false)).not.toThrow(); }); }); }); diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issues/issue_spec.js index 952ef54d286..8a089b372ff 100644 --- a/spec/frontend/issue_spec.js +++ b/spec/frontend/issues/issue_spec.js @@ -1,7 +1,7 @@ import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import Issue from '~/issue'; +import Issue from '~/issues/issue'; import axios from '~/lib/utils/axios_utils'; describe('Issue', () => { diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index e619ab8cbfe..0300132308c 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -318,8 +318,14 @@ describe('Blob content viewer component', () => { repository: { empty }, } = projectMock; + afterEach(() => { + delete gon.current_user_id; + delete gon.current_username; + }); + it('renders component', async () => { window.gon.current_user_id = 1; + window.gon.current_username = 'root'; await createComponent({ pushCode, downloadCode, empty }, mount); @@ -330,28 +336,34 @@ describe('Blob content viewer component', () => { deletePath: webPath, canPushCode: pushCode, canLock: true, - isLocked: false, + isLocked: true, emptyRepo: empty, }); }); it.each` - canPushCode | canDownloadCode | canLock - ${true} | ${true} | ${true} - ${false} | ${true} | ${false} - ${true} | ${false} | ${false} - `('passes the correct lock states', async ({ canPushCode, canDownloadCode, canLock }) => { - await createComponent( - { - pushCode: canPushCode, - downloadCode: canDownloadCode, - empty, - }, - mount, - ); + canPushCode | canDownloadCode | username | canLock + ${true} | ${true} | ${'root'} | ${true} + ${false} | ${true} | ${'root'} | ${false} + ${true} | ${false} | ${'root'} | ${false} + ${true} | ${true} | ${'peter'} | ${false} + `( + 'passes the correct lock states', + async ({ canPushCode, canDownloadCode, username, canLock }) => { + gon.current_username = username; + + await createComponent( + { + pushCode: canPushCode, + downloadCode: canDownloadCode, + empty, + }, + mount, + ); - expect(findBlobButtonGroup().props('canLock')).toBe(canLock); - }); + expect(findBlobButtonGroup().props('canLock')).toBe(canLock); + }, + ); it('does not render if not logged in', async () => { isLoggedIn.mockReturnValueOnce(false); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index f67eed34a58..74d35daf578 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -47,7 +47,13 @@ export const projectMock = { id: '1234', userPermissions: userPermissionsMock, pathLocks: { - nodes: [], + nodes: [ + { + id: 'test', + path: simpleViewerMock.path, + user: { id: '123', username: 'root' }, + }, + ], }, repository: { empty: false, diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js new file mode 100644 index 00000000000..0f6e7091c59 --- /dev/null +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import { escape } from 'lodash'; +import ItemTitle from '~/work_items/components/item_title.vue'; + +jest.mock('lodash/escape', () => jest.fn((fn) => fn)); + +const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) => + shallowMount(ItemTitle, { + propsData: { + initialTitle, + disabled, + }, + }); + +describe('ItemTitle', () => { + let wrapper; + const mockUpdatedTitle = 'Updated title'; + const findInputEl = () => wrapper.find('span#item-title'); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders title contents', () => { + expect(findInputEl().attributes()).toMatchObject({ + 'data-placeholder': 'Add a title...', + contenteditable: 'true', + }); + expect(findInputEl().text()).toBe('Sample title'); + }); + + it('renders title contents with editing disabled', () => { + wrapper = createComponent({ + disabled: true, + }); + + expect(wrapper.classes()).toContain('gl-cursor-not-allowed'); + expect(findInputEl().attributes('contenteditable')).toBe('false'); + }); + + it.each` + eventName | sourceEvent + ${'title-changed'} | ${'blur'} + ${'title-input'} | ${'keyup'} + `('emits "$eventName" event on input $sourceEvent', async ({ eventName, sourceEvent }) => { + findInputEl().element.innerText = mockUpdatedTitle; + await findInputEl().trigger(sourceEvent); + + expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(escape).toHaveBeenCalledWith(mockUpdatedTitle); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index efb4aa2feb2..c8d46b51888 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -15,3 +15,22 @@ export const workItemQueryResponse = { }, }, }; + +export const updateWorkItemMutationResponse = { + __typename: 'UpdateWorkItemPayload', + workItem: { + __typename: 'WorkItem', + id: '1', + widgets: { + __typename: 'WorkItemWidgetConnection', + nodes: [ + { + __typename: 'TitleWidget', + type: 'TITLE', + enabled: true, + contentText: 'Updated title', + }, + ], + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 180f61f559f..71e153d30c3 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -5,6 +5,7 @@ import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; +import ItemTitle from '~/work_items/components/item_title.vue'; import { resolvers } from '~/work_items/graphql/resolvers'; Vue.use(VueApollo); @@ -14,9 +15,9 @@ describe('Create work item component', () => { let fakeApollo; const findAlert = () => wrapper.findComponent(GlAlert); + const findTitleInput = () => wrapper.findComponent(ItemTitle); const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); - const findTitleInput = () => wrapper.find('[data-testid="title-input"]'); const createComponent = ({ data = {} } = {}) => { fakeApollo = createMockApollo([], resolvers); @@ -70,9 +71,10 @@ describe('Create work item component', () => { }); describe('when title input field has a text', () => { - beforeEach(() => { + beforeEach(async () => { + const mockTitle = 'Test title'; createComponent(); - findTitleInput().setValue('Test title'); + await findTitleInput().vm.$emit('title-input', mockTitle); }); it('renders a non-disabled Create button', () => { diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index ea76e2628d3..02795751f33 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -2,8 +2,12 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import { resolvers } from '~/work_items/graphql/resolvers'; import { workItemQueryResponse } from '../mock_data'; Vue.use(VueApollo); @@ -14,10 +18,10 @@ describe('Work items root component', () => { let wrapper; let fakeApollo; - const findTitle = () => wrapper.find('[data-testid="title"]'); + const findTitle = () => wrapper.findComponent(ItemTitle); const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { - fakeApollo = createMockApollo(); + fakeApollo = createMockApollo([], resolvers); fakeApollo.clients.defaultClient.cache.writeQuery({ query: workItemQuery, variables: { @@ -43,7 +47,28 @@ describe('Work items root component', () => { createComponent(); expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe('Test'); + expect(findTitle().props('initialTitle')).toBe('Test'); + }); + + it('updates the title when it is edited', async () => { + createComponent(); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + const mockUpdatedTitle = 'Updated title'; + + await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: WORK_ITEM_ID, + title: mockUpdatedTitle, + }, + }, + }); + + await waitForPromises(); + expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle); }); it('does not render the title if title is not in the widgets list', () => { diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index 5a596526a7e..b481c214ca1 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -395,4 +395,170 @@ RSpec.describe AuthHelper do end end end + + describe '#auth_strategy_class' do + subject(:auth_strategy_class) { helper.auth_strategy_class(name) } + + context 'when configuration specifies no provider' do + let(:name) { 'does_not_exist' } + + before do + allow(Gitlab.config.omniauth).to receive(:providers).and_return([]) + end + + it 'returns false' do + expect(auth_strategy_class).to be_falsey + end + end + + context 'when configuration specifies a provider with args but without strategy_class' do + let(:name) { 'google_oauth2' } + let(:provider) do + Struct.new(:name, :args).new( + name, + 'app_id' => 'YOUR_APP_ID' + ) + end + + before do + allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + end + + it 'returns false' do + expect(auth_strategy_class).to be_falsey + end + end + + context 'when configuration specifies a provider with args and strategy_class' do + let(:name) { 'provider1' } + let(:strategy) { 'OmniAuth::Strategies::LDAP' } + let(:provider) do + Struct.new(:name, :args).new( + name, + 'strategy_class' => strategy + ) + end + + before do + allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + end + + it 'returns the class' do + expect(auth_strategy_class).to eq(strategy) + end + end + + context 'when configuration specifies another provider with args and another strategy_class' do + let(:name) { 'provider1' } + let(:strategy) { 'OmniAuth::Strategies::LDAP' } + let(:provider) do + Struct.new(:name, :args).new( + 'another_name', + 'strategy_class' => strategy + ) + end + + before do + allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + end + + it 'returns false' do + expect(auth_strategy_class).to be_falsey + end + end + end + + describe '#saml_providers' do + subject(:saml_providers) { helper.saml_providers } + + let(:saml_strategy) { 'OmniAuth::Strategies::SAML' } + + let(:saml_provider_1_name) { 'saml_provider_1' } + let(:saml_provider_1) do + Struct.new(:name, :args).new( + saml_provider_1_name, + 'strategy_class' => saml_strategy + ) + end + + let(:saml_provider_2_name) { 'saml_provider_2' } + let(:saml_provider_2) do + Struct.new(:name, :args).new( + saml_provider_2_name, + 'strategy_class' => saml_strategy + ) + end + + let(:ldap_provider_name) { 'ldap_provider' } + let(:ldap_strategy) { 'OmniAuth::Strategies::LDAP' } + let(:ldap_provider) do + Struct.new(:name, :args).new( + ldap_provider_name, + 'strategy_class' => ldap_strategy + ) + end + + let(:google_oauth2_provider_name) { 'google_oauth2' } + let(:google_oauth2_provider) do + Struct.new(:name, :args).new( + google_oauth2_provider_name, + 'app_id' => 'YOUR_APP_ID' + ) + end + + context 'when configuration specifies no provider' do + before do + allow(Devise).to receive(:omniauth_providers).and_return([]) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([]) + end + + it 'returns an empty list' do + expect(saml_providers).to be_empty + end + end + + context 'when configuration specifies a provider with a SAML strategy_class' do + before do + allow(Devise).to receive(:omniauth_providers).and_return([saml_provider_1_name]) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([saml_provider_1]) + end + + it 'returns the provider' do + expect(saml_providers).to match_array([saml_provider_1_name]) + end + end + + context 'when configuration specifies two providers with a SAML strategy_class' do + before do + allow(Devise).to receive(:omniauth_providers).and_return([saml_provider_1_name, saml_provider_2_name]) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([saml_provider_1, saml_provider_2]) + end + + it 'returns the provider' do + expect(saml_providers).to match_array([saml_provider_1_name, saml_provider_2_name]) + end + end + + context 'when configuration specifies a provider with a non-SAML strategy_class' do + before do + allow(Devise).to receive(:omniauth_providers).and_return([ldap_provider_name]) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([ldap_provider]) + end + + it 'returns an empty list' do + expect(saml_providers).to be_empty + end + end + + context 'when configuration specifies four providers but only two with SAML strategy_class' do + before do + allow(Devise).to receive(:omniauth_providers).and_return([saml_provider_1_name, ldap_provider_name, saml_provider_2_name, google_oauth2_provider_name]) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([saml_provider_1, ldap_provider, saml_provider_2, google_oauth2_provider]) + end + + it 'returns the provider' do + expect(saml_providers).to match_array([saml_provider_1_name, saml_provider_2_name]) + end + end + end end diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb index f04be91a057..b89f61b53f8 100644 --- a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb @@ -5,7 +5,9 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_gitlab_redis_queues do let(:middleware) { described_class.new } let(:worker) { worker_class.new } - let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'database_replica_location' => '0/D525E3A8' } } + let(:location) {'0/D525E3A8' } + let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } } + let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations } } before do skip_feature_flags_yaml_validation @@ -60,9 +62,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ end shared_examples_for 'replica is up to date' do |expected_strategy| - let(:location) {'0/D525E3A8' } - let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } } - it 'does not stick to the primary', :aggregate_failures do expect(ActiveRecord::Base.load_balancer) .to receive(:select_up_to_date_host) @@ -114,19 +113,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ it_behaves_like 'replica is up to date', 'replica' end - context 'when legacy wal location is set' do - let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_write_location' => '0/D525E3A8' } } - - before do - allow(ActiveRecord::Base.load_balancer) - .to receive(:select_up_to_date_host) - .with('0/D525E3A8') - .and_return(true) - end - - it_behaves_like 'replica is up to date', 'replica' - end - context 'when database location is not set' do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e' } } @@ -146,7 +132,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ end context 'when WAL locations are present', :freeze_time do - let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", "database_replica_location" => "0/D525E3A8", "created_at" => Time.current.to_f - elapsed_time } } + let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, "created_at" => Time.current.to_f - elapsed_time } } context 'when delay interval has not elapsed' do let(:elapsed_time) { described_class::MINIMUM_DELAY_INTERVAL - 0.3 } @@ -192,7 +178,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ include_examples 'stick to the primary', 'primary' context 'when delay interval has not elapsed', :freeze_time do - let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'database_replica_location' => '0/D525E3A8', "created_at" => Time.current.to_f - elapsed_time } } + let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, "created_at" => Time.current.to_f - elapsed_time } } let(:elapsed_time) { described_class::MINIMUM_DELAY_INTERVAL - 0.3 } it 'does not sleep' do @@ -235,7 +221,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ end context 'when job is retried' do - let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'database_replica_location' => '0/D525E3A8', 'retry_count' => 0 } } + let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, 'retry_count' => 0 } } context 'and replica still lagging behind' do include_examples 'stick to the primary', 'primary' diff --git a/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb b/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb index 4de5c9b9c82..65c76aac10c 100644 --- a/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb +++ b/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb @@ -16,6 +16,7 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do end let(:user) { project.creator } + let(:source_branch) { 'feature' } let(:merge_request_description) { "Merge Request Description\nNext line" } let(:merge_request_title) { 'Bugfix' } let(:merge_request) do @@ -24,6 +25,8 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do :simple, source_project: project, target_project: project, + target_branch: 'master', + source_branch: source_branch, author: user, description: merge_request_description, title: merge_request_title @@ -226,6 +229,50 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do MSG end end + + context 'when project has merge commit template with first_commit' do + let(message_template_name) { <<~MSG.rstrip } + Message: %{first_commit} + MSG + + it 'uses first commit' do + expect(result_message).to eq <<~MSG.rstrip + Message: Feature added + + Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> + MSG + end + + context 'when branch has no unmerged commits' do + let(:source_branch) { 'v1.1.0' } + + it 'is an empty string' do + expect(result_message).to eq 'Message: ' + end + end + end + + context 'when project has merge commit template with first_multiline_commit' do + let(message_template_name) { <<~MSG.rstrip } + Message: %{first_multiline_commit} + MSG + + it 'uses first multiline commit' do + expect(result_message).to eq <<~MSG.rstrip + Message: Feature added + + Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> + MSG + end + + context 'when branch has no multiline commits' do + let(:source_branch) { 'spooky-stuff' } + + it 'is mr title' do + expect(result_message).to eq 'Message: Bugfix' + end + end + end end end diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index f9df84e8ff4..3b521086c14 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -75,4 +75,37 @@ RSpec.describe Clusters::Agent do expect(agent.has_access_to?(create(:project))).to be_falsey end end + + describe '#active?' do + let_it_be(:agent) { create(:cluster_agent) } + + let!(:token) { create(:cluster_agent_token, agent: agent, last_used_at: last_used_at) } + + subject { agent.active? } + + context 'agent has never connected' do + let(:last_used_at) { nil } + + it { is_expected.to be_falsey } + end + + context 'agent has connected, but not recently' do + let(:last_used_at) { 2.hours.ago } + + it { is_expected.to be_falsey } + end + + context 'agent has connected recently' do + let(:last_used_at) { 2.minutes.ago } + + it { is_expected.to be_truthy } + end + + context 'agent has multiple tokens' do + let!(:inactive_token) { create(:cluster_agent_token, agent: agent, last_used_at: 2.hours.ago) } + let(:last_used_at) { 2.minutes.ago } + + it { is_expected.to be_truthy } + end + end end diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb index bde4798abec..ad9f948224f 100644 --- a/spec/models/clusters/agent_token_spec.rb +++ b/spec/models/clusters/agent_token_spec.rb @@ -39,7 +39,9 @@ RSpec.describe Clusters::AgentToken do end describe '#track_usage', :clean_gitlab_redis_cache do - let(:agent_token) { create(:cluster_agent_token) } + let_it_be(:agent) { create(:cluster_agent) } + + let(:agent_token) { create(:cluster_agent_token, agent: agent) } subject { agent_token.track_usage } @@ -73,6 +75,34 @@ RSpec.describe Clusters::AgentToken do expect_redis_update end end + + context 'agent is inactive' do + before do + allow(agent).to receive(:active?).and_return(false) + end + + it 'creates an activity event' do + expect { subject }.to change { agent.activity_events.count } + + event = agent.activity_events.last + expect(event).to have_attributes( + kind: 'agent_connected', + level: 'info', + recorded_at: agent_token.reload.read_attribute(:last_used_at), + agent_token: agent_token + ) + end + end + + context 'agent is active' do + before do + allow(agent).to receive(:active?).and_return(true) + end + + it 'does not create an activity event' do + expect { subject }.not_to change { agent.activity_events.count } + end + end end def expect_redis_update diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index d2b1114259e..253b7d65d33 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1648,6 +1648,9 @@ RSpec.describe MergeRequest, factory_default: :keep do it 'uses template from target project' do request = build(:merge_request, title: 'Fix everything') + request.compare_commits = [ + double(safe_message: 'Commit message', gitaly_commit?: true, merge_commit?: false, description?: false) + ] subject.target_project.merge_commit_template = '%{title}' expect(request.default_merge_commit_message) diff --git a/spec/services/merge_requests/approval_service_spec.rb b/spec/services/merge_requests/approval_service_spec.rb index d30b2721a36..4d20d62b864 100644 --- a/spec/services/merge_requests/approval_service_spec.rb +++ b/spec/services/merge_requests/approval_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe MergeRequests::ApprovalService do describe '#execute' do let(:user) { create(:user) } - let(:merge_request) { create(:merge_request) } + let(:merge_request) { create(:merge_request, reviewers: [user]) } let(:project) { merge_request.project } let!(:todo) { create(:todo, user: user, project: project, target: merge_request) } @@ -59,6 +59,14 @@ RSpec.describe MergeRequests::ApprovalService do service.execute(merge_request) end + it 'removes attention requested state' do + expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new) + .with(project: project, current_user: user, merge_request: merge_request, user: user) + .and_call_original + + service.execute(merge_request) + end + context 'with remaining approvals' do it 'fires an approval webhook' do expect(service).to receive(:execute_hooks).with(merge_request, 'approved') diff --git a/spec/services/merge_requests/bulk_remove_attention_requested_service_spec.rb b/spec/services/merge_requests/bulk_remove_attention_requested_service_spec.rb new file mode 100644 index 00000000000..fe4ce0dab5e --- /dev/null +++ b/spec/services/merge_requests/bulk_remove_attention_requested_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::BulkRemoveAttentionRequestedService do + let(:current_user) { create(:user) } + let(:user) { create(:user) } + let(:assignee_user) { create(:user) } + let(:merge_request) { create(:merge_request, reviewers: [user], assignees: [assignee_user]) } + let(:reviewer) { merge_request.find_reviewer(user) } + let(:assignee) { merge_request.find_assignee(assignee_user) } + let(:project) { merge_request.project } + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request) } + let(:result) { service.execute } + + before do + project.add_developer(current_user) + project.add_developer(user) + end + + describe '#execute' do + context 'invalid permissions' do + let(:service) { described_class.new(project: project, current_user: create(:user), merge_request: merge_request) } + + it 'returns an error' do + expect(result[:status]).to eq :error + end + end + + context 'updates reviewers and assignees' do + it 'returns success' do + expect(result[:status]).to eq :success + end + + it 'updates reviewers state' do + service.execute + reviewer.reload + assignee.reload + + expect(reviewer.state).to eq 'reviewed' + expect(assignee.state).to eq 'reviewed' + end + end + end +end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index 86d972bc516..d36a2f75cfe 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -54,6 +54,10 @@ RSpec.describe MergeRequests::CloseService do expect(todo.reload).to be_done end + it 'removes attention requested state' do + expect(merge_request.find_assignee(user2).attention_requested?).to eq(false) + end + context 'when auto merge is enabled' do let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } diff --git a/spec/services/merge_requests/handle_assignees_change_service_spec.rb b/spec/services/merge_requests/handle_assignees_change_service_spec.rb index c43f5db6059..fa3b1614e21 100644 --- a/spec/services/merge_requests/handle_assignees_change_service_spec.rb +++ b/spec/services/merge_requests/handle_assignees_change_service_spec.rb @@ -87,6 +87,14 @@ RSpec.describe MergeRequests::HandleAssigneesChangeService do expect(todo).to be_pending end + it 'removes attention requested state' do + expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new) + .with(project: project, current_user: user, merge_request: merge_request, user: user) + .and_call_original + + execute + end + it 'tracks users assigned event' do expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) .to receive(:track_users_assigned_to_mr).once.with(users: [assignee]) diff --git a/spec/services/merge_requests/remove_attention_requested_service_spec.rb b/spec/services/merge_requests/remove_attention_requested_service_spec.rb new file mode 100644 index 00000000000..875afc2dc7e --- /dev/null +++ b/spec/services/merge_requests/remove_attention_requested_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::RemoveAttentionRequestedService do + let(:current_user) { create(:user) } + let(:user) { create(:user) } + let(:assignee_user) { create(:user) } + let(:merge_request) { create(:merge_request, reviewers: [user], assignees: [assignee_user]) } + let(:reviewer) { merge_request.find_reviewer(user) } + let(:assignee) { merge_request.find_assignee(assignee_user) } + let(:project) { merge_request.project } + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: user) } + let(:result) { service.execute } + + before do + project.add_developer(current_user) + project.add_developer(user) + end + + describe '#execute' do + context 'invalid permissions' do + let(:service) { described_class.new(project: project, current_user: create(:user), merge_request: merge_request, user: user) } + + it 'returns an error' do + expect(result[:status]).to eq :error + end + end + + context 'reviewer does not exist' do + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: create(:user)) } + + it 'returns an error' do + expect(result[:status]).to eq :error + end + end + + context 'reviewer exists' do + it 'returns success' do + expect(result[:status]).to eq :success + end + + it 'updates reviewers state' do + service.execute + reviewer.reload + + expect(reviewer.state).to eq 'reviewed' + end + end + + context 'assignee exists' do + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: assignee_user) } + + before do + assignee.update!(state: :reviewed) + end + + it 'returns success' do + expect(result[:status]).to eq :success + end + + it 'updates assignees state' do + service.execute + assignee.reload + + expect(assignee.state).to eq 'reviewed' + end + end + + context 'assignee is the same as reviewer' do + let(:merge_request) { create(:merge_request, reviewers: [user], assignees: [user]) } + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: user) } + let(:assignee) { merge_request.find_assignee(user) } + + it 'updates reviewers and assignees state' do + service.execute + reviewer.reload + assignee.reload + + expect(reviewer.state).to eq 'reviewed' + expect(assignee.state).to eq 'reviewed' + end + end + end +end diff --git a/spec/services/merge_requests/toggle_attention_requested_service_spec.rb b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb index e5ba7bcefae..63fa61b8097 100644 --- a/spec/services/merge_requests/toggle_attention_requested_service_spec.rb +++ b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb @@ -70,6 +70,14 @@ RSpec.describe MergeRequests::ToggleAttentionRequestedService do service.execute end + + it 'removes attention requested state' do + expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new) + .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user) + .and_call_original + + service.execute + end end context 'assignee exists' do @@ -101,6 +109,14 @@ RSpec.describe MergeRequests::ToggleAttentionRequestedService do service.execute end + + it 'removes attention requested state' do + expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new) + .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user) + .and_call_original + + service.execute + end end context 'assignee is the same as reviewer' do diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb index 96df5a5f972..eec911f3b6f 100644 --- a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb @@ -161,7 +161,7 @@ RSpec.shared_examples 'User views a wiki page' do commit = wiki.commit visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) - expect(page).to have_content('by John Doe') + expect(page).to have_content('by Sidney Jones') expect(page).to have_content('updated home') expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions') expect(page).to have_content('some link') @@ -174,7 +174,7 @@ RSpec.shared_examples 'User views a wiki page' do commit = wiki.commit('HEAD^') visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) - expect(page).to have_content('by John Doe') + expect(page).to have_content('by Sidney Jones') expect(page).to have_content('updated home') expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions') expect(page).to have_content('some link') @@ -188,7 +188,7 @@ RSpec.shared_examples 'User views a wiki page' do commit = wiki.commit('HEAD^') visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) - expect(page).to have_content('by John Doe') + expect(page).to have_content('by Sidney Jones') expect(page).to have_content('created page: home') expect(page).to have_content('Showing 1 changed file with 4 additions and 0 deletions') expect(page).to have_content('Look at this') |