Skip to content

Commit

Permalink
feat(backend-gitlab): initial GraphQL support (#6059)
Browse files Browse the repository at this point in the history
  • Loading branch information
erezrokah authored Dec 28, 2021
1 parent a83dba7 commit 1523a41
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 7 deletions.
4 changes: 2 additions & 2 deletions packages/netlify-cms-backend-git-gateway/src/GitLabAPI.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { API as GitlabAPI } from 'netlify-cms-backend-gitlab';
import { unsentRequest } from 'netlify-cms-lib-util';

import type { Config as GitHubConfig, CommitAuthor } from 'netlify-cms-backend-gitlab/src/API';
import type { Config as GitLabConfig, CommitAuthor } from 'netlify-cms-backend-gitlab/src/API';
import type { ApiRequest } from 'netlify-cms-lib-util';

type Config = GitHubConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
type Config = GitLabConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };

export default class API extends GitlabAPI {
tokenPromise: () => Promise<string>;
Expand Down
4 changes: 4 additions & 0 deletions packages/netlify-cms-backend-gitlab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
},
"dependencies": {
"apollo-cache-inmemory": "^1.6.2",
"apollo-client": "^2.6.3",
"apollo-link-context": "^1.0.18",
"apollo-link-http": "^1.5.15",
"js-base64": "^3.0.0",
"semaphore": "^1.1.0"
},
Expand Down
156 changes: 155 additions & 1 deletion packages/netlify-cms-backend-gitlab/src/API.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { createHttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import {
localForage,
parseLinkHeader,
Expand Down Expand Up @@ -27,24 +31,32 @@ import { Map } from 'immutable';
import { flow, partial, result, trimStart } from 'lodash';
import { dirname } from 'path';

const NO_CACHE = 'no-cache';
import * as queries from './queries';

import type { ApolloQueryResult } from 'apollo-client';
import type { NormalizedCacheObject } from 'apollo-cache-inmemory';
import type {
ApiRequest,
DataFile,
AssetProxy,
PersistOptions,
FetchError,
ImplementationFile,
} from 'netlify-cms-lib-util';

export const API_NAME = 'GitLab';

export interface Config {
apiRoot?: string;
graphQLAPIRoot?: string;
token?: string;
branch?: string;
repo?: string;
squashMerges: boolean;
initialWorkflowStatus: string;
cmsLabelPrefix: string;
useGraphQL?: boolean;
}

export interface CommitAuthor {
Expand All @@ -66,6 +78,8 @@ type CommitItem = {
action: CommitAction;
};

type FileEntry = { id: string; type: string; path: string; name: string };

interface CommitsParams {
commit_message: string;
branch: string;
Expand Down Expand Up @@ -183,8 +197,16 @@ export function getMaxAccess(groups: { group_access_level: number }[]) {
}, groups[0]);
}

function batch<T>(items: T[], maxPerBatch: number, action: (items: T[]) => void) {
for (let index = 0; index < items.length; index = index + maxPerBatch) {
const itemsSlice = items.slice(index, index + maxPerBatch);
action(itemsSlice);
}
}

export default class API {
apiRoot: string;
graphQLAPIRoot: string;
token: string | boolean;
branch: string;
useOpenAuthoring?: boolean;
Expand All @@ -195,15 +217,52 @@ export default class API {
initialWorkflowStatus: string;
cmsLabelPrefix: string;

graphQLClient?: ApolloClient<NormalizedCacheObject>;

constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4';
this.graphQLAPIRoot = config.graphQLAPIRoot || 'https://gitlab.com/api/graphql';
this.token = config.token || false;
this.branch = config.branch || 'master';
this.repo = config.repo || '';
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
this.squashMerges = config.squashMerges;
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
if (config.useGraphQL === true) {
this.graphQLClient = this.getApolloClient();
}
}

getApolloClient() {
const authLink = setContext((_, { headers }) => {
return {
headers: {
'Content-Type': 'application/json; charset=utf-8',
...headers,
authorization: this.token ? `token ${this.token}` : '',
},
};
});
const httpLink = createHttpLink({ uri: this.graphQLAPIRoot });
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: NO_CACHE,
errorPolicy: 'ignore',
},
query: {
fetchPolicy: NO_CACHE,
errorPolicy: 'all',
},
},
});
}

reset() {
return this.graphQLClient?.resetStore();
}

withAuthorizationHeaders = (req: ApiRequest) => {
Expand Down Expand Up @@ -352,7 +411,7 @@ export default class API {
fetchCursorAndEntries = (
req: ApiRequest,
): Promise<{
entries: { id: string; type: string; path: string; name: string }[];
entries: FileEntry[];
cursor: Cursor;
}> =>
flow([
Expand Down Expand Up @@ -392,7 +451,102 @@ export default class API {
};
};

listAllFilesGraphQL = async (path: string, recursive: boolean, branch: String) => {
const files: FileEntry[] = [];
let blobsPaths;
let cursor;
do {
blobsPaths = await this.graphQLClient!.query({
query: queries.files,
variables: { repo: this.repo, branch, path, recursive, cursor },
});
files.push(...blobsPaths.data.project.repository.tree.blobs.nodes);
cursor = blobsPaths.data.project.repository.tree.blobs.pageInfo.endCursor;
} while (blobsPaths.data.project.repository.tree.blobs.pageInfo.hasNextPage);

return files;
};

readFilesGraphQL = async (files: ImplementationFile[]) => {
const paths = files.map(({ path }) => path);

type BlobResult = {
project: { repository: { blobs: { nodes: { id: string; data: string }[] } } };
};

const blobPromises: Promise<ApolloQueryResult<BlobResult>>[] = [];
batch(paths, 90, slice => {
blobPromises.push(
this.graphQLClient!.query({
query: queries.blobs,
variables: {
repo: this.repo,
branch: this.branch,
paths: slice,
},
fetchPolicy: 'cache-first',
}),
);
});

type LastCommit = {
id: string;
authoredDate: string;
authorName: string;
author?: {
name: string;
username: string;
publicEmail: string;
};
};

type CommitResult = {
project: { repository: { [tree: string]: { lastCommit: LastCommit } } };
};

const commitPromises: Promise<ApolloQueryResult<CommitResult>>[] = [];
batch(paths, 8, slice => {
commitPromises.push(
this.graphQLClient!.query({
query: queries.lastCommits(slice),
variables: {
repo: this.repo,
branch: this.branch,
},
fetchPolicy: 'cache-first',
}),
);
});

const [blobsResults, commitsResults] = await Promise.all([
(await Promise.all(blobPromises)).map(result => result.data.project.repository.blobs.nodes),
(
await Promise.all(commitPromises)
).map(
result =>
Object.values(result.data.project.repository)
.map(({ lastCommit }) => lastCommit)
.filter(Boolean) as LastCommit[],
),
]);

const blobs = blobsResults.flat().map(result => result.data) as string[];
const metadata = commitsResults.flat().map(({ author, authoredDate, authorName }) => ({
author: author ? author.name || author.username || author.publicEmail : authorName,
updatedOn: authoredDate,
}));

const filesWithData = files.map((file, index) => ({
file: { ...file, ...metadata[index] },
data: blobs[index],
}));
return filesWithData;
};

listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
if (this.graphQLClient) {
return await this.listAllFilesGraphQL(path, recursive, branch);
}
const entries = [];
// eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
Expand Down
8 changes: 8 additions & 0 deletions packages/netlify-cms-backend-gitlab/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export default class GitLab implements Implementation {
cmsLabelPrefix: string;
mediaFolder: string;
previewContext: string;
useGraphQL: boolean;
graphQLAPIRoot: string;

_mediaDisplayURLSem?: Semaphore;

Expand Down Expand Up @@ -88,6 +90,8 @@ export default class GitLab implements Implementation {
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || '';
this.useGraphQL = config.backend.use_graphql || false;
this.graphQLAPIRoot = config.backend.graphql_api_root || 'https://gitlab.com/api/graphql';
this.lock = asyncLock();
}

Expand Down Expand Up @@ -126,6 +130,8 @@ export default class GitLab implements Implementation {
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
useGraphQL: this.useGraphQL,
graphQLAPIRoot: this.graphQLAPIRoot,
});
const user = await this.api.user();
const isCollab = await this.api.hasWriteAccess().catch((error: Error) => {
Expand Down Expand Up @@ -212,7 +218,9 @@ export default class GitLab implements Implementation {
getDifferences: (to, from) => this.api!.getDifferences(to, from),
getFileId: path => this.api!.getFileId(path, this.branch),
filterFile: file => this.filterFile(folder, file, extension, depth),
customFetch: this.useGraphQL ? files => this.api!.readFilesGraphQL(files) : undefined,
});

return files;
}

Expand Down
73 changes: 73 additions & 0 deletions packages/netlify-cms-backend-gitlab/src/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { gql } from 'graphql-tag';
import { oneLine } from 'common-tags';

export const files = gql`
query files($repo: ID!, $branch: String!, $path: String!, $recursive: Boolean!, $cursor: String) {
project(fullPath: $repo) {
repository {
tree(ref: $branch, path: $path, recursive: $recursive) {
blobs(after: $cursor) {
nodes {
type
id: sha
path
name
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
}
`;

export const blobs = gql`
query blobs($repo: ID!, $branch: String!, $paths: [String!]!) {
project(fullPath: $repo) {
repository {
blobs(ref: $branch, paths: $paths) {
nodes {
id
data: rawBlob
}
}
}
}
}
`;

export function lastCommits(paths: string[]) {
const tree = paths
.map(
(path, index) => oneLine`
tree${index}: tree(ref: $branch, path: "${path}") {
lastCommit {
authorName
authoredDate
author {
id
username
name
publicEmail
}
}
}
`,
)
.join('\n');

const query = gql`
query lastCommits($repo: ID!, $branch: String!) {
project(fullPath: $repo) {
repository {
${tree}
}
}
}
`;

return query;
}
Loading

0 comments on commit 1523a41

Please sign in to comment.