diff options
68 files changed, 869 insertions, 295 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 8be1d8ee85d..ad92618e1b4 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -5fdd1ba64d79df3a46c74f29d17faf7927650887 +c8a29dc9fd507cab8835b2e1152b94a6ac96de35 diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js new file mode 100644 index 00000000000..613c53144a1 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/upload_file.js @@ -0,0 +1,44 @@ +import axios from '~/lib/utils/axios_utils'; + +const extractAttachmentLinkUrl = (html) => { + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + const link = body.querySelector('a'); + const src = link.getAttribute('href'); + const { canonicalSrc } = link.dataset; + + return { src, canonicalSrc }; +}; + +/** + * Uploads a file with a post request to the URL indicated + * in the uploadsPath parameter. The expected response of the + * uploads service is a JSON object that contains, at least, a + * link property. The link property should contain markdown link + * definition (i.e. [GitLab](https://gitlab.com)). + * + * This Markdown will be rendered to extract its canonical and full + * URLs using GitLab Flavored Markdown renderer in the backend. + * + * @param {Object} params + * @param {String} params.uploadsPath An absolute URL that points to a service + * that allows sending a file for uploading via POST request. + * @param {String} params.renderMarkdown A function that accepts a markdown string + * and returns a rendered version in HTML format. + * @param {File} params.file The file to upload + * + * @returns Returns an object with two properties: + * + * canonicalSrc: The URL as defined in the Markdown + * src: The absolute URL that points to the resource in the server + */ +export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { + const formData = new FormData(); + formData.append('file', file, file.name); + + const { data } = await axios.post(uploadsPath, formData); + const { markdown } = data.link; + const rendered = await renderMarkdown(markdown); + + return extractAttachmentLinkUrl(rendered); +}; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index 8a66d4d11b7..a7d44322eb1 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -86,11 +86,11 @@ export default class GroupFilterableList extends FilterableList { // Get option query param, also preserve currently applied query param const sortParam = getParameterByName( 'sort', - isOptionFilterBySort ? e.currentTarget.href : window.location.href, + isOptionFilterBySort ? e.currentTarget.search : window.location.search, ); const archivedParam = getParameterByName( 'archived', - isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href, + isOptionFilterByArchivedProjects ? e.currentTarget.search : window.location.search, ); if (sortParam) { diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 48eadec1d5c..6e300831e00 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -36,7 +36,7 @@ export default { default: null, }, issuableType: { - default: '', + default: 'issue', }, emailsHelpPagePath: { default: '', diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue index 70d73aca925..07492b0046c 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -115,7 +115,7 @@ export default { {{ timeEstimate }} </span> <weight-count - class="gl-display-none gl-sm-display-inline-block gl-mr-3" + class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3" :weight="issue.weight" /> <issue-health-status diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index fcd31013fe5..e3cc43d2679 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -664,7 +664,7 @@ export default { v-gl-tooltip class="gl-display-none gl-sm-display-block" :title="$options.i18n.relatedMergeRequests" - data-testid="issuable-mr" + data-testid="merge-requests" > <gl-icon name="merge-request" /> {{ issuable.mergeRequestsCount }} @@ -672,7 +672,7 @@ export default { <li v-if="issuable.upvotes" v-gl-tooltip - class="gl-display-none gl-sm-display-block" + class="issuable-upvotes gl-display-none gl-sm-display-block" :title="$options.i18n.upvotes" data-testid="issuable-upvotes" > @@ -682,7 +682,7 @@ export default { <li v-if="issuable.downvotes" v-gl-tooltip - class="gl-display-none gl-sm-display-block" + class="issuable-downvotes gl-display-none gl-sm-display-block" :title="$options.i18n.downvotes" data-testid="issuable-downvotes" > @@ -690,7 +690,7 @@ export default { {{ issuable.downvotes }} </li> <blocking-issues-count - class="gl-display-none gl-sm-display-block" + class="blocking-issues gl-display-none gl-sm-display-block" :blocking-issues-count="issuable.blockedByCount" :is-list-item="true" /> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index e4b9136343e..d94d4b9a19a 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -97,7 +97,7 @@ export const i18n = { relatedMergeRequests: __('Related merge requests'), reorderError: __('An error occurred while reordering issues.'), rssLabel: __('Subscribe to RSS feed'), - searchPlaceholder: __('Search or filter results…'), + searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), }; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index dc73d8c7cc8..71ceb9bef55 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { IssuableType } from '~/issue_show/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; @@ -150,7 +149,6 @@ export function mountIssuesListApp() { // For IssuableByEmail component emailsHelpPagePath, initialEmail, - issuableType: IssuableType.Issue, markdownHelpPath, quickActionsHelpPath, resetPath, diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 35b16d73cc7..e31c13f40b0 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -35,11 +35,6 @@ export default { required: false, default: false, }, - variablesSettingsUrl: { - type: String, - required: false, - default: null, - }, action: { type: Object, required: false, @@ -75,11 +70,7 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <manual-variables-form - v-if="shouldRenderManualVariables" - :action="action" - :variables-settings-url="variablesSettingsUrl" - /> + <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> <div class="text-content"> <div v-if="action && !shouldRenderManualVariables" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index be95001a396..fa9ee56c049 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -50,11 +50,6 @@ export default { required: false, default: null, }, - variablesSettingsUrl: { - type: String, - required: false, - default: null, - }, deploymentHelpUrl: { type: String, required: false, @@ -315,7 +310,6 @@ export default { :action="emptyStateAction" :playable="job.playable" :scheduled="job.scheduled" - :variables-settings-url="variablesSettingsUrl" /> <!-- EO empty state --> diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index d45012d2023..269551ff9aa 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -1,14 +1,16 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapActions } from 'vuex'; -import { s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; export default { name: 'ManualVariablesForm', components: { GlButton, + GlLink, + GlSprintf, }, props: { action: { @@ -24,11 +26,6 @@ export default { ); }, }, - variablesSettingsUrl: { - type: String, - required: true, - default: '', - }, }, inputTypes: { key: 'key', @@ -37,6 +34,9 @@ export default { i18n: { keyPlaceholder: s__('CiVariables|Input variable key'), valuePlaceholder: s__('CiVariables|Input variable value'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), }, data() { return { @@ -47,17 +47,8 @@ export default { }; }, computed: { - helpText() { - return sprintf( - s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), - { - linkStart: `<a href="${this.variablesSettingsUrl}">`, - linkEnd: '</a>', - }, - false, - ); + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, }, watch: { @@ -188,8 +179,14 @@ export default { </div> </div> </div> - <div class="d-flex gl-mt-3 justify-content-center"> - <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </div> <div class="d-flex justify-content-center"> <gl-button diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 260190f5043..1fb6a6f9850 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -15,7 +15,6 @@ export default () => { deploymentHelpUrl, codeQualityHelpUrl, runnerSettingsUrl, - variablesSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, pagePath, @@ -41,7 +40,6 @@ export default () => { deploymentHelpUrl, codeQualityHelpUrl, runnerSettingsUrl, - variablesSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, pagePath, diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index cec689a44ca..d91fc61ba21 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -2,12 +2,13 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; -import { createHttpLink } from 'apollo-link-http'; +import { HttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; +import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; export const fetchPolicies = { @@ -18,6 +19,31 @@ export const fetchPolicies = { CACHE_ONLY: 'cache-only', }; +export const stripWhitespaceFromQuery = (url, path) => { + /* eslint-disable-next-line no-unused-vars */ + const [_, params] = url.split(path); + + if (!params) { + return url; + } + + const decoded = decodeURIComponent(params); + const paramsObj = queryToObject(decoded); + + if (!paramsObj.query) { + return url; + } + + const stripped = paramsObj.query + .split(/\s+|\n/) + .join(' ') + .trim(); + paramsObj.query = stripped; + + const reassembled = objectToQuery(paramsObj); + return `${path}?${reassembled}`; +}; + export default (resolvers = {}, config = {}) => { const { assumeImmutableResults, @@ -58,10 +84,31 @@ export default (resolvers = {}, config = {}) => { }); }); + /* + This custom fetcher intervention is to deal with an issue where we are using GET to access + eTag polling, but Apollo Client adds excessive whitespace, which causes the + request to fail on certain self-hosted stacks. When we can move + to subscriptions entirely or can land an upstream PR, this can be removed. + + Related links + Bug report: https://gitlab.com/gitlab-org/gitlab/-/issues/329895 + Moving to subscriptions: https://gitlab.com/gitlab-org/gitlab/-/issues/332485 + Apollo Client issue: https://github.com/apollographql/apollo-feature-requests/issues/182 + */ + + const fetchIntervention = (url, options) => { + return fetch(stripWhitespaceFromQuery(url, path), options); + }; + + const requestLink = ApolloLink.split( + () => useGet, + new HttpLink({ ...httpOptions, fetch: fetchIntervention }), + new BatchHttpLink(httpOptions), + ); + const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), - useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), ); const performanceBarLink = new ApolloLink((operation, forward) => { @@ -99,6 +146,7 @@ export default (resolvers = {}, config = {}) => { new StartupJSLink(), apolloCaptchaLink, uploadsLink, + requestLink, ]), ); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 8b02d2567a4..7922ff22a70 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -108,25 +108,6 @@ export function getParameterValues(sParam, url = window.location) { } /** - * This function accepts the `name` of the param to parse in the url - * if the name does not exist this function will return `null` - * otherwise it will return the value of the param key provided - * - * @param {String} name - * @param {String?} urlToParse - * @returns value of the parameter as string - */ -export const getParameterByName = (name, urlToParse) => { - const url = urlToParse || window.location.href; - const parsedName = name.replace(/[[\]]/g, '\\$&'); - const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`); - const results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ''; - return decodeUrlParameter(results[2]); -}; - -/** * Merges a URL to a set of params replacing value for * those already present. * @@ -514,6 +495,19 @@ export function queryToObject(query, { gatherArrays = false, legacySpacesDecode } /** + * This function accepts the `name` of the param to parse in the url + * if the name does not exist this function will return `null` + * otherwise it will return the value of the param key provided + * + * @param {String} name + * @param {String?} urlToParse + * @returns value of the parameter as string + */ +export const getParameterByName = (name, query = window.location.search) => { + return queryToObject(query)[name] || null; +}; + +/** * Convert search query object back into a search query * * @param {Object?} params that needs to be converted diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 05b87abecd5..e01f9cc79c0 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -158,6 +158,12 @@ export default { const updatedPath = setUrlParams({ branch_name: newBranch }); historyPushState(updatedPath); + this.$emit('updateCommitSha', { newBranch }); + + // refetching the content will cause a lot of components to re-render, + // including the text editor which uses the commit sha to register the CI schema + // so we need to make sure the commit sha is updated first + await this.$nextTick(); this.$emit('refetchContent'); }, async setSearchTerm(newSearchTerm) { diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 368a026bdaa..6af3361e7e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -66,6 +66,7 @@ export default { }, data() { return { + commitSha: '', hasError: false, }; }, diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql new file mode 100644 index 00000000000..dce17cad808 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCommitSha($commitSha: String) { + updateCommitSha(commitSha: $commitSha) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql new file mode 100644 index 00000000000..219c23bb22b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql @@ -0,0 +1,12 @@ +query getLatestCommitSha($projectPath: ID!, $ref: String) { + project(fullPath: $projectPath) { + pipelines(ref: $ref) { + nodes { + id + sha + path + commitPath + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index ad333f6d42a..2bec2006e95 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,5 +1,6 @@ import produce from 'immer'; import axios from '~/lib/utils/axios_utils'; +import getCommitShaQuery from './queries/client/commit_sha.graphql'; import getCurrentBranchQuery from './queries/client/current_branch.graphql'; import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; @@ -31,7 +32,15 @@ export const resolvers = { __typename: 'CiLintContent', })); }, - updateCurrentBranch: (_, { currentBranch = undefined }, { cache }) => { + updateCommitSha: (_, { commitSha }, { cache }) => { + cache.writeQuery({ + query: getCommitShaQuery, + data: produce(cache.readQuery({ query: getCommitShaQuery }), (draftData) => { + draftData.commitSha = commitSha; + }), + }); + }, + updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ query: getCurrentBranchQuery, data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => { @@ -39,7 +48,7 @@ export const resolvers = { }), }); }, - updateLastCommitBranch: (_, { lastCommitBranch = undefined }, { cache }) => { + updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ query: getLastCommitBranchQuery, data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => { diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index c7de8516c86..758c8c51a5b 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -16,12 +16,14 @@ import { LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; +import updateCommitShaMutation from './graphql/mutations/update_commit_sha.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; import getAppStatus from './graphql/queries/client/app_status.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql'; import getTemplate from './graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; export default { @@ -250,6 +252,38 @@ export default { updateCiConfig(ciFileContent) { this.currentCiFileContent = ciFileContent; }, + async updateCommitSha({ newBranch }) { + let fetchResults; + + try { + fetchResults = await this.$apollo.query({ + query: getLatestCommitShaQuery, + variables: { + projectPath: this.projectFullPath, + ref: newBranch, + }, + }); + } catch { + this.showFetchError(); + return; + } + + if (fetchResults.errors?.length > 0) { + this.showFetchError(); + return; + } + + const pipelineNodes = fetchResults?.data?.project?.pipelines?.nodes ?? []; + if (pipelineNodes.length === 0) { + return; + } + + const commitSha = pipelineNodes[0].sha; + this.$apollo.mutate({ + mutation: updateCommitShaMutation, + variables: { commitSha }, + }); + }, updateOnCommit({ type }) { this.reportSuccess(type); @@ -302,6 +336,7 @@ export default { @showError="showErrorAlert" @refetchContent="refetchContent" @updateCiConfig="updateCiConfig" + @updateCommitSha="updateCommitSha" /> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 07d8f3cc5f1..a0129dd536b 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -131,7 +131,8 @@ export default { <div class="col-lg-8"> <div class="form-group"> <gl-button - variant="success" + category="primary" + variant="confirm" name="commit" type="submit" :disabled="!isSubmitEnabled" diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 794a8a85cc5..cf1cff9023e 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -2,7 +2,7 @@ import filesQuery from 'shared_queries/repository/files.query.graphql'; import createFlash from '~/flash'; import { __ } from '../../locale'; -import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT } from '../constants'; +import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; @@ -36,6 +36,7 @@ export default { return { projectPath: '', nextPageCursor: '', + pagesLoaded: 1, entries: { trees: [], submodules: [], @@ -44,16 +45,26 @@ export default { isLoadingFiles: false, isOverLimit: false, clickedShowMore: false, - pageSize: TREE_PAGE_SIZE, fetchCounter: 0, }; }, computed: { + pageSize() { + // we want to exponentially increase the page size to reduce the load on the frontend + const exponentialSize = (TREE_PAGE_SIZE / TREE_INITIAL_FETCH_COUNT) * (this.fetchCounter + 1); + return exponentialSize < TREE_PAGE_SIZE ? exponentialSize : TREE_PAGE_SIZE; + }, + totalEntries() { + return Object.values(this.entries).flat().length; + }, readme() { return readmeFile(this.entries.blobs); }, + pageLimitReached() { + return this.totalEntries / this.pagesLoaded >= TREE_PAGE_LIMIT; + }, hasShowMore() { - return !this.clickedShowMore && this.fetchCounter === TREE_INITIAL_FETCH_COUNT; + return !this.clickedShowMore && this.pageLimitReached; }, }, @@ -104,7 +115,7 @@ export default { if (pageInfo?.hasNextPage) { this.nextPageCursor = pageInfo.endCursor; this.fetchCounter += 1; - if (this.fetchCounter < TREE_INITIAL_FETCH_COUNT || this.clickedShowMore) { + if (!this.pageLimitReached || this.clickedShowMore) { this.fetchFiles(); this.clickedShowMore = false; } @@ -127,6 +138,7 @@ export default { }, handleShowMore() { this.clickedShowMore = true; + this.pagesLoaded += 1; this.fetchFiles(); }, }, diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 62d5d3db445..22349261d3c 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -1,4 +1,3 @@ -const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page - +export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 6668af33a1c..82d768c2351 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -240,3 +240,13 @@ $gl-line-height-42: px-to-rem(42px); } } } + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1490 +.gl-w-grid-size-28 { + width: $grid-size * 28; +} + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1491 +.gl-min-w-8 { + min-width: $gl-spacing-scale-8; +} diff --git a/app/graphql/resolvers/ci/template_resolver.rb b/app/graphql/resolvers/ci/template_resolver.rb index dd910116544..7f5a1a486d7 100644 --- a/app/graphql/resolvers/ci/template_resolver.rb +++ b/app/graphql/resolvers/ci/template_resolver.rb @@ -6,7 +6,7 @@ module Resolvers type Types::Ci::TemplateType, null: true argument :name, GraphQL::STRING_TYPE, required: true, - description: 'Name of the CI/CD template to search for.' + description: 'Name of the CI/CD template to search for. Template must be formatted as `Name.gitlab-ci.yml`.' alias_method :project, :object diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 23f2a082a68..882302f05ad 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -9,7 +9,6 @@ module Ci "artifact_help_url" => help_page_path('user/gitlab_com/index.html', anchor: 'gitlab-cicd'), "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'), "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'), - "variables_settings_url" => project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'), "page_path" => project_job_path(@project, @build), "build_status" => @build.status, "build_stage" => @build.stage, diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index 1a30b6bed08..d441ffbb853 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -9,7 +9,9 @@ module Ci end def js_pipeline_editor_data(project) - commit_sha = project.commit ? project.commit.sha : '' + initial_branch = params[:branch_name] + latest_commit = project.repository.commit(initial_branch) || project.commit + commit_sha = latest_commit ? latest_commit.sha : '' { "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), @@ -17,11 +19,11 @@ module Ci "commit-sha" => commit_sha, "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'), - "initial-branch-name": params[:branch_name], + "initial-branch-name" => initial_branch, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => namespace_project_new_merge_request_path, - "pipeline_etag" => project.commit ? graphql_etag_pipeline_sha_path(commit_sha) : '', + "pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(commit_sha) : '', "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 7679f6fce72..dc37d73df85 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -27,6 +27,9 @@ class AwardEmoji < ApplicationRecord after_save :expire_cache after_destroy :expire_cache + after_save :update_awardable_upvotes_count + after_destroy :update_awardable_upvotes_count + class << self def votes_for_collection(ids, type) select('name', 'awardable_id', 'COUNT(*) as count') @@ -64,6 +67,14 @@ class AwardEmoji < ApplicationRecord awardable.try(:bump_updated_at) awardable.try(:expire_etag_cache) end + + private + + def update_awardable_upvotes_count + return unless upvote? && awardable.has_attribute?(:upvotes_count) + + awardable.update_column(:upvotes_count, awardable.upvotes) + end end AwardEmoji.prepend_mod_with('AwardEmoji') diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index 90f63f82a66..ed14a1bce41 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ErrorTracking::ErrorEvent < ApplicationRecord - belongs_to :error + belongs_to :error, counter_cache: :events_count validates :payload, json_schema: { filename: 'error_tracking_event_payload' } diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json index 79e81672d0e..19abde7de08 100644 --- a/app/validators/json_schemas/error_tracking_event_payload.json +++ b/app/validators/json_schemas/error_tracking_event_payload.json @@ -2,6 +2,9 @@ "description": "Error tracking event payload", "type": "object", "required": [], + "modules": { + "type": "object" + }, "properties": { "event_id": { "type": "string" @@ -73,28 +76,7 @@ } }, "trace": { - "type": "object", - "required": [], - "properties": { - "trace_id": { - "type": "string" - }, - "span_id": { - "type": "string" - }, - "parent_span_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "op": { - "type": "string" - }, - "status": { - "type": "string" - } - } + "type": "object" } } }, @@ -118,52 +100,13 @@ "type": "string" }, "data": { - "type": "object", - "required": [], - "properties": { - "controller": { - "type": "string" - }, - "action": { - "type": "string" - }, - "params": { - "type": "object", - "required": [], - "properties": { - "controller": { - "type": "string" - }, - "action": { - "type": "string" - } - } - }, - "format": { - "type": "string" - }, - "method": { - "type": "string" - }, - "path": { - "type": "string" - }, - "start_timestamp": { - "type": "number" - } - } - }, - "level": { - "type": "string" + "type": "object" }, "message": { "type": "string" }, "timestamp": { "type": "number" - }, - "type": { - "type": "string" } } } @@ -199,37 +142,7 @@ "type": "string" }, "headers": { - "type": "object", - "required": [], - "properties": { - "Host": { - "type": "string" - }, - "User-Agent": { - "type": "string" - }, - "Accept": { - "type": "string" - }, - "Accept-Language": { - "type": "string" - }, - "Accept-Encoding": { - "type": "string" - }, - "Referer": { - "type": "string" - }, - "Turbolinks-Referrer": { - "type": "string" - }, - "Connection": { - "type": "string" - }, - "X-Request-Id": { - "type": "string" - } - } + "type": "object" }, "env": { "type": "object", @@ -290,25 +203,19 @@ "type": "number" }, "in_app": { - "type": "string" + "type": "boolean" }, "filename": { "type": "string" }, "pre_context": { - "type": "array", - "items": { - "type": "string" - } + "type": "array" }, "context_line": { "type": "string" }, "post_context": { - "type": "array", - "items": { - "type": "string" - } + "type": "array" } } } diff --git a/config/feature_flags/development/api_caching_rate_limit_repository_compare.yml b/config/feature_flags/development/api_caching_rate_limit_repository_compare.yml index 66597f95e8c..81200aff786 100644 --- a/config/feature_flags/development/api_caching_rate_limit_repository_compare.yml +++ b/config/feature_flags/development/api_caching_rate_limit_repository_compare.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334264 milestone: '14.1' type: development group: group::source code -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/api_caching_repository_compare.yml b/config/feature_flags/development/api_caching_repository_compare.yml deleted file mode 100644 index d39bd283512..00000000000 --- a/config/feature_flags/development/api_caching_repository_compare.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: api_caching_repository_compare -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64418 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334264 -milestone: '14.1' -type: development -group: group::source code -default_enabled: false diff --git a/danger/gitaly/Dangerfile b/danger/gitaly/Dangerfile new file mode 100644 index 00000000000..59e55845c83 --- /dev/null +++ b/danger/gitaly/Dangerfile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +TEMPLATE_MESSAGE = <<~MSG +This merge request requires coordination with gitaly deployments. +Before merging this merge request we should verify that gitaly +running in production already implements the new gRPC interface +included here. + +Failing to do so will introduce a [non backward compatible +change](https://docs.gitlab.com/ee/development/multi_version_compatibility.html) +during canary depoyment that can cause an incident. + +1. Identify the gitaly MR introducing the new interface +1. Verify that the environment widget contains a `gprd` deployment +MSG + +changed_lines = helper.changed_lines('Gemfile.lock') +if changed_lines.any? { |line| line =~ /^\+\s+gitaly \(/ } + warn 'Changing gitaly gem can cause a multi-version incompatibility incident' + + markdown(TEMPLATE_MESSAGE) +end diff --git a/db/migrate/20210701111627_add_upvotes_count_to_issues.rb b/db/migrate/20210701111627_add_upvotes_count_to_issues.rb new file mode 100644 index 00000000000..beefb186f37 --- /dev/null +++ b/db/migrate/20210701111627_add_upvotes_count_to_issues.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddUpvotesCountToIssues < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + def up + with_lock_retries do + add_column :issues, :upvotes_count, :integer, default: 0, null: false + end + end + + def down + remove_column :issues, :upvotes_count + end +end diff --git a/db/migrate/20210708131048_add_error_tracking_counter_cache.rb b/db/migrate/20210708131048_add_error_tracking_counter_cache.rb new file mode 100644 index 00000000000..3bf7e1e3688 --- /dev/null +++ b/db/migrate/20210708131048_add_error_tracking_counter_cache.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddErrorTrackingCounterCache < ActiveRecord::Migration[6.1] + def up + add_column :error_tracking_errors, :events_count, :bigint, null: false, default: 0 + end + + def down + remove_column :error_tracking_errors, :events_count + end +end diff --git a/db/post_migrate/20210701111909_backfill_issues_upvotes_count.rb b/db/post_migrate/20210701111909_backfill_issues_upvotes_count.rb new file mode 100644 index 00000000000..0afc0bc1d08 --- /dev/null +++ b/db/post_migrate/20210701111909_backfill_issues_upvotes_count.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class BackfillIssuesUpvotesCount < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + MIGRATION = 'BackfillUpvotesCountOnIssues' + DELAY_INTERVAL = 2.minutes + BATCH_SIZE = 5_000 + + def up + scope = Issue.joins("INNER JOIN award_emoji e ON e.awardable_id = issues.id AND e.awardable_type = 'Issue' AND e.name = 'thumbsup'") + + queue_background_migration_jobs_by_range_at_intervals( + scope, + MIGRATION, + DELAY_INTERVAL, + batch_size: BATCH_SIZE + ) + end + + def down + # no-op + end +end diff --git a/db/schema_migrations/20210701111627 b/db/schema_migrations/20210701111627 new file mode 100644 index 00000000000..ca52a786a22 --- /dev/null +++ b/db/schema_migrations/20210701111627 @@ -0,0 +1 @@ +c2efdad12c3d0ec5371259baa91466137b827f513250e901842ab28e56c3de0a
\ No newline at end of file diff --git a/db/schema_migrations/20210701111909 b/db/schema_migrations/20210701111909 new file mode 100644 index 00000000000..ed6e2d56e8d --- /dev/null +++ b/db/schema_migrations/20210701111909 @@ -0,0 +1 @@ +fdd7509fc88e563b65b487706cae1a64066a7ba7d4bd13d0414b8431c3ddfb68
\ No newline at end of file diff --git a/db/schema_migrations/20210708131048 b/db/schema_migrations/20210708131048 new file mode 100644 index 00000000000..f61978d8e0f --- /dev/null +++ b/db/schema_migrations/20210708131048 @@ -0,0 +1 @@ +ed0c0dc015e7c3457248303b8b478c8d259d6a800a2bfed8b05b1f976b6794a7
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b21c1bfd484..6b583c8da46 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -12790,6 +12790,7 @@ CREATE TABLE error_tracking_errors ( platform text, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + events_count bigint DEFAULT 0 NOT NULL, CONSTRAINT check_18a758e537 CHECK ((char_length(name) <= 255)), CONSTRAINT check_b5cb4d3888 CHECK ((char_length(actor) <= 255)), CONSTRAINT check_c739788b12 CHECK ((char_length(description) <= 1024)), @@ -14267,6 +14268,7 @@ CREATE TABLE issues ( sprint_id bigint, issue_type smallint DEFAULT 0 NOT NULL, blocking_issues_count integer DEFAULT 0 NOT NULL, + upvotes_count integer DEFAULT 0 NOT NULL, CONSTRAINT check_fba63f706d CHECK ((lock_version IS NOT NULL)) ); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7fadc6e5cce..cbf92fce85d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11596,7 +11596,7 @@ Returns [`CiTemplate`](#citemplate). | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="projectcitemplatename"></a>`name` | [`String!`](#string) | Name of the CI/CD template to search for. | +| <a id="projectcitemplatename"></a>`name` | [`String!`](#string) | Name of the CI/CD template to search for. Template must be formatted as `Name.gitlab-ci.yml`. | ##### `Project.clusterAgent` diff --git a/doc/user/permissions.md b/doc/user/permissions.md index cb102823057..45d516c4f49 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -49,8 +49,10 @@ The following table lists project permissions available for each role: | View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View License Compliance reports **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View Security reports **(ULTIMATE)** | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | -| View Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | -| View License list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | +| View Dependency list **(ULTIMATE)** | | | ✓ | ✓ | ✓ | +| View License list **(ULTIMATE)** | | ✓ | ✓ | ✓ | ✓ | +| View [Threats list](application_security/threat_monitoring/#threat-monitoring) **(ULTIMATE)** | | | ✓ | ✓ | ✓ | +| Create and run [on-demand DAST scans](application_security/dast/#on-demand-scans) | | | ✓ | ✓ | ✓ | | View licenses in Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ | | View project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 66f56f0b984..f274406e225 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -122,7 +122,7 @@ module API get ':id/repository/compare' do ff_enabled = Feature.enabled?(:api_caching_rate_limit_repository_compare, user_project, default_enabled: :yaml) - cache_action_if(ff_enabled, [user_project, :repository_compare, current_user, declared_params], expires_in: 30.seconds) do + cache_action_if(ff_enabled, [user_project, :repository_compare, current_user, declared_params], expires_in: 1.minute) do if params[:from_project_id].present? target_project = MergeRequestTargetProjectFinder .new(current_user: current_user, source_project: user_project, project_feature: :repository) @@ -138,11 +138,7 @@ module API compare = CompareService.new(user_project, params[:to]).execute(target_project, params[:from], straight: params[:straight]) if compare - if Feature.enabled?(:api_caching_repository_compare, user_project, default_enabled: :yaml) - present_cached compare, with: Entities::Compare, expires_in: 1.day, cache_context: nil - else - present compare, with: Entities::Compare - end + present compare, with: Entities::Compare else not_found!("Ref") end diff --git a/lib/gitlab/background_migration/backfill_upvotes_count_on_issues.rb b/lib/gitlab/background_migration/backfill_upvotes_count_on_issues.rb new file mode 100644 index 00000000000..170af90805a --- /dev/null +++ b/lib/gitlab/background_migration/backfill_upvotes_count_on_issues.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Class that will populate the upvotes_count field + # for each issue + class BackfillUpvotesCountOnIssues + BATCH_SIZE = 1_000 + + def perform(start_id, stop_id) + (start_id..stop_id).step(BATCH_SIZE).each do |offset| + update_issue_upvotes_count(offset, offset + BATCH_SIZE) + end + end + + private + + def execute(sql) + @connection ||= ::ActiveRecord::Base.connection + @connection.execute(sql) + end + + def update_issue_upvotes_count(batch_start, batch_stop) + execute(<<~SQL) + UPDATE issues + SET upvotes_count = sub_q.count_all + FROM ( + SELECT COUNT(*) AS count_all, e.awardable_id AS issue_id + FROM award_emoji AS e + WHERE e.name = 'thumbsup' AND + e.awardable_type = 'Issue' AND + e.awardable_id BETWEEN #{batch_start} AND #{batch_stop} + GROUP BY issue_id + ) AS sub_q + WHERE sub_q.issue_id = issues.id; + SQL + end + end + end +end diff --git a/lib/gitlab/import_export/project/import_export.yml b/lib/gitlab/import_export/project/import_export.yml index 4e2c5d033ac..a84978a2a80 100644 --- a/lib/gitlab/import_export/project/import_export.yml +++ b/lib/gitlab/import_export/project/import_export.yml @@ -229,6 +229,7 @@ excluded_attributes: - :promoted_to_epic_id - :blocking_issues_count - :service_desk_reply_to + - :upvotes_count merge_request: - :milestone_id - :sprint_id diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5c259a6fb70..0c23d0efbf6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16865,6 +16865,9 @@ msgstr "" msgid "InProductMarketing|Create your first project!" msgstr "" +msgid "InProductMarketing|Deliver Better Products Faster" +msgstr "" + msgid "InProductMarketing|Did you know teams that use GitLab are far more efficient?" msgstr "" @@ -16904,6 +16907,9 @@ msgstr "" msgid "InProductMarketing|Follow our steps" msgstr "" +msgid "InProductMarketing|Free 30-day trial" +msgstr "" + msgid "InProductMarketing|Get going with CI/CD quickly using our %{quick_start_link}. Start with an available runner and then create a CI .yml file – it's really that easy." msgstr "" @@ -16982,6 +16988,9 @@ msgstr "" msgid "InProductMarketing|Improve code quality and streamline reviews" msgstr "" +msgid "InProductMarketing|Increase Operational Efficiencies" +msgstr "" + msgid "InProductMarketing|Invite your colleagues and start shipping code faster." msgstr "" @@ -17024,12 +17033,18 @@ msgstr "" msgid "InProductMarketing|Neutral" msgstr "" +msgid "InProductMarketing|No credit card required." +msgstr "" + msgid "InProductMarketing|Our tool brings all the things together" msgstr "" msgid "InProductMarketing|Rapid development, simplified" msgstr "" +msgid "InProductMarketing|Reduce Security & Compliance Risk" +msgstr "" + msgid "InProductMarketing|Security that's integrated into your development lifecycle" msgstr "" @@ -17120,6 +17135,9 @@ msgstr "" msgid "InProductMarketing|Use GitLab CI/CD" msgstr "" +msgid "InProductMarketing|Used by more than 100,000 organizations from around the globe:" +msgstr "" + msgid "InProductMarketing|Very difficult" msgstr "" @@ -29087,6 +29105,9 @@ msgstr "" msgid "SecurityOrchestration|Security policy project" msgstr "" +msgid "SecurityPolicies|All policies" +msgstr "" + msgid "SecurityPolicies|Description" msgstr "" diff --git a/package.json b/package.json index 1d839a200f4..fbe6424c741 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "1.202.0", "@gitlab/tributejs": "1.0.0", - "@gitlab/ui": "31.0.1", + "@gitlab/ui": "31.2.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "6.1.3-2", "@rails/ujs": "6.1.3-2", diff --git a/spec/features/projects/user_views_empty_project_spec.rb b/spec/features/projects/user_views_empty_project_spec.rb index cce38456df9..696a7f4ee8a 100644 --- a/spec/features/projects/user_views_empty_project_spec.rb +++ b/spec/features/projects/user_views_empty_project_spec.rb @@ -7,10 +7,12 @@ RSpec.describe 'User views an empty project' do let_it_be(:user) { create(:user) } shared_examples 'allowing push to default branch' do - it 'shows push-to-master instructions' do + let(:default_branch) { project.default_branch_or_main } + + it 'shows push-to-default-branch instructions' do visit project_path(project) - expect(page).to have_content('git push -u origin master') + expect(page).to have_content("git push -u origin #{default_branch}") end end @@ -47,7 +49,7 @@ RSpec.describe 'User views an empty project' do it 'does not show push-to-master instructions' do visit project_path(project) - expect(page).not_to have_content('git push -u origin master') + expect(page).not_to have_content('git push -u origin') end end end @@ -61,7 +63,7 @@ RSpec.describe 'User views an empty project' do it 'does not show push-to-master instructions nor invite members link', :aggregate_failures, :js do visit project_path(project) - expect(page).not_to have_content('git push -u origin master') + expect(page).not_to have_content('git push -u origin') expect(page).not_to have_button(text: 'Invite members') end end diff --git a/spec/frontend/content_editor/services/upload_file_spec.js b/spec/frontend/content_editor/services/upload_file_spec.js new file mode 100644 index 00000000000..87c5298079e --- /dev/null +++ b/spec/frontend/content_editor/services/upload_file_spec.js @@ -0,0 +1,46 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { uploadFile } from '~/content_editor/services/upload_file'; +import httpStatus from '~/lib/utils/http_status'; + +describe('content_editor/services/upload_file', () => { + const uploadsPath = '/uploads'; + const file = new File(['content'], 'file.txt'); + // TODO: Replace with automated fixture + const renderedAttachmentLinkFixture = + '<a href="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"><img data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"></a></p>'; + const successResponse = { + link: { + markdown: '[GitLab](https://gitlab.com)', + }, + }; + const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); + let mock; + let renderMarkdown; + let renderedMarkdown; + + beforeEach(() => { + const formData = new FormData(); + formData.append('file', file); + + renderedMarkdown = parseHTML(renderedAttachmentLinkFixture); + + mock = new MockAdapter(axios); + mock.onPost(uploadsPath, formData).reply(httpStatus.OK, successResponse); + renderMarkdown = jest.fn().mockResolvedValue(renderedAttachmentLinkFixture); + }); + + afterEach(() => { + mock.restore(); + }); + + it('returns src and canonicalSrc of uploaded file', async () => { + const response = await uploadFile({ uploadsPath, renderMarkdown, file }); + + expect(renderMarkdown).toHaveBeenCalledWith(successResponse.link.markdown); + expect(response).toEqual({ + src: renderedMarkdown.querySelector('a').getAttribute('href'), + canonicalSrc: renderedMarkdown.querySelector('a').dataset.canonicalSrc, + }); + }); +}); diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js index dbef547c297..99f13a1c84c 100644 --- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js +++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js @@ -54,7 +54,7 @@ describe('Compare diff version dropdowns', () => { Object.defineProperty(window, 'location', { writable: true, - value: { href: `https://example.gitlab.com${diffHeadParam}` }, + value: { search: diffHeadParam }, }); expectedFirstVersion = { diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js index c9de110ce06..9738fd14275 100644 --- a/spec/frontend/jobs/components/empty_state_spec.js +++ b/spec/frontend/jobs/components/empty_state_spec.js @@ -9,7 +9,6 @@ describe('Empty State', () => { illustrationSizeClass: 'svg-430', title: 'This job has not started yet', playable: false, - variablesSettingsUrl: '', }; const createWrapper = (props) => { diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 3fcefde1aba..871969205b7 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -37,7 +37,6 @@ describe('Job App', () => { deploymentHelpUrl: 'help/deployment', codeQualityHelpPath: '/help/code_quality', runnerSettingsUrl: 'settings/ci-cd/runners', - variablesSettingsUrl: 'settings/ci-cd/variables', terminalPath: 'jobs/123/terminal', projectPath: 'user-name/project-name', subscriptionsMoreMinutesUrl: 'https://customers.gitlab.com/buy_pipeline_minutes', diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js index 376a822dde5..7e42ee957d3 100644 --- a/spec/frontend/jobs/components/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -1,3 +1,4 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -18,7 +19,6 @@ describe('Manual Variables Form', () => { method: 'post', button_title: 'Trigger this manual action', }, - variablesSettingsUrl: '/settings', }; const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { @@ -33,15 +33,19 @@ describe('Manual Variables Form', () => { propsData: { ...requiredProps, ...props }, localVue, store, + stubs: { + GlSprintf, + }, }), ); }; const findInputKey = () => wrapper.findComponent({ ref: 'inputKey' }); const findInputValue = () => wrapper.findComponent({ ref: 'inputSecretValue' }); + const findHelpText = () => wrapper.findComponent(GlSprintf); + const findHelpLink = () => wrapper.findComponent(GlLink); const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); - const findHelpText = () => wrapper.findByTestId('form-help-text'); const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); @@ -62,11 +66,10 @@ describe('Manual Variables Form', () => { }); it('renders help text with provided link', () => { - expect(findHelpText().text()).toBe( - 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default', + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', ); - - expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl); }); describe('when adding a new variable', () => { diff --git a/spec/frontend/lib/graphql_spec.js b/spec/frontend/lib/graphql_spec.js new file mode 100644 index 00000000000..a39ce2ffd99 --- /dev/null +++ b/spec/frontend/lib/graphql_spec.js @@ -0,0 +1,54 @@ +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { stripWhitespaceFromQuery } from '~/lib/graphql'; +import { queryToObject } from '~/lib/utils/url_utility'; + +describe('stripWhitespaceFromQuery', () => { + const operationName = 'getPipelineDetails'; + const variables = `{ + projectPath: 'root/abcd-dag', + iid: '44' + }`; + + const testQuery = getPipelineDetails.loc.source.body; + const defaultPath = '/api/graphql'; + const encodedVariables = encodeURIComponent(variables); + + it('shortens the query argument by replacing multiple spaces and newlines with a single space', () => { + const testString = `${defaultPath}?query=${encodeURIComponent(testQuery)}`; + expect(testString.length > stripWhitespaceFromQuery(testString, defaultPath).length).toBe(true); + }); + + it('does not contract a single space', () => { + const simpleSingleString = `${defaultPath}?query=${encodeURIComponent('fragment Nonsense')}`; + expect(stripWhitespaceFromQuery(simpleSingleString, defaultPath)).toEqual(simpleSingleString); + }); + + it('works with a non-default path', () => { + const newPath = 'another/graphql/path'; + const newPathSingleString = `${newPath}?query=${encodeURIComponent('fragment Nonsense')}`; + expect(stripWhitespaceFromQuery(newPathSingleString, newPath)).toEqual(newPathSingleString); + }); + + it('does not alter other arguments', () => { + const bareParams = `?query=${encodeURIComponent( + testQuery, + )}&operationName=${operationName}&variables=${encodedVariables}`; + const testLongString = `${defaultPath}${bareParams}`; + + const processed = stripWhitespaceFromQuery(testLongString, defaultPath); + const decoded = decodeURIComponent(processed); + const params = queryToObject(decoded); + + expect(params.operationName).toBe(operationName); + expect(params.variables).toBe(variables); + }); + + it('works when there are no query params', () => { + expect(stripWhitespaceFromQuery(defaultPath, defaultPath)).toEqual(defaultPath); + }); + + it('works when the params do not include a query', () => { + const paramsWithoutQuery = `${defaultPath}&variables=${encodedVariables}`; + expect(stripWhitespaceFromQuery(paramsWithoutQuery, defaultPath)).toEqual(paramsWithoutQuery); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 1a9711210a0..66d0faa95e7 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -101,48 +101,6 @@ describe('URL utility', () => { }); }); - describe('getParameterByName', () => { - const { getParameterByName } = urlUtils; - - it('should return valid parameter', () => { - setWindowLocation({ href: 'https://gitlab.com?scope=all&p=2' }); - - expect(getParameterByName('p')).toEqual('2'); - expect(getParameterByName('scope')).toBe('all'); - }); - - it('should return invalid parameter', () => { - setWindowLocation({ href: 'https://gitlab.com?scope=all&p=2' }); - - expect(getParameterByName('fakeParameter')).toBe(null); - }); - - it('should return a parameter with spaces', () => { - setWindowLocation({ href: 'https://gitlab.com?search=my terms' }); - - expect(getParameterByName('search')).toBe('my terms'); - }); - - it('should return a parameter with encoded spaces', () => { - setWindowLocation({ href: 'https://gitlab.com?search=my%20terms' }); - - expect(getParameterByName('search')).toBe('my terms'); - }); - - it('should return a parameter with plus signs as spaces', () => { - setWindowLocation({ href: 'https://gitlab.com?search=my+terms' }); - - expect(getParameterByName('search')).toBe('my terms'); - }); - - it('should return valid parameters if URL is provided', () => { - expect(getParameterByName('foo', 'http://cocteau.twins?foo=bar')).toBe('bar'); - expect(getParameterByName('manan', 'http://cocteau.twins?foo=bar&manan=canchu')).toBe( - 'canchu', - ); - }); - }); - describe('mergeUrlParams', () => { const { mergeUrlParams } = urlUtils; @@ -762,6 +720,49 @@ describe('URL utility', () => { }); }); + describe('getParameterByName', () => { + const { getParameterByName } = urlUtils; + + it('should return valid parameter', () => { + setWindowLocation({ search: '?scope=all&p=2' }); + + expect(getParameterByName('p')).toEqual('2'); + expect(getParameterByName('scope')).toBe('all'); + }); + + it('should return invalid parameter', () => { + setWindowLocation({ search: '?scope=all&p=2' }); + + expect(getParameterByName('fakeParameter')).toBe(null); + }); + + it('should return a parameter with spaces', () => { + setWindowLocation({ search: '?search=my terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return a parameter with encoded spaces', () => { + setWindowLocation({ search: '?search=my%20terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return a parameter with plus signs as spaces', () => { + setWindowLocation({ search: '?search=my+terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return valid parameters if search is provided', () => { + expect(getParameterByName('foo', 'foo=bar')).toBe('bar'); + expect(getParameterByName('foo', '?foo=bar')).toBe('bar'); + + expect(getParameterByName('manan', 'foo=bar&manan=canchu')).toBe('canchu'); + expect(getParameterByName('manan', '?foo=bar&manan=canchu')).toBe('canchu'); + }); + }); + describe('objectToQuery', () => { it('converts search query object back into a search query', () => { const searchQueryObject = { one: '1', two: '2' }; diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index e731ad8695e..85b51d08f88 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -207,7 +207,8 @@ describe('Pipeline editor branch switcher', () => { it('updates session history when selecting a different branch', async () => { const branch = findDropdownItems().at(1); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(window.history.pushState).toHaveBeenCalled(); expect(window.history.pushState.mock.calls[0][2]).toContain(`?branch_name=${branch.text()}`); @@ -215,7 +216,8 @@ describe('Pipeline editor branch switcher', () => { it('does not update session history when selecting current branch', async () => { const branch = findDropdownItems().at(0); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(branch.text()).toBe(mockDefaultBranch); expect(window.history.pushState).not.toHaveBeenCalled(); @@ -227,7 +229,8 @@ describe('Pipeline editor branch switcher', () => { expect(branch.text()).not.toBe(mockDefaultBranch); expect(wrapper.emitted('refetchContent')).toBeUndefined(); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(wrapper.emitted('refetchContent')).toBeDefined(); expect(wrapper.emitted('refetchContent')).toHaveLength(1); @@ -239,10 +242,20 @@ describe('Pipeline editor branch switcher', () => { expect(branch.text()).toBe(mockDefaultBranch); expect(wrapper.emitted('refetchContent')).toBeUndefined(); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(wrapper.emitted('refetchContent')).toBeUndefined(); }); + + it('emits the updateCommitSha event when selecting a different branch', async () => { + expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); + + const branch = findDropdownItems().at(1); + branch.vm.$emit('click'); + + expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); + }); }); describe('when searching', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 4b0f1aaa13c..4d4a8c21d78 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -156,6 +156,35 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockNewCommitShaResults = { + data: { + project: { + pipelines: { + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/1', + sha: 'd0d56d363d8a3f67a8ab9fc00207d468f30032ca', + path: `/${mockProjectFullPath}/-/pipelines/488`, + commitPath: `/${mockProjectFullPath}/-/commit/d0d56d363d8a3f67a8ab9fc00207d468f30032ca`, + }, + { + id: 'gid://gitlab/Ci::Pipeline/2', + sha: 'fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa', + path: `/${mockProjectFullPath}/-/pipelines/487`, + commitPath: `/${mockProjectFullPath}/-/commit/fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa`, + }, + { + id: 'gid://gitlab/Ci::Pipeline/3', + sha: '6c16b17c7f94a438ae19a96c285bb49e3c632cf4', + path: `/${mockProjectFullPath}/-/pipelines/433`, + commitPath: `/${mockProjectFullPath}/-/commit/6c16b17c7f94a438ae19a96c285bb49e3c632cf4`, + }, + ], + }, + }, + }, +}; + export const mockProjectBranches = { data: { project: { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index eaf7d5e36a1..3dd09c583d6 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -12,7 +12,9 @@ import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_edi import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.graphql'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; import { @@ -24,6 +26,7 @@ import { mockDefaultBranch, mockProjectFullPath, mockCiYml, + mockNewCommitShaResults, } from './mock_data'; const localVue = createLocalVue(); @@ -49,6 +52,9 @@ describe('Pipeline editor app component', () => { let mockBlobContentData; let mockCiConfigData; let mockGetTemplate; + let mockUpdateCommitSha; + let mockLatestCommitShaQuery; + let mockPipelineQuery; const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorApp, { @@ -84,9 +90,16 @@ describe('Pipeline editor app component', () => { [getBlobContent, mockBlobContentData], [getCiConfigData, mockCiConfigData], [getTemplate, mockGetTemplate], + [getLatestCommitShaQuery, mockLatestCommitShaQuery], + [getPipelineQuery, mockPipelineQuery], ]; - mockApollo = createMockApollo(handlers); + const resolvers = { + Mutation: { + updateCommitSha: mockUpdateCommitSha, + }, + }; + mockApollo = createMockApollo(handlers, resolvers); const options = { localVue, @@ -116,6 +129,9 @@ describe('Pipeline editor app component', () => { mockBlobContentData = jest.fn(); mockCiConfigData = jest.fn(); mockGetTemplate = jest.fn(); + mockUpdateCommitSha = jest.fn(); + mockLatestCommitShaQuery = jest.fn(); + mockPipelineQuery = jest.fn(); }); afterEach(() => { @@ -347,4 +363,45 @@ describe('Pipeline editor app component', () => { expect(findTextEditor().exists()).toBe(true); }); }); + + describe('when updating commit sha', () => { + const newCommitSha = mockNewCommitShaResults.data.project.pipelines.nodes[0].sha; + + beforeEach(async () => { + mockUpdateCommitSha.mockResolvedValue(newCommitSha); + mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); + await createComponentWithApollo(); + }); + + it('fetches updated commit sha for the new branch', async () => { + expect(mockLatestCommitShaQuery).not.toHaveBeenCalled(); + + wrapper + .findComponent(PipelineEditorHome) + .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); + await waitForPromises(); + + expect(mockLatestCommitShaQuery).toHaveBeenCalledWith({ + projectPath: mockProjectFullPath, + ref: 'new-branch', + }); + }); + + it('updates commit sha with the newly fetched commit sha', async () => { + expect(mockUpdateCommitSha).not.toHaveBeenCalled(); + + wrapper + .findComponent(PipelineEditorHome) + .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); + await waitForPromises(); + + expect(mockUpdateCommitSha).toHaveBeenCalled(); + expect(mockUpdateCommitSha).toHaveBeenCalledWith( + expect.any(Object), + { commitSha: mockNewCommitShaResults.data.project.pipelines.nodes[0].sha }, + expect.any(Object), + expect.any(Object), + ); + }); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index d397bc185e2..96c19776513 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import filesQuery from 'shared_queries/repository/files.query.graphql'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from '~/repository/components/tree_content.vue'; -import { TREE_INITIAL_FETCH_COUNT } from '~/repository/constants'; let vm; let $apollo; @@ -23,6 +23,8 @@ function factory(path, data = () => ({})) { } describe('Repository table component', () => { + const findFileTable = () => vm.find(FileTable); + afterEach(() => { vm.destroy(); }); @@ -85,14 +87,12 @@ describe('Repository table component', () => { describe('FileTable showMore', () => { describe('when is present', () => { - const fileTable = () => vm.find(FileTable); - beforeEach(async () => { factory('/'); }); it('is changes hasShowMore to false when "showMore" event is emitted', async () => { - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); await vm.vm.$nextTick(); @@ -100,7 +100,7 @@ describe('Repository table component', () => { }); it('changes clickedShowMore when "showMore" event is emitted', async () => { - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); await vm.vm.$nextTick(); @@ -110,7 +110,7 @@ describe('Repository table component', () => { it('triggers fetchFiles when "showMore" event is emitted', () => { jest.spyOn(vm.vm, 'fetchFiles'); - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); expect(vm.vm.fetchFiles).toHaveBeenCalled(); }); @@ -126,10 +126,52 @@ describe('Repository table component', () => { expect(vm.vm.hasShowMore).toBe(false); }); - it('has limit of 1000 files on initial load', () => { + it.each` + totalBlobs | pagesLoaded | limitReached + ${900} | ${1} | ${false} + ${1000} | ${1} | ${true} + ${1002} | ${1} | ${true} + ${1002} | ${2} | ${false} + ${1900} | ${2} | ${false} + ${2000} | ${2} | ${true} + `('has limit of 1000 entries per page', async ({ totalBlobs, pagesLoaded, limitReached }) => { factory('/'); - expect(TREE_INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); + const blobs = new Array(totalBlobs).fill('fakeBlob'); + vm.setData({ entries: { blobs }, pagesLoaded }); + + await vm.vm.$nextTick(); + + expect(findFileTable().props('hasMore')).toBe(limitReached); + }); + + it.each` + fetchCounter | pageSize + ${0} | ${10} + ${2} | ${30} + ${4} | ${50} + ${6} | ${70} + ${8} | ${90} + ${10} | ${100} + ${20} | ${100} + ${100} | ${100} + ${200} | ${100} + `('exponentially increases page size, to a maximum of 100', ({ fetchCounter, pageSize }) => { + factory('/'); + vm.setData({ fetchCounter }); + + vm.vm.fetchFiles(); + + expect($apollo.query).toHaveBeenCalledWith({ + query: filesQuery, + variables: { + pageSize, + nextPageCursor: '', + path: '/', + projectPath: '', + ref: '', + }, + }); }); }); }); diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index feb654a091a..3ce4657282e 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Ci::PipelineEditorHelper do "commit-sha" => project.commit.sha, "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', - "initial-branch-name": nil, + "initial-branch-name" => nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', @@ -72,7 +72,7 @@ RSpec.describe Ci::PipelineEditorHelper do "commit-sha" => '', "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', - "initial-branch-name": nil, + "initial-branch-name" => nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', @@ -87,5 +87,21 @@ RSpec.describe Ci::PipelineEditorHelper do }) end end + + context 'with a non-default branch name' do + let(:user) { create(:user) } + + before do + create_commit('Message', project, user, 'feature') + controller.params[:branch_name] = 'feature' + end + + it 'returns correct values' do + latest_feature_sha = project.repository.commit('feature').sha + + expect(pipeline_editor_data['initial-branch-name']).to eq('feature') + expect(pipeline_editor_data['commit-sha']).to eq(latest_feature_sha) + end + end end end diff --git a/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb new file mode 100644 index 00000000000..b084e3fe885 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillUpvotesCountOnIssues, schema: 20210701111909 do + let(:award_emoji) { table(:award_emoji) } + + let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let!(:project1) { table(:projects).create!(namespace_id: namespace.id) } + let!(:project2) { table(:projects).create!(namespace_id: namespace.id) } + let!(:issue1) { table(:issues).create!(project_id: project1.id) } + let!(:issue2) { table(:issues).create!(project_id: project2.id) } + let!(:issue3) { table(:issues).create!(project_id: project2.id) } + let!(:issue4) { table(:issues).create!(project_id: project2.id) } + + describe '#perform' do + before do + add_upvotes(issue1, :thumbsdown, 1) + add_upvotes(issue2, :thumbsup, 2) + add_upvotes(issue2, :thumbsdown, 1) + add_upvotes(issue3, :thumbsup, 3) + add_upvotes(issue4, :thumbsup, 4) + end + + it 'updates upvotes_count' do + subject.perform(issue1.id, issue4.id) + + expect(issue1.reload.upvotes_count).to eq(0) + expect(issue2.reload.upvotes_count).to eq(2) + expect(issue3.reload.upvotes_count).to eq(3) + expect(issue4.reload.upvotes_count).to eq(4) + end + end + + private + + def add_upvotes(issue, name, count) + count.times do + award_emoji.create!( + name: name.to_s, + awardable_type: 'Issue', + awardable_id: issue.id + ) + end + end +end diff --git a/spec/migrations/backfill_issues_upvotes_count_spec.rb b/spec/migrations/backfill_issues_upvotes_count_spec.rb new file mode 100644 index 00000000000..f2bea0edea0 --- /dev/null +++ b/spec/migrations/backfill_issues_upvotes_count_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillIssuesUpvotesCount do + let(:migration) { described_class.new } + let(:issues) { table(:issues) } + let(:award_emoji) { table(:award_emoji) } + + let!(:issue1) { issues.create! } + let!(:issue2) { issues.create! } + let!(:issue3) { issues.create! } + let!(:issue4) { issues.create! } + let!(:issue4_without_thumbsup) { issues.create! } + + let!(:award_emoji1) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue1.id) } + let!(:award_emoji2) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue2.id) } + let!(:award_emoji3) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue3.id) } + let!(:award_emoji4) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue4.id) } + + it 'correctly schedules background migrations' do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_migration(issue1.id, issue2.id) + expect(described_class::MIGRATION).to be_scheduled_migration(issue3.id, issue4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb index 484e1a9e266..ebd1441f901 100644 --- a/spec/models/award_emoji_spec.rb +++ b/spec/models/award_emoji_spec.rb @@ -171,4 +171,43 @@ RSpec.describe AwardEmoji do expect(awards).to eq('thumbsup' => 2) end end + + describe 'updating upvotes_count' do + context 'on an issue' do + let(:issue) { create(:issue) } + let(:upvote) { build(:award_emoji, :upvote, user: build(:user), awardable: issue) } + let(:downvote) { build(:award_emoji, :downvote, user: build(:user), awardable: issue) } + + it 'updates upvotes_count on the issue when saved' do + expect(issue).to receive(:update_column).with(:upvotes_count, 1).once + + upvote.save! + downvote.save! + end + + it 'updates upvotes_count on the issue when destroyed' do + expect(issue).to receive(:update_column).with(:upvotes_count, 0).once + + upvote.destroy! + downvote.destroy! + end + end + + context 'on another awardable' do + let(:merge_request) { create(:merge_request) } + let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: merge_request) } + + it 'does not update upvotes_count on the merge_request when saved' do + expect(merge_request).not_to receive(:update_column) + + award_emoji.save! + end + + it 'does not update upvotes_count on the merge_request when destroyed' do + expect(merge_request).not_to receive(:update_column) + + award_emoji.destroy! + end + end + end end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 17cdb5166db..d019e89e0b4 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -488,17 +488,6 @@ RSpec.describe API::Repositories do let(:current_user) { nil } end end - - context 'api_caching_repository_compare is disabled' do - before do - stub_feature_flags(api_caching_repository_compare: false) - end - - it_behaves_like 'repository compare' do - let(:project) { create(:project, :public, :repository) } - let(:current_user) { nil } - end - end end describe 'GET /projects/:id/repository/contributors' do diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb index 64fde3db19f..ec34dc7e7a1 100644 --- a/spec/requests/api/wikis_spec.rb +++ b/spec/requests/api/wikis_spec.rb @@ -611,11 +611,12 @@ RSpec.describe API::Wikis do let(:payload) { { file: fixture_file_upload('spec/fixtures/dk.png') } } let(:url) { "/projects/#{project.id}/wikis/attachments" } let(:file_path) { "#{Wikis::CreateAttachmentService::ATTACHMENT_PATH}/fixed_hex/dk.png" } + let(:branch) { wiki.default_branch } let(:result_hash) do { file_name: 'dk.png', file_path: file_path, - branch: 'master', + branch: branch, link: { url: file_path, markdown: "![dk](#{file_path})" diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index d2d706fad3f..9c4e9407f90 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -221,7 +221,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do describe '.local_warning_message' do it 'returns an informational message with rules that can run' do - expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, database, datateam, documentation, duplicate_yarn_dependencies, eslint, karma, pajamas, pipeline, prettier, product_intelligence, utility_css') + expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, database, datateam, documentation, duplicate_yarn_dependencies, eslint, gitaly, karma, pajamas, pipeline, prettier, product_intelligence, utility_css') end end diff --git a/tooling/danger/project_helper.rb b/tooling/danger/project_helper.rb index 5e2970169f6..45a53ac2922 100644 --- a/tooling/danger/project_helper.rb +++ b/tooling/danger/project_helper.rb @@ -10,6 +10,7 @@ module Tooling documentation duplicate_yarn_dependencies eslint + gitaly karma pajamas pipeline diff --git a/yarn.lock b/yarn.lock index 1fda92cad95..a7421c05d8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -908,10 +908,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== -"@gitlab/ui@31.0.1": - version "31.0.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-31.0.1.tgz#55c481f2e2fa777ff34237a8229f39553428d107" - integrity sha512-Sw7Hm9VZ4ZE6knZNkd9L7vs1DGmeTFC1d0xzDytOKBw+1kK1+CpCLae2ehT+Kkkwho9GLwUFtHDdATEDLbFaBg== +"@gitlab/ui@31.2.0": + version "31.2.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-31.2.0.tgz#7716500c9e811560d6e450d8553bf71bdcba79ec" + integrity sha512-hbW3Zd/gIN4C/AKx27ChZy4lf9yW8TBTJwG85dqQKSYvqWG3LuLx7o0kvc+UJqVFK3lk1iUC3pUSN2UrQ+isqg== dependencies: "@babel/standalone" "^7.0.0" bootstrap-vue "2.18.1" |