Commit 97f0ae74 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 5ebc4d92
......@@ -188,6 +188,15 @@ const Api = {
return axios.get(url, { params });
},
createProjectMergeRequest(projectPath, options) {
const url = Api.buildUrl(Api.projectMergeRequestsPath).replace(
':id',
encodeURIComponent(projectPath),
);
return axios.post(url, options);
},
// Return Merge Request for project
projectMergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.projectMergeRequestPath)
......
......@@ -3,10 +3,8 @@ import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContent from '~/blob/components/blob_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
......@@ -16,25 +14,9 @@ export default {
BlobEmbeddable,
BlobHeader,
BlobContent,
GlLoadingIcon,
CloneDropdownButton,
},
apollo: {
blob: {
query: GetSnippetBlobQuery,
variables() {
return {
ids: this.snippet.id,
};
},
update: data => data.snippets.edges[0].node.blob,
result(res) {
const viewer = res.data.snippets.edges[0].node.blob.richViewer
? RICH_BLOB_VIEWER
: SIMPLE_BLOB_VIEWER;
this.switchViewer(viewer, true);
},
},
blobContent: {
query: GetBlobContent,
variables() {
......@@ -55,18 +37,18 @@ export default {
},
data() {
return {
blob: {},
blob: this.snippet.blob,
blobContent: '',
activeViewerType: window.location.hash ? SIMPLE_BLOB_VIEWER : '',
activeViewerType:
this.snippet.blob?.richViewer && !window.location.hash
? RICH_BLOB_VIEWER
: SIMPLE_BLOB_VIEWER,
};
},
computed: {
embeddable() {
return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
},
isBlobLoading() {
return this.$apollo.queries.blob.loading;
},
isContentLoading() {
return this.$apollo.queries.blobContent.loading;
},
......@@ -79,8 +61,8 @@ export default {
},
},
methods: {
switchViewer(newViewer, respectHash = false) {
this.activeViewerType = respectHash && window.location.hash ? SIMPLE_BLOB_VIEWER : newViewer;
switchViewer(newViewer) {
this.activeViewerType = newViewer;
},
},
};
......@@ -88,13 +70,7 @@ export default {
<template>
<div>
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
<gl-loading-icon
v-if="isBlobLoading"
:label="__('Loading blob')"
size="lg"
class="prepend-top-20 append-bottom-20"
/>
<article v-else class="file-holder snippet-file-content">
<article class="file-holder snippet-file-content">
<blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer">
<template #actions>
<clone-dropdown-button
......
#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
fragment SnippetBase on Snippet {
id
title
......@@ -9,6 +11,19 @@ fragment SnippetBase on Snippet {
webUrl
httpUrlToRepo
sshUrlToRepo
blob {
binary
name
path
rawPath
size
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
userPermissions {
adminSnippet
updateSnippet
......
#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
query SnippetBlobFull($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
id
blob {
binary
name
path
rawPath
size
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
}
}
}
}
import { s__ } from '~/locale';
export const BRANCH_SUFFIX_COUNT = 8;
export const DEFAULT_TARGET_BRANCH = 'master';
export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.');
export const SUBMIT_CHANGES_COMMIT_ERROR = s__(
'StaticSiteEditor|Could not commit the content changes.',
);
export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
'StaticSiteEditor|Could not create merge request.',
);
import { BRANCH_SUFFIX_COUNT, DEFAULT_TARGET_BRANCH } from '../constants';
const generateBranchSuffix = () => `${Date.now()}`.substr(BRANCH_SUFFIX_COUNT);
const generateBranchName = (username, targetBranch = DEFAULT_TARGET_BRANCH) =>
`${username}-${targetBranch}-patch-${generateBranchSuffix()}`;
export default generateBranchName;
// TODO implement
const submitContentChanges = () => new Promise(resolve => setTimeout(resolve, 1000));
import Api from '~/api';
import { s__, sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import {
DEFAULT_TARGET_BRANCH,
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
} from '../constants';
const createBranch = (projectId, branch) =>
Api.createBranch(projectId, {
ref: DEFAULT_TARGET_BRANCH,
branch,
}).catch(() => {
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
});
const commitContent = (projectId, message, branch, sourcePath, content) =>
Api.commitMultiple(
projectId,
convertObjectPropsToSnakeCase({
branch,
commitMessage: message,
actions: [
convertObjectPropsToSnakeCase({
action: 'update',
filePath: sourcePath,
content,
}),
],
}),
).catch(() => {
throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
});
const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) =>
Api.createProjectMergeRequest(
projectId,
convertObjectPropsToSnakeCase({
title,
sourceBranch,
targetBranch,
}),
).catch(() => {
throw new Error(SUBMIT_CHANGES_MERGE_REQUEST_ERROR);
});
const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
const branch = generateBranchName(username);
const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
sourcePath,
});
const meta = {};
return createBranch(projectId, branch)
.then(() => {
Object.assign(meta, { branch: { label: branch } });
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content);
})
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
return createMergeRequest(projectId, mergeRequestTitle, branch);
})
.then(({ data: { iid: label, web_url: url } }) => {
Object.assign(meta, { mergeRequest: { label, url } });
return meta;
});
};
export default submitContentChanges;
......@@ -10,6 +10,8 @@ const createState = (initialState = {}) => ({
content: '',
title: '',
savedContentMeta: null,
...initialState,
});
......
......@@ -20,8 +20,17 @@ module Resolvers
args[:iids] ||= [args[:iid]].compact
args[:iids].map { |iid| batch_load(iid) }
.select(&:itself) # .compact doesn't work on BatchLoader
if args[:iids].any?
batch_load_merge_requests(args[:iids])
else
args[:project_id] = project.id
MergeRequestsFinder.new(context[:current_user], args).execute
end
end
def batch_load_merge_requests(iids)
iids.map { |iid| batch_load(iid) }.select(&:itself) # .compact doesn't work on BatchLoader
end
# rubocop: disable CodeReuse/ActiveRecord
......
---
title: Save changes in Static Site Editor using REST GitLab API
merge_request: 29286
author:
type: added
---
title: Optimize protected branches usage data
merge_request: 29148
author:
type: performance
---
title: Fix pagination in Merge Request GraphQL api
merge_request: 28667
author: briankabiro
type: fixed
......@@ -26,6 +26,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
scope '-' do
get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive'
scope controller: :static_site_editor do
get '/sse/*id', action: :show, as: :show_sse
end
resources :artifacts, only: [:index, :destroy]
resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
......
......@@ -67,10 +67,6 @@ scope format: false do
end
end
scope controller: :static_site_editor do
get '/sse/*id', action: :show, as: :show_sse
end
get '/tree/*id', to: 'tree#show', as: :tree
get '/raw/*id', to: 'raw#show', as: :raw
get '/blame/*id', to: 'blame#show', as: :blame
......
# frozen_string_literal: true
class AddIndexOnCreatorIdCreatedAtIdToProjectsTable < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :projects, [:creator_id, :created_at, :id]
end
def down
remove_concurrent_index :projects, [:creator_id, :created_at, :id]
end
end
......@@ -9884,6 +9884,8 @@ CREATE INDEX index_projects_on_created_at_and_id ON public.projects USING btree
CREATE INDEX index_projects_on_creator_id_and_created_at ON public.projects USING btree (creator_id, created_at);
CREATE INDEX index_projects_on_creator_id_and_created_at_and_id ON public.projects USING btree (creator_id, created_at, id);
CREATE INDEX index_projects_on_description_trigram ON public.projects USING gin (description public.gin_trgm_ops);
CREATE INDEX index_projects_on_id_and_archived_and_pending_delete ON public.projects USING btree (id) WHERE ((archived = false) AND (pending_delete = false));
......@@ -13092,5 +13094,6 @@ COPY "schema_migrations" (version) FROM STDIN;
20200407094005
20200407094923
20200408110856
20200408175424
\.
......@@ -468,12 +468,16 @@ config.
Manual failover is possible by updating `praefect['virtual_storages']` and
nominating a new primary node.
NOTE: **Note:**: Automatic failover is not yet supported for setups with
multiple Praefect nodes. There is currently no coordination between Praefect
nodes, which could result in two Praefect instances thinking two different
Gitaly nodes are the primary. Follow issue
[#2547](https://gitlab.com/gitlab-org/gitaly/-/issues/2547) for
updates.
1. By default, Praefect will nominate a primary Gitaly node for each
shard and store the state of the primary in local memory. This state
does not persist across restarts and will cause a split brain
if multiple Praefect nodes are used for redundancy.
To avoid this limitation, enable the SQL election strategy:
```ruby
praefect['failover_election_strategy'] = 'sql'
```
1. Save the changes to `/etc/gitlab/gitlab.rb` and [reconfigure
Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure):
......@@ -677,8 +681,18 @@ current primary node is found to be unhealthy.
checks fail for the current primary backend Gitaly node, and new primary will
be elected. **Do not use with multiple Praefect nodes!** Using with multiple
Praefect nodes is likely to result in a split brain.
- **PostgreSQL:** Coming soon. See isse
[#2547](https://gitlab.com/gitlab-org/gitaly/-/issues/2547) for updates.
- **PostgreSQL:** Enabled by setting
`praefect['failover_election_strategy'] = sql`. This configuration
option will allow multiple Praefect nodes to coordinate via the
PostgreSQL database to elect a primary Gitaly node. This configuration
will cause Praefect nodes to elect a new primary, monitor its health,
and elect a new primary if the current one has not been reachable in
10 seconds by a majority of the Praefect nodes.
NOTE: **Note:**: Praefect does not yet account for replication lag on
the secondaries during the election process, so data loss can occur
during a failover. Follow issue
[#2642](https://gitlab.com/gitlab-org/gitaly/-/issues/2642) for updates.
It is likely that we will implement support for Consul, and a cloud native
strategy in the future.
......
......@@ -5,7 +5,7 @@ module API
class ProjectImportStatus < ProjectIdentity
expose :import_status
expose :correlation_id do |project, _options|
project.import_state.correlation_id
project.import_state&.correlation_id
end
# TODO: Use `expose_nil` once we upgrade the grape-entity gem
......
......@@ -98,7 +98,6 @@ module Gitlab
preview
raw
refs
sse
tree
update
wikis
......
......@@ -12189,9 +12189,6 @@ msgstr ""
msgid "Loading"
msgstr ""
msgid "Loading blob"
msgstr ""
msgid "Loading contribution stats for group members"
msgstr ""
......@@ -19368,6 +19365,15 @@ msgstr ""
msgid "Static Application Security Testing (SAST)"
msgstr ""
msgid "StaticSiteEditor|Branch could not be created."
msgstr ""
msgid "StaticSiteEditor|Could not commit the content changes."
msgstr ""
msgid "StaticSiteEditor|Could not create merge request."
msgstr ""
msgid "StaticSiteEditor|Return to site"
msgstr ""
......@@ -19377,6 +19383,9 @@ msgstr ""
msgid "StaticSiteEditor|Summary of changes"
msgstr ""
msgid "StaticSiteEditor|Update %{sourcePath} file"
msgstr ""
msgid "StaticSiteEditor|View merge request"
msgstr ""
......
......@@ -651,7 +651,7 @@ describe('Api', () => {
describe('when an error occurs while getting a raw file', () => {
it('rejects the Promise', () => {
mock.onDelete(expectedUrl).replyOnce(500);
mock.onPost(expectedUrl).replyOnce(500);
return Api.getRawFile(dummyProjectPath, dummyFilePath).catch(() => {
expect(mock.history.get).toHaveLength(1);
......@@ -659,4 +659,36 @@ describe('Api', () => {
});
});
});
describe('createProjectMergeRequest', () => {
const dummyProjectPath = 'gitlab-org/gitlab';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent(
dummyProjectPath,
)}/merge_requests`;
const options = {
source_branch: 'feature',
target_branch: 'master',
title: 'Add feature',
};
describe('when the merge request is successfully created', () => {
it('resolves the Promise', () => {
mock.onPost(expectedUrl, options).replyOnce(201);
return Api.createProjectMergeRequest(dummyProjectPath, options).then(() => {
expect(mock.history.post).toHaveLength(1);
});
});
});
describe('when an error occurs while getting a raw file', () => {
it('rejects the Promise', () => {
mock.onPost(expectedUrl).replyOnce(500);
return Api.createProjectMergeRequest(dummyProjectPath).catch(() => {
expect(mock.history.post).toHaveLength(1);
});
});
});
});
});
import { mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
......@@ -19,23 +18,15 @@ describe('Blob Embeddable', () => {
id: 'gid://foo.bar/snippet',
webUrl: 'https://foo.bar',
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
blob: BlobMock,
};
const dataMock = {
blob: BlobMock,
activeViewerType: SimpleViewerMock.type,
};
function createComponent(
props = {},
data = dataMock,
blobLoading = false,
contentLoading = false,
) {
function createComponent(props = {}, data = dataMock, contentLoading = false) {
const $apollo = {
queries: {
blob: {
loading: blobLoading,
},
blobContent: {
loading: contentLoading,
},
......@@ -87,12 +78,6 @@ describe('Blob Embeddable', () => {
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
});
it('shows loading icon while blob data is in flight', () => {
createComponent({}, dataMock, true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find('.snippet-file-content').exists()).toBe(false);
});
it('sets simple viewer correctly', () => {
createComponent();
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
......@@ -133,14 +118,14 @@ describe('Blob Embeddable', () => {
});
it('renders simple viewer by default if URL contains hash', () => {
createComponent();
createComponent({}, {});
expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
describe('switchViewer()', () => {
it('by default switches to the passed viewer', () => {
it('switches to the passed viewer', () => {
createComponent();
wrapper.vm.switchViewer(RichViewerMock.type);
......@@ -157,22 +142,6 @@ describe('Blob Embeddable', () => {
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
});
it('respects hash over richViewer in the blob when corresponding parameter is passed', () => {
createComponent(
{},
{
blob: BlobMock,
},
);
expect(wrapper.vm.blob.richViewer).toEqual(expect.any(Object));
wrapper.vm.switchViewer(RichViewerMock.type, true);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
});
});
});
});
......
......@@ -34,3 +34,11 @@ export const savedContentMeta = {
};
export const submitChangesError = 'Could not save changes';
export const commitMultipleResponse = {
short_id: 'ed899a2f4b5',
web_url: '/commit/ed899a2f4b5',
};
export const createMergeRequestResponse = {
iid: '123',
web_url: '/merge_requests/123',
};
import { DEFAULT_TARGET_BRANCH, BRANCH_SUFFIX_COUNT } from '~/static_site_editor/constants';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import { username } from '../mock_data';
describe('generateBranchName', () => {
const timestamp = 12345678901234;
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValueOnce(timestamp);
});
it('generates a name that includes the username and target branch', () => {
expect(generateBranchName(username)).toMatch(`${username}-${DEFAULT_TARGET_BRANCH}`);
});
it(`adds the first ${BRANCH_SUFFIX_COUNT} numbers of the current timestamp`, () => {
expect(generateBranchName(username)).toMatch(
timestamp.toString().substring(BRANCH_SUFFIX_COUNT),
);
});
});
import Api from '~/api';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import {
DEFAULT_TARGET_BRANCH,
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
} from '~/static_site_editor/constants';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
import {
username,
projectId,
commitMultipleResponse,
createMergeRequestResponse,
sourcePath,
sourceContent as content,
} from '../mock_data';
jest.mock('~/static_site_editor/services/generate_branch_name');
describe('submitContentChanges', () => {
const mergeRequestTitle = `Update ${sourcePath} file`;
const branch = 'branch-name';
beforeEach(() => {
jest.spyOn(Api, 'createBranch').mockResolvedValue();
jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse });
jest
.spyOn(Api, 'createProjectMergeRequest')
.mockResolvedValue({ data: createMergeRequestResponse });
generateBranchName.mockReturnValue(branch);
});
it('creates a branch named after the username and target branch', () => {
return submitContentChanges({ username, projectId }).then(() => {
expect(Api.createBranch).toHaveBeenCalledWith(projectId, {
ref: DEFAULT_TARGET_BRANCH,
branch,
});
});
});
it('notifies error when branch could not be created', () => {
Api.createBranch.mockRejectedValueOnce();
expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_BRANCH_ERROR,
);
});
it('commits the content changes to the branch when creating branch succeeds', () => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
commit_message: mergeRequestTitle,
actions: [
{
action: 'update',
file_path: sourcePath,
content,
},
],
});
});
});
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
it('creates a merge request when commiting changes succeeds', () => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
expect(Api.createProjectMergeRequest).toHaveBeenCalledWith(
projectId,
convertObjectPropsToSnakeCase({
title: mergeRequestTitle,
targetBranch: DEFAULT_TARGET_BRANCH,
sourceBranch: branch,
}),
);
});
});
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
describe('when changes are submitted successfully', () => {
let result;
beforeEach(() => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(_result => {
result = _result;
});
});
it('returns the branch name', () => {
expect(result).toMatchObject({ branch: { label: branch } });
});
it('returns commit short id and web url', () => {
expect(result).toMatchObject({
commit: {
label: commitMultipleResponse.short_id,
url: commitMultipleResponse.web_url,
},
});
});
it('returns merge request iid and web url', () => {
expect(result).toMatchObject({
mergeRequest: {
label: createMergeRequestResponse.iid,
url: createMergeRequestResponse.web_url,
},
});
});
});
});
......@@ -5,7 +5,7 @@ require 'spec_helper'
describe 'getting notes for a merge request' do
include GraphqlHelpers
let(:noteable) { create(:merge_request) }
let_it_be(:noteable) { create(:merge_request) }
def noteable_query(noteable_fields)
<<~QRY
......
......@@ -93,4 +93,41 @@ describe 'getting merge request information nested in a project' do
expect(merge_request_graphql_data['pipelines']['edges'].size).to eq(1)
end
end
context 'when limiting the number of results' do
let(:merge_requests_graphql_data) { graphql_data['project']['mergeRequests']['edges'] }
let!(:merge_requests) do
[
create(:merge_request, source_project: project, source_branch: 'branch-1'),
create(:merge_request, source_project: project, source_branch: 'branch-2'),
create(:merge_request, source_project: project, source_branch: 'branch-3')
]
end
let(:fields) do
<<~QUERY
edges {
node {
iid,
title
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
"mergeRequests(first: 2) { #{fields} }"
)
end
it 'returns the correct number of results' do
post_graphql(query, current_user: current_user)
expect(merge_requests_graphql_data.size).to eq 2
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment