Feat: editorial workflow bitbucket gitlab (#3014)

* refactor: typescript the backends

* feat: support multiple files upload for GitLab and BitBucket

* fix: load entry media files from media folder or UI state

* chore: cleanup log message

* chore: code cleanup

* refactor: typescript the test backend

* refactor: cleanup getEntry unsued variables

* refactor: moved shared backend code to lib util

* chore: rename files to preserve history

* fix: bind readFile method to API classes

* test(e2e): switch to chrome in cypress tests

* refactor: extract common api methods

* refactor: remove most of immutable js usage from backends

* feat(backend-gitlab): initial editorial workflow support

* feat(backend-gitlab): implement missing workflow methods

* chore: fix lint error

* feat(backend-gitlab): support files deletion

* test(e2e): add gitlab cypress tests

* feat(backend-bitbucket): implement missing editorial workflow methods

* test(e2e): add BitBucket backend e2e tests

* build: update node version to 12 on netlify builds

* fix(backend-bitbucket): extract BitBucket avatar url

* test: fix git-gateway AuthenticationPage test

* test(e2e): fix some backend tests

* test(e2e): fix tests

* test(e2e): add git-gateway editorial workflow test

* chore: code cleanup

* test(e2e): revert back to electron

* test(e2e): add non editorial workflow tests

* fix(git-gateway-gitlab): don't call unpublishedEntry in simple workflow

gitlab git-gateway doesn't support editorial workflow APIs yet. This change makes sure not to call them in simple workflow

* refactor(backend-bitbucket): switch to diffstat API instead of raw diff

* chore: fix test

* test(e2e): add more git-gateway tests

* fix: post rebase typescript fixes

* test(e2e): fix tests

* fix: fix parsing of content key and add tests

* refactor: rename test file

* test(unit): add getStatues unit tests

* chore: update cypress

* docs: update beta docs
This commit is contained in:
Erez Rokah
2020-01-15 00:15:14 +02:00
committed by Shawn Erquhart
parent 4ff5bc2ee0
commit 6f221ab3c1
251 changed files with 70910 additions and 15974 deletions

View File

@ -9,14 +9,21 @@ import {
flowAsync,
localForage,
onlySuccessfulPromises,
resolvePromiseProperties,
ResponseParser,
basename,
AssetProxy,
Entry as LibEntry,
PersistOptions,
readFile,
CMS_BRANCH_PREFIX,
generateContentKey,
DEFAULT_PR_BODY,
MERGE_COMMIT_MESSAGE,
PreviewState,
FetchError,
} from 'netlify-cms-lib-util';
import {
UsersGetAuthenticatedResponse as GitHubUser,
ReposGetResponse as GitHubRepo,
ReposGetContentsResponseItem as GitHubFile,
ReposGetBranchResponse as GitHubBranch,
GitGetBlobResponse as GitHubBlob,
GitCreateTreeResponse as GitHubTree,
@ -28,35 +35,33 @@ import {
ReposCompareCommitsResponseBaseCommit as GitHubCompareBaseCommit,
GitCreateCommitResponseAuthor as GitHubAuthor,
GitCreateCommitResponseCommitter as GitHubCommiter,
ReposListStatusesForRefResponseItem,
} from '@octokit/rest';
const CMS_BRANCH_PREFIX = 'cms';
const CURRENT_METADATA_VERSION = '1';
interface FetchError extends Error {
status: number;
}
export const API_NAME = 'GitHub';
interface Config {
api_root?: string;
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
useOpenAuthoring: boolean;
useOpenAuthoring?: boolean;
repo?: string;
originRepo?: string;
squash_merges?: string;
squashMerges: boolean;
initialWorkflowStatus: string;
}
interface File {
interface TreeFile {
type: 'blob' | 'tree';
sha: string;
path: string;
raw?: string;
}
interface Entry extends File {
slug: string;
export interface Entry extends LibEntry {
sha?: string;
}
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
@ -69,18 +74,20 @@ type GitHubCompareFile = ReposCompareCommitsResponseFilesItem & { previous_filen
type GitHubCompareFiles = GitHubCompareFile[];
interface CommitFields {
parents: { sha: string }[];
sha: string;
message: string;
author: string;
committer: string;
tree: { sha: string };
enum GitHubCommitStatusState {
Error = 'error',
Failure = 'failure',
Pending = 'pending',
Success = 'success',
}
interface PR {
type GitHubCommitStatus = ReposListStatusesForRefResponseItem & {
state: GitHubCommitStatusState;
};
export interface PR {
number: number;
head: string;
head: string | { sha: string };
}
interface MetaDataObjects {
@ -88,7 +95,7 @@ interface MetaDataObjects {
files: MediaFile[];
}
interface Metadata {
export interface Metadata {
type: string;
objects: MetaDataObjects;
branch: string;
@ -103,23 +110,16 @@ interface Metadata {
timeStamp: string;
}
interface Branch {
export interface Branch {
ref: string;
}
interface BlobArgs {
export interface BlobArgs {
sha: string;
repoURL: string;
parseText: boolean;
}
interface ContentArgs {
path: string;
branch: string;
repoURL: string;
parseText: boolean;
}
type Param = string | number | undefined;
type Options = RequestInit & { params?: Record<string, Param | Record<string, Param>> };
@ -133,30 +133,21 @@ const replace404WithEmptyArray = (err: FetchError) => {
}
};
type PersistOptions = {
useWorkflow: boolean;
commitMessage: string;
collectionName: string;
unpublished: boolean;
parsedData?: { title: string; description: string };
status: string;
};
type MediaFile = {
sha: string;
path: string;
};
export default class API {
api_root: string;
apiRoot: string;
token: string;
branch: string;
useOpenAuthoring: boolean;
useOpenAuthoring?: boolean;
repo: string;
originRepo: string;
repoURL: string;
originRepoURL: string;
merge_method: string;
mergeMethod: string;
initialWorkflowStatus: string;
_userPromise?: Promise<GitHubUser>;
@ -165,8 +156,7 @@ export default class API {
commitAuthor?: {};
constructor(config: Config) {
// eslint-disable-next-line @typescript-eslint/camelcase
this.api_root = config.api_root || 'https://api.github.com';
this.apiRoot = config.apiRoot || 'https://api.github.com';
this.token = config.token || '';
this.branch = config.branch || 'master';
this.useOpenAuthoring = config.useOpenAuthoring;
@ -175,15 +165,13 @@ export default class API {
this.repoURL = `/repos/${this.repo}`;
// when not in 'useOpenAuthoring' mode originRepoURL === repoURL
this.originRepoURL = `/repos/${this.originRepo}`;
// eslint-disable-next-line @typescript-eslint/camelcase
this.merge_method = config.squash_merges ? 'squash' : 'merge';
this.mergeMethod = config.squashMerges ? 'squash' : 'merge';
this.initialWorkflowStatus = config.initialWorkflowStatus;
}
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Netlify CMS';
static DEFAULT_PR_BODY = 'Automatically generated by Netlify CMS';
user() {
user(): Promise<{ name: string; login: string }> {
if (!this._userPromise) {
this._userPromise = this.request('/user') as Promise<GitHubUser>;
}
@ -199,6 +187,10 @@ export default class API {
});
}
reset() {
// no op
}
requestHeaders(headers = {}) {
const baseHeader: Record<string, string> = {
'Content-Type': 'application/json; charset=utf-8',
@ -207,10 +199,10 @@ export default class API {
if (this.token) {
baseHeader.Authorization = `token ${this.token}`;
return baseHeader;
return Promise.resolve(baseHeader);
}
return baseHeader;
return Promise.resolve(baseHeader);
}
parseJsonResponse(response: Response) {
@ -234,7 +226,7 @@ export default class API {
if (params.length) {
path += `?${params.join('&')}`;
}
return this.api_root + path;
return this.apiRoot + path;
}
parseResponse(response: Response) {
@ -252,16 +244,15 @@ export default class API {
}
handleRequestError(error: FetchError, responseStatus: number) {
throw new APIError(error.message, responseStatus, 'GitHub');
throw new APIError(error.message, responseStatus, API_NAME);
}
async request(
path: string,
options: Options = {},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parser: ResponseParser<any> = response => this.parseResponse(response),
parser = (response: Response) => this.parseResponse(response),
) {
// overriding classes can return a promise from requestHeaders
const headers = await this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus: number;
@ -274,7 +265,6 @@ export default class API {
}
async requestAllPages<T>(url: string, options: Options = {}) {
// overriding classes can return a promise from requestHeaders
const headers = await this.requestHeaders(options.headers || {});
const processedURL = this.urlFor(url, options);
const allResponses = await getAllResponses(processedURL, { ...options, headers });
@ -286,7 +276,7 @@ export default class API {
generateContentKey(collectionName: string, slug: string) {
if (!this.useOpenAuthoring) {
return `${collectionName}/${slug}`;
return generateContentKey(collectionName, slug);
}
return `${this.repo}/${collectionName}/${slug}`;
@ -353,7 +343,7 @@ export default class API {
const file = { path: `${key}.json`, raw: JSON.stringify(data) };
await this.uploadBlob(file);
const changeTree = await this.updateTree(branchData.sha, [file as File]);
const changeTree = await this.updateTree(branchData.sha, [file as TreeFile]);
const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
await this.patchRef('meta', '_netlify_cms', sha);
localForage.setItem(`gh.meta.${key}`, {
@ -433,16 +423,9 @@ export default class API {
});
}
retrieveContent({ path, branch, repoURL, parseText }: ContentArgs) {
return this.request(`${repoURL}/contents/${path}`, {
params: { ref: branch },
cache: 'no-store',
}).then((file: GitHubFile) => this.getBlob({ sha: file.sha, repoURL, parseText }));
}
readFile(
async readFile(
path: string,
sha: string | null,
sha?: string | null,
{
branch = this.branch,
repoURL = this.repoURL,
@ -453,11 +436,12 @@ export default class API {
parseText?: boolean;
} = {},
) {
if (sha) {
return this.getBlob({ sha, repoURL, parseText });
} else {
return this.retrieveContent({ path, branch, repoURL, parseText });
if (!sha) {
sha = await this.getFileSha(path, { repoURL, branch });
}
const fetchContent = () => this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
}
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
@ -479,38 +463,10 @@ export default class API {
}
}
async getMediaAsBlob(sha: string | null, path: string) {
let blob: Blob;
if (path.match(/.svg$/)) {
const text = (await this.readFile(path, sha, { parseText: true })) as string;
blob = new Blob([text], { type: 'image/svg+xml' });
} else {
blob = (await this.readFile(path, sha, { parseText: false })) as Blob;
}
return blob;
}
async getMediaDisplayURL(sha: string, path: string) {
const blob = await this.getMediaAsBlob(sha, path);
return URL.createObjectURL(blob);
}
getBlob({ sha, repoURL = this.repoURL, parseText = true }: BlobArgs) {
const key = parseText ? `gh.${sha}` : `gh.${sha}.blob`;
return localForage.getItem<string | Blob>(key).then(cached => {
if (cached) {
return cached;
}
return this.fetchBlobContent({ sha, repoURL, parseText }).then(result => {
localForage.setItem(key, result);
return result;
});
});
}
async listFiles(path: string, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}) {
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
return this.request(`${repoURL}/git/trees/${branch}:${folder}`, {
// GitHub API supports recursive=1 for getting the entire recursive tree
@ -522,43 +478,50 @@ export default class API {
// filter only files and up to the required depth
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
.map(file => ({
...file,
type: file.type,
id: file.sha,
name: basename(file.path),
path: `${folder}/${file.path}`,
size: file.size,
})),
)
.catch(replace404WithEmptyArray);
}
readUnpublishedBranchFile(contentKey: string) {
const metaDataPromise = this.retrieveMetadata(contentKey).then(data =>
data.objects.entry.path ? data : Promise.reject(null),
);
const repoURL = this.useOpenAuthoring
? `/repos/${contentKey
.split('/')
.slice(0, 2)
.join('/')}`
: this.repoURL;
return resolvePromiseProperties({
metaData: metaDataPromise,
fileData: metaDataPromise.then(data =>
this.readFile(data.objects.entry.path, null, {
branch: data.branch,
async readUnpublishedBranchFile(contentKey: string) {
try {
const metaData = await this.retrieveMetadata(contentKey).then(data =>
data.objects.entry.path ? data : Promise.reject(null),
);
const repoURL = this.useOpenAuthoring
? `/repos/${contentKey
.split('/')
.slice(0, 2)
.join('/')}`
: this.repoURL;
const [fileData, isModification] = await Promise.all([
this.readFile(metaData.objects.entry.path, null, {
branch: metaData.branch,
repoURL,
}),
),
isModification: metaDataPromise.then(data =>
this.isUnpublishedEntryModification(data.objects.entry.path, this.branch),
),
}).catch(() => {
}) as Promise<string>,
this.isUnpublishedEntryModification(metaData.objects.entry.path),
]);
return {
metaData,
fileData,
isModification,
slug: this.slugFromContentKey(contentKey, metaData.collection),
};
} catch (e) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
});
}
}
isUnpublishedEntryModification(path: string, branch: string) {
isUnpublishedEntryModification(path: string) {
return this.readFile(path, null, {
branch,
branch: this.branch,
repoURL: this.originRepoURL,
})
.then(() => true)
@ -635,7 +598,7 @@ export default class API {
const newBranchName = `cms/${newContentKey}`;
// create new branch and pull request in new format
const newBranch = await this.createBranch(newBranchName, (metaData.pr as PR).head);
const newBranch = await this.createBranch(newBranchName, (metaData.pr as PR).head as string);
const pr = await this.createPR(metaData.commitMessage, newBranchName);
// store new metadata
@ -667,7 +630,7 @@ export default class API {
return branch;
}
async listUnpublishedBranches() {
async listUnpublishedBranches(): Promise<Branch[]> {
console.log(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
@ -720,8 +683,16 @@ export default class API {
*/
async getStatuses(sha: string) {
try {
const resp = await this.request(`${this.originRepoURL}/commits/${sha}/status`);
return resp.statuses;
const resp: { statuses: GitHubCommitStatus[] } = await this.request(
`${this.originRepoURL}/commits/${sha}/status`,
);
return resp.statuses.map(s => ({
context: s.context,
// eslint-disable-next-line @typescript-eslint/camelcase
target_url: s.target_url,
state:
s.state === GitHubCommitStatusState.Success ? PreviewState.Success : PreviewState.Other,
}));
} catch (err) {
if (err && err.message && err.message === 'Ref not found') {
return [];
@ -730,26 +701,35 @@ export default class API {
}
}
async persistFiles(entry: Entry, mediaFiles: File[], options: PersistOptions) {
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
const uploadPromises = files.map(file => this.uploadBlob(file));
await Promise.all(uploadPromises);
if (!options.useWorkflow) {
return this.getDefaultBranch()
.then(branchData => this.updateTree(branchData.commit.sha, files))
.then(branchData =>
this.updateTree(branchData.commit.sha, files as { sha: string; path: string }[]),
)
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
} else {
const mediaFilesList = mediaFiles.map(({ sha, path }) => ({
path: trimStart(path, '/'),
sha,
}));
return this.editorialWorkflowGit(files, entry, mediaFilesList, options);
const mediaFilesList = (mediaFiles as { sha: string; path: string }[]).map(
({ sha, path }) => ({
path: trimStart(path, '/'),
sha,
}),
);
return this.editorialWorkflowGit(
files as TreeFile[],
entry as Entry,
mediaFilesList,
options,
);
}
}
getFileSha(path: string, branch: string) {
getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
/**
* We need to request the tree first to get the SHA. We use extended SHA-1
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
@ -760,22 +740,25 @@ export default class API {
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
return this.request(fileDataURL, { cache: 'no-store' }).then(resp => {
const { sha } = resp.tree.find((file: File) => file.path === filename);
return sha;
return this.request(fileDataURL, { cache: 'no-store' }).then((resp: GitHubTree) => {
const file = resp.tree.find(file => file.path === filename);
if (file) {
return file.sha;
}
throw new APIError('Not Found', 404, API_NAME);
});
}
deleteFile(path: string, message: string, options: { branch?: string } = {}) {
deleteFile(path: string, message: string) {
if (this.useOpenAuthoring) {
return Promise.reject('Cannot delete published entries as an Open Authoring user!');
}
const branch = options.branch || this.branch;
const branch = this.branch;
return this.getFileSha(path, branch).then(sha => {
return this.getFileSha(path, { branch }).then(sha => {
const params: { sha: string; message: string; branch: string; author?: { date: string } } = {
sha,
message,
@ -799,12 +782,12 @@ export default class API {
}
async editorialWorkflowGit(
files: File[],
files: TreeFile[],
entry: Entry,
mediaFilesList: MediaFile[],
options: PersistOptions,
) {
const contentKey = this.generateContentKey(options.collectionName, entry.slug);
const contentKey = this.generateContentKey(options.collectionName as string, entry.slug);
const branchName = this.generateBranchName(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
@ -837,14 +820,14 @@ export default class API {
user: user.name || user.login,
status: options.status || this.initialWorkflowStatus,
branch: branchName,
collection: options.collectionName,
collection: options.collectionName as string,
commitMessage: options.commitMessage,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: {
path: entry.path,
sha: entry.sha,
sha: entry.sha as string,
},
files: mediaFilesList,
},
@ -871,7 +854,7 @@ export default class API {
const pr = metadata.pr ? { ...metadata.pr, head: commit.sha } : undefined;
const objects = {
entry: { path: entry.path, sha: entry.sha },
entry: { path: entry.path, sha: entry.sha as string },
files: mediaFilesList,
};
@ -1114,7 +1097,7 @@ export default class API {
method: 'POST',
body: JSON.stringify({
title,
body: API.DEFAULT_PR_BODY,
body: DEFAULT_PR_BODY,
head: headReference,
base: this.branch,
}),
@ -1150,10 +1133,10 @@ export default class API {
method: 'PUT',
body: JSON.stringify({
// eslint-disable-next-line @typescript-eslint/camelcase
commit_message: 'Automatically generated. Merged on Netlify CMS.',
commit_message: MERGE_COMMIT_MESSAGE,
sha: headSha,
// eslint-disable-next-line @typescript-eslint/camelcase
merge_method: this.merge_method,
merge_method: this.mergeMethod,
}),
}).catch(error => {
if (error instanceof APIError && error.status === 405) {
@ -1184,7 +1167,7 @@ export default class API {
return Promise.resolve(Base64.encode(str));
}
uploadBlob(item: { raw?: string; sha?: string }) {
uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const content = result(item, 'toBase64', partial(this.toBase64, item.raw as string));
return content.then(contentBase64 =>