Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-07-12 12:09:39 +00:00
parent 12de063de4
commit f81141c25d
68 changed files with 869 additions and 295 deletions

View File

@ -1 +1 @@
5fdd1ba64d79df3a46c74f29d17faf7927650887
c8a29dc9fd507cab8835b2e1152b94a6ac96de35

View File

@ -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);
};

View File

@ -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) {

View File

@ -36,7 +36,7 @@ export default {
default: null,
},
issuableType: {
default: '',
default: 'issue',
},
emailsHelpPagePath: {
default: '',

View File

@ -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

View File

@ -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"
/>

View File

@ -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'),
};

View File

@ -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,

View File

@ -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

View File

@ -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 -->

View File

@ -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

View File

@ -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,

View File

@ -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,
]),
);

View File

@ -107,25 +107,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.
@ -513,6 +494,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
*

View File

@ -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) {

View File

@ -66,6 +66,7 @@ export default {
},
data() {
return {
commitSha: '',
hasError: false,
};
},

View File

@ -0,0 +1,3 @@
mutation updateCommitSha($commitSha: String) {
updateCommitSha(commitSha: $commitSha) @client
}

View File

@ -0,0 +1,12 @@
query getLatestCommitSha($projectPath: ID!, $ref: String) {
project(fullPath: $projectPath) {
pipelines(ref: $ref) {
nodes {
id
sha
path
commitPath
}
}
}
}

View File

@ -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) => {

View File

@ -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>

View File

@ -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"

View File

@ -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();
},
},

View File

@ -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

View File

@ -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;
}

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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')

View File

@ -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' }

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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

22
danger/gitaly/Dangerfile Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
c2efdad12c3d0ec5371259baa91466137b827f513250e901842ab28e56c3de0a

View File

@ -0,0 +1 @@
fdd7509fc88e563b65b487706cae1a64066a7ba7d4bd13d0414b8431c3ddfb68

View File

@ -0,0 +1 @@
ed0c0dc015e7c3457248303b8b478c8d259d6a800a2bfed8b05b1f976b6794a7

View File

@ -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))
);

View File

@ -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`

View File

@ -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*) | ✓ | ✓ | ✓ | ✓ |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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",

View File

@ -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

View File

@ -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,
});
});
});

View File

@ -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 = {

View File

@ -9,7 +9,6 @@ describe('Empty State', () => {
illustrationSizeClass: 'svg-430',
title: 'This job has not started yet',
playable: false,
variablesSettingsUrl: '',
};
const createWrapper = (props) => {

View File

@ -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',

View File

@ -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', () => {

View File

@ -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);
});
});

View File

@ -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' };

View File

@ -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', () => {

View File

@ -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: {

View File

@ -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),
);
});
});
});

View File

@ -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: '',
},
});
});
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})"

View File

@ -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

View File

@ -10,6 +10,7 @@ module Tooling
documentation
duplicate_yarn_dependencies
eslint
gitaly
karma
pajamas
pipeline

View File

@ -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"