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:
committed by
Shawn Erquhart
parent
4ff5bc2ee0
commit
6f221ab3c1
@ -17,9 +17,10 @@
|
||||
"scripts": {
|
||||
"develop": "yarn build:esm --watch",
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
||||
},
|
||||
"dependencies": {
|
||||
"common-tags": "^1.8.0",
|
||||
"js-base64": "^2.5.1",
|
||||
"semaphore": "^1.1.0"
|
||||
},
|
||||
|
@ -1,193 +0,0 @@
|
||||
import { flow, get } from 'lodash';
|
||||
import {
|
||||
localForage,
|
||||
unsentRequest,
|
||||
responseParser,
|
||||
then,
|
||||
basename,
|
||||
Cursor,
|
||||
APIError,
|
||||
} from 'netlify-cms-lib-util';
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || 'https://api.bitbucket.org/2.0';
|
||||
this.branch = config.branch || 'master';
|
||||
this.repo = config.repo || '';
|
||||
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
|
||||
// Allow overriding this.hasWriteAccess
|
||||
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
|
||||
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
|
||||
}
|
||||
|
||||
buildRequest = req =>
|
||||
flow([unsentRequest.withRoot(this.api_root), unsentRequest.withTimestamp])(req);
|
||||
|
||||
request = req =>
|
||||
flow([
|
||||
this.buildRequest,
|
||||
this.requestFunction,
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
|
||||
requestJSON = req =>
|
||||
flow([
|
||||
unsentRequest.withDefaultHeaders({ 'Content-Type': 'application/json' }),
|
||||
this.request,
|
||||
then(responseParser({ format: 'json' })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
requestText = req =>
|
||||
flow([
|
||||
unsentRequest.withDefaultHeaders({ 'Content-Type': 'text/plain' }),
|
||||
this.request,
|
||||
then(responseParser({ format: 'text' })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
|
||||
user = () => this.requestJSON('/user');
|
||||
|
||||
hasWriteAccess = async () => {
|
||||
const response = await this.request(this.repoURL);
|
||||
if (response.status === 404) {
|
||||
throw Error('Repo not found');
|
||||
}
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
branchCommitSha = async () => {
|
||||
const {
|
||||
target: { hash: branchSha },
|
||||
} = await this.requestJSON(`${this.repoURL}/refs/branches/${this.branch}`);
|
||||
return branchSha;
|
||||
};
|
||||
|
||||
isFile = ({ type }) => type === 'commit_file';
|
||||
processFile = file => ({
|
||||
...file,
|
||||
name: basename(file.path),
|
||||
|
||||
// BitBucket does not return file SHAs, but it does give us the
|
||||
// commit SHA. Since the commit SHA will change if any files do,
|
||||
// we can construct an ID using the commit SHA and the file path
|
||||
// that will help with caching (though not as well as a normal
|
||||
// SHA, since it will change even if the individual file itself
|
||||
// doesn't.)
|
||||
...(file.commit && file.commit.hash ? { id: `${file.commit.hash}/${file.path}` } : {}),
|
||||
});
|
||||
processFiles = files => files.filter(this.isFile).map(this.processFile);
|
||||
|
||||
readFile = async (path, sha, { parseText = true } = {}) => {
|
||||
const cacheKey = parseText ? `bb.${sha}` : `bb.${sha}.blob`;
|
||||
const cachedFile = sha ? await localForage.getItem(cacheKey) : null;
|
||||
if (cachedFile) {
|
||||
return cachedFile;
|
||||
}
|
||||
const node = await this.branchCommitSha();
|
||||
const result = await this.request({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
cache: 'no-store',
|
||||
}).then(parseText ? responseParser({ format: 'text' }) : responseParser({ format: 'blob' }));
|
||||
if (sha) {
|
||||
localForage.setItem(cacheKey, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
getEntriesAndCursor = jsonResponse => {
|
||||
const {
|
||||
size: count,
|
||||
page: index,
|
||||
pagelen: pageSize,
|
||||
next,
|
||||
previous: prev,
|
||||
values: entries,
|
||||
} = jsonResponse;
|
||||
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
|
||||
return {
|
||||
entries,
|
||||
cursor: Cursor.create({
|
||||
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
data: { links: { next, prev } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
listFiles = async (path, depth = 1) => {
|
||||
const node = await this.branchCommitSha();
|
||||
const { entries, cursor } = await flow([
|
||||
// sort files by filename ascending
|
||||
unsentRequest.withParams({ sort: '-path', max_depth: depth }),
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
])(`${this.repoURL}/src/${node}/${path}`);
|
||||
return { entries: this.processFiles(entries), cursor };
|
||||
};
|
||||
|
||||
traverseCursor = async (cursor, action) =>
|
||||
flow([
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
then(({ cursor: newCursor, entries }) => ({
|
||||
cursor: newCursor,
|
||||
entries: this.processFiles(entries),
|
||||
})),
|
||||
])(cursor.data.getIn(['links', action]));
|
||||
|
||||
listAllFiles = async (path, depth = 1) => {
|
||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(path, depth);
|
||||
const entries = [...initialEntries];
|
||||
let currentCursor = initialCursor;
|
||||
while (currentCursor && currentCursor.actions.has('next')) {
|
||||
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
|
||||
currentCursor,
|
||||
'next',
|
||||
);
|
||||
entries.push(...newEntries);
|
||||
currentCursor = newCursor;
|
||||
}
|
||||
return this.processFiles(entries);
|
||||
};
|
||||
|
||||
uploadBlob = (item, { commitMessage, branch = this.branch } = {}) => {
|
||||
const contentBlob = get(item, 'fileObj', new Blob([item.raw]));
|
||||
const formData = new FormData();
|
||||
// Third param is filename header, in case path is `message`, `branch`, etc.
|
||||
formData.append(item.path, contentBlob, basename(item.path));
|
||||
formData.append('branch', branch);
|
||||
if (commitMessage) {
|
||||
formData.append('message', commitMessage);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
formData.append('author', `${name} <${email}>`);
|
||||
}
|
||||
|
||||
return flow([
|
||||
unsentRequest.withMethod('POST'),
|
||||
unsentRequest.withBody(formData),
|
||||
this.request,
|
||||
then(() => ({ ...item })),
|
||||
])(`${this.repoURL}/src`);
|
||||
};
|
||||
|
||||
persistFiles = (files, { commitMessage }) =>
|
||||
Promise.all(files.map(file => this.uploadBlob(file, { commitMessage })));
|
||||
|
||||
deleteFile = (path, message, { branch = this.branch } = {}) => {
|
||||
const body = new FormData();
|
||||
body.append('files', path);
|
||||
body.append('branch', branch);
|
||||
if (message) {
|
||||
body.append('message', message);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
body.append('author', `${name} <${email}>`);
|
||||
}
|
||||
return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])(
|
||||
`${this.repoURL}/src`,
|
||||
);
|
||||
};
|
||||
}
|
695
packages/netlify-cms-backend-bitbucket/src/API.ts
Normal file
695
packages/netlify-cms-backend-bitbucket/src/API.ts
Normal file
@ -0,0 +1,695 @@
|
||||
import { flow, get } from 'lodash';
|
||||
import {
|
||||
localForage,
|
||||
unsentRequest,
|
||||
responseParser,
|
||||
then,
|
||||
basename,
|
||||
Cursor,
|
||||
APIError,
|
||||
ApiRequest,
|
||||
AssetProxy,
|
||||
Entry,
|
||||
PersistOptions,
|
||||
readFile,
|
||||
CMS_BRANCH_PREFIX,
|
||||
generateContentKey,
|
||||
labelToStatus,
|
||||
isCMSLabel,
|
||||
EditorialWorkflowError,
|
||||
statusToLabel,
|
||||
DEFAULT_PR_BODY,
|
||||
MERGE_COMMIT_MESSAGE,
|
||||
PreviewState,
|
||||
FetchError,
|
||||
parseContentKey,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { oneLine } from 'common-tags';
|
||||
|
||||
interface Config {
|
||||
apiRoot?: string;
|
||||
token?: string;
|
||||
branch?: string;
|
||||
repo?: string;
|
||||
requestFunction?: (req: ApiRequest) => Promise<Response>;
|
||||
hasWriteAccess?: () => Promise<boolean>;
|
||||
squashMerges: boolean;
|
||||
initialWorkflowStatus: string;
|
||||
}
|
||||
|
||||
interface CommitAuthor {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
enum BitBucketPullRequestState {
|
||||
MERGED = 'MERGED',
|
||||
SUPERSEDED = 'SUPERSEDED',
|
||||
OPEN = 'OPEN',
|
||||
DECLINED = 'DECLINED',
|
||||
}
|
||||
|
||||
type BitBucketPullRequest = {
|
||||
description: string;
|
||||
id: number;
|
||||
title: string;
|
||||
state: BitBucketPullRequestState;
|
||||
summary: {
|
||||
raw: string;
|
||||
};
|
||||
source: {
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
branch: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
destination: {
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
branch: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BitBucketPullRequests = {
|
||||
size: number;
|
||||
page: number;
|
||||
pagelen: number;
|
||||
next: string;
|
||||
preview: string;
|
||||
values: BitBucketPullRequest[];
|
||||
};
|
||||
|
||||
type BitBucketPullComment = {
|
||||
content: {
|
||||
raw: string;
|
||||
};
|
||||
};
|
||||
|
||||
type BitBucketPullComments = {
|
||||
size: number;
|
||||
page: number;
|
||||
pagelen: number;
|
||||
next: string;
|
||||
preview: string;
|
||||
values: BitBucketPullComment[];
|
||||
};
|
||||
|
||||
enum BitBucketPullRequestStatusState {
|
||||
Successful = 'SUCCESSFUL',
|
||||
Failed = 'FAILED',
|
||||
InProgress = 'INPROGRESS',
|
||||
Stopped = 'STOPPED',
|
||||
}
|
||||
|
||||
type BitBucketPullRequestStatus = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
key: string;
|
||||
refname: string;
|
||||
url: string;
|
||||
description: string;
|
||||
state: BitBucketPullRequestStatusState;
|
||||
};
|
||||
|
||||
type BitBucketPullRequestStatues = {
|
||||
size: number;
|
||||
page: number;
|
||||
pagelen: number;
|
||||
next: string;
|
||||
preview: string;
|
||||
values: BitBucketPullRequestStatus[];
|
||||
};
|
||||
|
||||
type BitBucketDiffStat = {
|
||||
pagelen: number;
|
||||
page: number;
|
||||
size: number;
|
||||
values: {
|
||||
status: string;
|
||||
lines_removed: number;
|
||||
lines_added: number;
|
||||
new: {
|
||||
path: string;
|
||||
type: 'commit_file';
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
type DeleteEntry = {
|
||||
path: string;
|
||||
delete: true;
|
||||
};
|
||||
|
||||
type BitBucketFile = {
|
||||
id: string;
|
||||
type: string;
|
||||
path: string;
|
||||
commit?: { hash: string };
|
||||
};
|
||||
|
||||
type BitBucketSrcResult = {
|
||||
size: number;
|
||||
page: number;
|
||||
pagelen: number;
|
||||
next: string;
|
||||
previous: string;
|
||||
values: BitBucketFile[];
|
||||
};
|
||||
|
||||
type BitBucketUser = {
|
||||
username: string;
|
||||
display_name: string;
|
||||
nickname: string;
|
||||
links: {
|
||||
avatar: {
|
||||
href: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const API_NAME = 'BitBucket';
|
||||
|
||||
const APPLICATION_JSON = 'application/json; charset=utf-8';
|
||||
|
||||
const replace404WithEmptyResponse = (err: FetchError) => {
|
||||
if (err && err.status === 404) {
|
||||
console.log('This 404 was expected and handled appropriately.');
|
||||
return { size: 0, values: [] as BitBucketFile[] } as BitBucketSrcResult;
|
||||
} else {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default class API {
|
||||
apiRoot: string;
|
||||
branch: string;
|
||||
repo: string;
|
||||
requestFunction: (req: ApiRequest) => Promise<Response>;
|
||||
repoURL: string;
|
||||
commitAuthor?: CommitAuthor;
|
||||
mergeStrategy: string;
|
||||
initialWorkflowStatus: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
|
||||
this.branch = config.branch || 'master';
|
||||
this.repo = config.repo || '';
|
||||
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
|
||||
// Allow overriding this.hasWriteAccess
|
||||
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
|
||||
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
|
||||
this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit';
|
||||
this.initialWorkflowStatus = config.initialWorkflowStatus;
|
||||
}
|
||||
|
||||
buildRequest = (req: ApiRequest) =>
|
||||
flow([unsentRequest.withRoot(this.apiRoot), unsentRequest.withTimestamp])(req);
|
||||
|
||||
request = (req: ApiRequest): Promise<Response> =>
|
||||
flow([
|
||||
this.buildRequest,
|
||||
this.requestFunction,
|
||||
p => p.catch((err: Error) => Promise.reject(new APIError(err.message, null, API_NAME))),
|
||||
])(req);
|
||||
|
||||
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
|
||||
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
|
||||
responseToText = responseParser({ format: 'text', apiName: API_NAME });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
|
||||
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
|
||||
|
||||
user = () => this.requestJSON('/user') as Promise<BitBucketUser>;
|
||||
|
||||
hasWriteAccess = async () => {
|
||||
const response = await this.request(this.repoURL);
|
||||
if (response.status === 404) {
|
||||
throw Error('Repo not found');
|
||||
}
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
branchCommitSha = async (branch: string) => {
|
||||
const {
|
||||
target: { hash: branchSha },
|
||||
} = await this.requestJSON(`${this.repoURL}/refs/branches/${branch}`);
|
||||
return branchSha as string;
|
||||
};
|
||||
|
||||
isFile = ({ type }: BitBucketFile) => type === 'commit_file';
|
||||
processFile = (file: BitBucketFile) => ({
|
||||
id: file.id,
|
||||
type: file.type,
|
||||
path: file.path,
|
||||
name: basename(file.path),
|
||||
|
||||
// BitBucket does not return file SHAs, but it does give us the
|
||||
// commit SHA. Since the commit SHA will change if any files do,
|
||||
// we can construct an ID using the commit SHA and the file path
|
||||
// that will help with caching (though not as well as a normal
|
||||
// SHA, since it will change even if the individual file itself
|
||||
// doesn't.)
|
||||
...(file.commit && file.commit.hash ? { id: `${file.commit.hash}/${file.path}` } : {}),
|
||||
});
|
||||
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile);
|
||||
|
||||
readFile = async (
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{ parseText = true, branch = this.branch } = {},
|
||||
): Promise<string | Blob> => {
|
||||
const fetchContent = async () => {
|
||||
const node = await this.branchCommitSha(branch);
|
||||
const content = await this.request({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
cache: 'no-store',
|
||||
}).then<string | Blob>(parseText ? this.responseToText : this.responseToBlob);
|
||||
return content;
|
||||
};
|
||||
const content = await readFile(sha, fetchContent, localForage, parseText);
|
||||
return content;
|
||||
};
|
||||
|
||||
getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => {
|
||||
const {
|
||||
size: count,
|
||||
page: index,
|
||||
pagelen: pageSize,
|
||||
next,
|
||||
previous: prev,
|
||||
values: entries,
|
||||
} = jsonResponse;
|
||||
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
|
||||
return {
|
||||
entries,
|
||||
cursor: Cursor.create({
|
||||
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
data: { links: { next, prev } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
listFiles = async (path: string, depth = 1) => {
|
||||
const node = await this.branchCommitSha(this.branch);
|
||||
const result: BitBucketSrcResult = await this.requestJSON({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
params: {
|
||||
// sort files by filename ascending
|
||||
sort: '-path',
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
max_depth: depth,
|
||||
},
|
||||
}).catch(replace404WithEmptyResponse);
|
||||
const { entries, cursor } = this.getEntriesAndCursor(result);
|
||||
|
||||
return { entries: this.processFiles(entries), cursor: cursor as Cursor };
|
||||
};
|
||||
|
||||
traverseCursor = async (
|
||||
cursor: Cursor,
|
||||
action: string,
|
||||
): Promise<{
|
||||
cursor: Cursor;
|
||||
entries: { path: string; name: string; type: string; id: string }[];
|
||||
}> =>
|
||||
flow([
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
then<
|
||||
{ cursor: Cursor; entries: BitBucketFile[] },
|
||||
{ cursor: Cursor; entries: BitBucketFile[] }
|
||||
>(({ cursor: newCursor, entries }) => ({
|
||||
cursor: newCursor,
|
||||
entries: this.processFiles(entries),
|
||||
})),
|
||||
])(cursor.data!.getIn(['links', action]));
|
||||
|
||||
listAllFiles = async (path: string, depth = 1) => {
|
||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(path, depth);
|
||||
const entries = [...initialEntries];
|
||||
let currentCursor = initialCursor;
|
||||
while (currentCursor && currentCursor.actions!.has('next')) {
|
||||
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
|
||||
currentCursor,
|
||||
'next',
|
||||
);
|
||||
entries.push(...newEntries);
|
||||
currentCursor = newCursor;
|
||||
}
|
||||
return this.processFiles(entries);
|
||||
};
|
||||
|
||||
async uploadFiles(
|
||||
files: (Entry | AssetProxy | DeleteEntry)[],
|
||||
{
|
||||
commitMessage,
|
||||
branch,
|
||||
parentSha,
|
||||
}: { commitMessage: string; branch: string; parentSha?: string },
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
if ((file as DeleteEntry).delete) {
|
||||
// delete the file
|
||||
formData.append('files', file.path);
|
||||
} else {
|
||||
// add/modify the file
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as Entry).raw]));
|
||||
// Third param is filename header, in case path is `message`, `branch`, etc.
|
||||
formData.append(file.path, contentBlob, basename(file.path));
|
||||
}
|
||||
});
|
||||
if (commitMessage) {
|
||||
formData.append('message', commitMessage);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
formData.append('author', `${name} <${email}>`);
|
||||
}
|
||||
|
||||
formData.append('branch', branch);
|
||||
|
||||
if (parentSha) {
|
||||
formData.append('parents', parentSha);
|
||||
}
|
||||
|
||||
await this.request({
|
||||
url: `${this.repoURL}/src`,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async persistFiles(entry: Entry | null, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = entry ? [entry, ...mediaFiles] : mediaFiles;
|
||||
if (options.useWorkflow) {
|
||||
return this.editorialWorkflowGit(files, entry as Entry, options);
|
||||
} else {
|
||||
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
|
||||
}
|
||||
}
|
||||
|
||||
async addPullRequestComment(pullRequest: BitBucketPullRequest, comment: string) {
|
||||
await this.requestJSON({
|
||||
method: 'POST',
|
||||
url: `${this.repoURL}/pullrequests/${pullRequest.id}/comments`,
|
||||
headers: { 'Content-Type': APPLICATION_JSON },
|
||||
body: JSON.stringify({
|
||||
content: {
|
||||
raw: comment,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getPullRequestLabel(id: number) {
|
||||
const comments: BitBucketPullComments = await this.requestJSON({
|
||||
url: `${this.repoURL}/pullrequests/${id}/comments`,
|
||||
params: {
|
||||
pagelen: 100,
|
||||
},
|
||||
});
|
||||
return comments.values.map(c => c.content.raw)[comments.values.length - 1];
|
||||
}
|
||||
|
||||
async createPullRequest(branch: string, commitMessage: string, status: string) {
|
||||
const pullRequest: BitBucketPullRequest = await this.requestJSON({
|
||||
method: 'POST',
|
||||
url: `${this.repoURL}/pullrequests`,
|
||||
headers: { 'Content-Type': APPLICATION_JSON },
|
||||
body: JSON.stringify({
|
||||
title: commitMessage,
|
||||
source: {
|
||||
branch: {
|
||||
name: branch,
|
||||
},
|
||||
},
|
||||
destination: {
|
||||
branch: {
|
||||
name: this.branch,
|
||||
},
|
||||
},
|
||||
description: DEFAULT_PR_BODY,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
close_source_branch: true,
|
||||
}),
|
||||
});
|
||||
// use comments for status labels
|
||||
await this.addPullRequestComment(pullRequest, statusToLabel(status));
|
||||
}
|
||||
|
||||
async getDifferences(branch: string) {
|
||||
const diff: BitBucketDiffStat = await this.requestJSON({
|
||||
url: `${this.repoURL}/diffstat/${branch}..${this.branch}`,
|
||||
params: {
|
||||
pagelen: 100,
|
||||
},
|
||||
});
|
||||
return diff.values;
|
||||
}
|
||||
|
||||
async editorialWorkflowGit(files: (Entry | AssetProxy)[], entry: Entry, options: PersistOptions) {
|
||||
const contentKey = this.generateContentKey(options.collectionName as string, entry.slug);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
if (!unpublished) {
|
||||
const defaultBranchSha = await this.branchCommitSha(this.branch);
|
||||
await this.uploadFiles(files, {
|
||||
commitMessage: options.commitMessage,
|
||||
branch,
|
||||
parentSha: defaultBranchSha,
|
||||
});
|
||||
await this.createPullRequest(
|
||||
branch,
|
||||
options.commitMessage,
|
||||
options.status || this.initialWorkflowStatus,
|
||||
);
|
||||
} else {
|
||||
// mark files for deletion
|
||||
const diffs = await this.getDifferences(branch);
|
||||
const toDelete: DeleteEntry[] = [];
|
||||
for (const diff of diffs) {
|
||||
if (!files.some(file => file.path === diff.new.path)) {
|
||||
toDelete.push({ path: diff.new.path, delete: true });
|
||||
}
|
||||
}
|
||||
|
||||
await this.uploadFiles([...files, ...toDelete], {
|
||||
commitMessage: options.commitMessage,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile = (path: string, message: string) => {
|
||||
const body = new FormData();
|
||||
body.append('files', path);
|
||||
body.append('branch', this.branch);
|
||||
if (message) {
|
||||
body.append('message', message);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
body.append('author', `${name} <${email}>`);
|
||||
}
|
||||
return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])(
|
||||
`${this.repoURL}/src`,
|
||||
);
|
||||
};
|
||||
|
||||
generateContentKey(collectionName: string, slug: string) {
|
||||
return generateContentKey(collectionName, slug);
|
||||
}
|
||||
|
||||
contentKeyFromBranch(branch: string) {
|
||||
return branch.substring(`${CMS_BRANCH_PREFIX}/`.length);
|
||||
}
|
||||
|
||||
branchFromContentKey(contentKey: string) {
|
||||
return `${CMS_BRANCH_PREFIX}/${contentKey}`;
|
||||
}
|
||||
|
||||
async isFileExists(path: string, branch: string) {
|
||||
const fileExists = await this.readFile(path, null, { branch })
|
||||
.then(() => true)
|
||||
.catch(error => {
|
||||
if (error instanceof APIError && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return fileExists;
|
||||
}
|
||||
|
||||
async getPullRequests(sourceBranch?: string) {
|
||||
const sourceQuery = sourceBranch
|
||||
? `source.branch.name = "${sourceBranch}"`
|
||||
: `source.branch.name ~ "${CMS_BRANCH_PREFIX}/"`;
|
||||
|
||||
const pullRequests: BitBucketPullRequests = await this.requestJSON({
|
||||
url: `${this.repoURL}/pullrequests`,
|
||||
params: {
|
||||
pagelen: 50,
|
||||
q: oneLine`
|
||||
source.repository.full_name = "${this.repo}"
|
||||
AND state = "${BitBucketPullRequestState.OPEN}"
|
||||
AND destination.branch.name = "${this.branch}"
|
||||
AND comment_count > 0
|
||||
AND ${sourceQuery}
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
const labels = await Promise.all(
|
||||
pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)),
|
||||
);
|
||||
|
||||
return pullRequests.values.filter((_, index) => isCMSLabel(labels[index]));
|
||||
}
|
||||
|
||||
async getBranchPullRequest(branch: string) {
|
||||
const pullRequests = await this.getPullRequests(branch);
|
||||
if (pullRequests.length <= 0) {
|
||||
throw new EditorialWorkflowError('content is not under editorial workflow', true);
|
||||
}
|
||||
|
||||
return pullRequests[0];
|
||||
}
|
||||
|
||||
async retrieveMetadata(contentKey: string) {
|
||||
const { collection, slug } = parseContentKey(contentKey);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const pullRequest = await this.getBranchPullRequest(branch);
|
||||
const diff = await this.getDifferences(branch);
|
||||
const path = diff.find(d => d.new.path.includes(slug))?.new.path as string;
|
||||
// TODO: get real file id
|
||||
const mediaFiles = await Promise.all(
|
||||
diff.filter(d => d.new.path !== path).map(d => ({ path: d.new.path, id: null })),
|
||||
);
|
||||
const label = await this.getPullRequestLabel(pullRequest.id);
|
||||
const status = labelToStatus(label);
|
||||
return { branch, collection, slug, path, status, mediaFiles };
|
||||
}
|
||||
|
||||
async readUnpublishedBranchFile(contentKey: string) {
|
||||
const { branch, collection, slug, path, status, mediaFiles } = await this.retrieveMetadata(
|
||||
contentKey,
|
||||
);
|
||||
|
||||
const [fileData, isModification] = await Promise.all([
|
||||
this.readFile(path, null, { branch }) as Promise<string>,
|
||||
this.isFileExists(path, this.branch),
|
||||
]);
|
||||
|
||||
return {
|
||||
slug,
|
||||
metaData: { branch, collection, objects: { entry: { path, mediaFiles } }, status },
|
||||
fileData,
|
||||
isModification,
|
||||
};
|
||||
}
|
||||
|
||||
async listUnpublishedBranches() {
|
||||
console.log(
|
||||
'%c Checking for Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
|
||||
const pullRequests = await this.getPullRequests();
|
||||
const branches = pullRequests.map(mr => mr.source.branch.name);
|
||||
|
||||
return branches;
|
||||
}
|
||||
|
||||
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
|
||||
const contentKey = this.generateContentKey(collection, slug);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const pullRequest = await this.getBranchPullRequest(branch);
|
||||
|
||||
await this.addPullRequestComment(pullRequest, statusToLabel(newStatus));
|
||||
}
|
||||
|
||||
async mergePullRequest(pullRequest: BitBucketPullRequest) {
|
||||
await this.requestJSON({
|
||||
method: 'POST',
|
||||
url: `${this.repoURL}/pullrequests/${pullRequest.id}/merge`,
|
||||
headers: { 'Content-Type': APPLICATION_JSON },
|
||||
body: JSON.stringify({
|
||||
message: MERGE_COMMIT_MESSAGE,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
close_source_branch: true,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
merge_strategy: this.mergeStrategy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async publishUnpublishedEntry(collectionName: string, slug: string) {
|
||||
const contentKey = this.generateContentKey(collectionName, slug);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const pullRequest = await this.getBranchPullRequest(branch);
|
||||
|
||||
await this.mergePullRequest(pullRequest);
|
||||
}
|
||||
|
||||
async declinePullRequest(pullRequest: BitBucketPullRequest) {
|
||||
await this.requestJSON({
|
||||
method: 'POST',
|
||||
url: `${this.repoURL}/pullrequests/${pullRequest.id}/decline`,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBranch(branch: string) {
|
||||
await this.request({
|
||||
method: 'DELETE',
|
||||
url: `${this.repoURL}/refs/branches/${branch}`,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUnpublishedEntry(collectionName: string, slug: string) {
|
||||
const contentKey = this.generateContentKey(collectionName, slug);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const pullRequest = await this.getBranchPullRequest(branch);
|
||||
|
||||
await this.declinePullRequest(pullRequest);
|
||||
await this.deleteBranch(branch);
|
||||
}
|
||||
|
||||
async getPullRequestStatuses(pullRequest: BitBucketPullRequest) {
|
||||
const statuses: BitBucketPullRequestStatues = await this.requestJSON({
|
||||
url: `${this.repoURL}/pullrequests/${pullRequest.id}/statuses`,
|
||||
params: {
|
||||
pagelen: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return statuses.values;
|
||||
}
|
||||
|
||||
async getStatuses(collectionName: string, slug: string) {
|
||||
const contentKey = this.generateContentKey(collectionName, slug);
|
||||
const branch = this.branchFromContentKey(contentKey);
|
||||
const pullRequest = await this.getBranchPullRequest(branch);
|
||||
const statuses = await this.getPullRequestStatuses(pullRequest);
|
||||
|
||||
return statuses.map(({ key, state, url }) => ({
|
||||
context: key,
|
||||
state:
|
||||
state === BitBucketPullRequestStatusState.Successful
|
||||
? PreviewState.Success
|
||||
: PreviewState.Other,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
target_url: url,
|
||||
}));
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { NetlifyAuthenticator, ImplicitAuthenticator } from 'netlify-cms-lib-auth';
|
||||
import { AuthenticationPage, Icon } from 'netlify-cms-ui-default';
|
||||
@ -16,22 +15,25 @@ export default class BitbucketAuthenticationPage extends React.Component {
|
||||
base_url: PropTypes.string,
|
||||
siteId: PropTypes.string,
|
||||
authEndpoint: PropTypes.string,
|
||||
config: ImmutablePropTypes.map,
|
||||
config: PropTypes.object.isRequired,
|
||||
clearHash: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
componentDidMount() {
|
||||
const authType = this.props.config.getIn(['backend', 'auth_type']);
|
||||
const {
|
||||
auth_type: authType = '',
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = this.props.config.backend;
|
||||
|
||||
if (authType === 'implicit') {
|
||||
this.auth = new ImplicitAuthenticator({
|
||||
base_url: this.props.config.getIn(['backend', 'base_url'], 'https://bitbucket.org'),
|
||||
auth_endpoint: this.props.config.getIn(
|
||||
['backend', 'auth_endpoint'],
|
||||
'site/oauth2/authorize',
|
||||
),
|
||||
app_id: this.props.config.getIn(['backend', 'app_id']),
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
clearHash: this.props.clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
@ -75,8 +77,8 @@ export default class BitbucketAuthenticationPage extends React.Component {
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={this.state.loginError}
|
||||
logoUrl={config.get('logo_url')}
|
||||
siteUrl={config.get('site_url')}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="bitbucket" />
|
||||
|
@ -0,0 +1,35 @@
|
||||
import API from '../API';
|
||||
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
|
||||
|
||||
describe('bitbucket API', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('should get preview statuses', async () => {
|
||||
const api = new API({});
|
||||
|
||||
const pr = { id: 1 };
|
||||
const statuses = [
|
||||
{ key: 'deploy', state: 'SUCCESSFUL', url: 'deploy-url' },
|
||||
{ key: 'build', state: 'FAILED' },
|
||||
];
|
||||
|
||||
api.getBranchPullRequest = jest.fn(() => Promise.resolve(pr));
|
||||
api.getPullRequestStatuses = jest.fn(() => Promise.resolve(statuses));
|
||||
|
||||
const collectionName = 'posts';
|
||||
const slug = 'title';
|
||||
await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([
|
||||
{ context: 'deploy', state: 'success', target_url: 'deploy-url' },
|
||||
{ context: 'build', state: 'other' },
|
||||
]);
|
||||
|
||||
expect(api.getBranchPullRequest).toHaveBeenCalledTimes(1);
|
||||
expect(api.getBranchPullRequest).toHaveBeenCalledWith(`cms/posts/title`);
|
||||
|
||||
expect(api.getPullRequestStatuses).toHaveBeenCalledTimes(1);
|
||||
expect(api.getPullRequestStatuses).toHaveBeenCalledWith(pr);
|
||||
});
|
||||
});
|
@ -1,312 +0,0 @@
|
||||
import semaphore from 'semaphore';
|
||||
import { flow, trimStart } from 'lodash';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import {
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
filterByPropExtension,
|
||||
resolvePromiseProperties,
|
||||
then,
|
||||
unsentRequest,
|
||||
basename,
|
||||
getBlobSHA,
|
||||
getCollectionDepth,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { NetlifyAuthenticator } from 'netlify-cms-lib-auth';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
// Implementation wrapper class
|
||||
export default class BitbucketBackend {
|
||||
constructor(config, options = {}) {
|
||||
this.config = config;
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
updateUserCredentials: async () => null,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (this.options.useWorkflow) {
|
||||
throw new Error('The BitBucket backend does not support the Editorial Workflow.');
|
||||
}
|
||||
|
||||
if (!this.options.proxied && !config.getIn(['backend', 'repo'], false)) {
|
||||
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.updateUserCredentials = this.options.updateUserCredentials;
|
||||
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master');
|
||||
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.bitbucket.org/2.0');
|
||||
this.base_url = config.get('base_url');
|
||||
this.site_id = config.get('site_id');
|
||||
this.token = '';
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.token = user.token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
});
|
||||
}
|
||||
|
||||
restoreUser(user) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async authenticate(state) {
|
||||
this.token = state.token;
|
||||
this.refreshToken = state.refresh_token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
api_root: this.api_root,
|
||||
});
|
||||
|
||||
const isCollab = await this.api.hasWriteAccess().catch(error => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a Bitbucket account with access.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your BitBucket user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
const user = await this.api.user();
|
||||
|
||||
// Authorized user
|
||||
return {
|
||||
...user,
|
||||
name: user.display_name,
|
||||
login: user.username,
|
||||
token: state.token,
|
||||
refresh_token: state.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
getRefreshedAccessToken() {
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
// instantiating a new Authenticator on each refresh isn't ideal,
|
||||
if (!this.auth) {
|
||||
const cfg = {
|
||||
base_url: this.base_url,
|
||||
site_id: this.site_id,
|
||||
};
|
||||
this.authenticator = new NetlifyAuthenticator(cfg);
|
||||
}
|
||||
|
||||
this.refreshedTokenPromise = this.authenticator
|
||||
.refresh({ provider: 'bitbucket', refresh_token: this.refreshToken })
|
||||
.then(({ token, refresh_token }) => {
|
||||
this.token = token;
|
||||
this.refreshToken = refresh_token;
|
||||
this.refreshedTokenPromise = undefined;
|
||||
this.updateUserCredentials({ token, refresh_token });
|
||||
return token;
|
||||
});
|
||||
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
apiRequestFunction = async req => {
|
||||
const token = this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token;
|
||||
return flow([
|
||||
unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }),
|
||||
unsentRequest.performRequest,
|
||||
then(async res => {
|
||||
if (res.status === 401) {
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
|
||||
const newToken = await this.getRefreshedAccessToken();
|
||||
const reqWithNewToken = unsentRequest.withHeaders(
|
||||
{ Authorization: `Bearer ${newToken}` },
|
||||
req,
|
||||
);
|
||||
return unsentRequest.performRequest(reqWithNewToken);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}),
|
||||
])(req);
|
||||
};
|
||||
|
||||
entriesByFolder(collection, extension) {
|
||||
const listPromise = this.api.listFiles(
|
||||
collection.get('folder'),
|
||||
getCollectionDepth(collection),
|
||||
);
|
||||
return resolvePromiseProperties({
|
||||
files: listPromise
|
||||
.then(({ entries }) => entries)
|
||||
.then(filterByPropExtension(extension, 'path'))
|
||||
.then(this.fetchFiles),
|
||||
cursor: listPromise.then(({ cursor }) => cursor),
|
||||
}).then(({ files, cursor }) => {
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
});
|
||||
}
|
||||
|
||||
allEntriesByFolder(collection, extension) {
|
||||
return this.api
|
||||
.listAllFiles(collection.get('folder'), getCollectionDepth(collection))
|
||||
.then(filterByPropExtension(extension, 'path'))
|
||||
.then(this.fetchFiles);
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return this.fetchFiles(files);
|
||||
}
|
||||
|
||||
fetchFiles = files => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(file.path, file.id)
|
||||
.then(data => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
})
|
||||
.catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from BitBucket: ${file.path}`);
|
||||
resolve({ error });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !loadedEntry.error),
|
||||
);
|
||||
};
|
||||
|
||||
getEntry(collection, slug, path) {
|
||||
return this.api.readFile(path).then(data => ({
|
||||
file: { path },
|
||||
data,
|
||||
}));
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.config.get('media_folder')) {
|
||||
return this.api
|
||||
.listAllFiles(mediaFolder)
|
||||
.then(files =>
|
||||
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
|
||||
);
|
||||
}
|
||||
|
||||
getMediaAsBlob(path, id) {
|
||||
return this.api.readFile(path, id, { parseText: false });
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const { id, path } = displayURL;
|
||||
return new Promise((resolve, reject) =>
|
||||
this._mediaDisplayURLSem.take(() =>
|
||||
this.getMediaAsBlob(path, id)
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.then(resolve, reject)
|
||||
.finally(() => this._mediaDisplayURLSem.leave()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path) {
|
||||
const name = basename(path);
|
||||
const blob = await this.getMediaAsBlob(path, null);
|
||||
const fileObj = new File([blob], name);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
persistEntry(entry, mediaFiles, options = {}) {
|
||||
return this.api.persistFiles([entry], options);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile, options = {}) {
|
||||
const { fileObj } = mediaFile;
|
||||
|
||||
const [sha] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api.persistFiles([mediaFile], options),
|
||||
]);
|
||||
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path: trimStart(mediaFile.path, '/k'),
|
||||
name: fileObj.name,
|
||||
size: fileObj.size,
|
||||
id: sha,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
deleteFile(path, commitMessage, options) {
|
||||
return this.api.deleteFile(path, commitMessage, options);
|
||||
}
|
||||
|
||||
traverseCursor(cursor, action) {
|
||||
return this.api.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(
|
||||
entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data }))),
|
||||
),
|
||||
cursor: newCursor,
|
||||
}));
|
||||
}
|
||||
}
|
457
packages/netlify-cms-backend-bitbucket/src/implementation.ts
Normal file
457
packages/netlify-cms-backend-bitbucket/src/implementation.ts
Normal file
@ -0,0 +1,457 @@
|
||||
import semaphore, { Semaphore } from 'semaphore';
|
||||
import { flow, trimStart } from 'lodash';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import {
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
filterByPropExtension,
|
||||
then,
|
||||
unsentRequest,
|
||||
basename,
|
||||
getBlobSHA,
|
||||
Entry,
|
||||
ApiRequest,
|
||||
Cursor,
|
||||
AssetProxy,
|
||||
PersistOptions,
|
||||
DisplayURL,
|
||||
Implementation,
|
||||
entriesByFolder,
|
||||
entriesByFiles,
|
||||
User,
|
||||
Credentials,
|
||||
getMediaDisplayURL,
|
||||
getMediaAsBlob,
|
||||
Config,
|
||||
ImplementationFile,
|
||||
unpublishedEntries,
|
||||
UnpublishedEntryMediaFile,
|
||||
runWithLock,
|
||||
AsyncLock,
|
||||
asyncLock,
|
||||
getPreviewStatus,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import NetlifyAuthenticator from 'netlify-cms-lib-auth';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API, { API_NAME } from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
// Implementation wrapper class
|
||||
export default class BitbucketBackend implements Implementation {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: API | null;
|
||||
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
|
||||
initialWorkflowStatus: string;
|
||||
};
|
||||
repo: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
baseUrl: string;
|
||||
siteId: string;
|
||||
token: string | null;
|
||||
mediaFolder: string;
|
||||
refreshToken?: string;
|
||||
refreshedTokenPromise?: Promise<string>;
|
||||
authenticator?: NetlifyAuthenticator;
|
||||
auth?: unknown;
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
squashMerges: boolean;
|
||||
previewContext: string;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
updateUserCredentials: async () => null,
|
||||
initialWorkflowStatus: '',
|
||||
...options,
|
||||
};
|
||||
|
||||
if (
|
||||
!this.options.proxied &&
|
||||
(config.backend.repo === null || config.backend.repo === undefined)
|
||||
) {
|
||||
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.updateUserCredentials = this.options.updateUserCredentials;
|
||||
|
||||
this.repo = config.backend.repo || '';
|
||||
this.branch = config.backend.branch || 'master';
|
||||
this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0';
|
||||
this.baseUrl = config.base_url || '';
|
||||
this.siteId = config.site_id || '';
|
||||
this.token = '';
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.squashMerges = config.backend.squash_merges || false;
|
||||
this.previewContext = config.backend.preview_context || '';
|
||||
this.lock = asyncLock();
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
setUser(user: { token: string }) {
|
||||
this.token = user.token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
squashMerges: this.squashMerges,
|
||||
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
||||
});
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
this.refreshToken = state.refresh_token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
apiRoot: this.apiRoot,
|
||||
squashMerges: this.squashMerges,
|
||||
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
||||
});
|
||||
|
||||
const isCollab = await this.api.hasWriteAccess().catch(error => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a Bitbucket account with access.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your BitBucket user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
const user = await this.api.user();
|
||||
|
||||
// Authorized user
|
||||
return {
|
||||
...user,
|
||||
name: user.display_name,
|
||||
login: user.username,
|
||||
token: state.token,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
avatar_url: user.links.avatar.href,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
refresh_token: state.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
getRefreshedAccessToken() {
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
// instantiating a new Authenticator on each refresh isn't ideal,
|
||||
if (!this.auth) {
|
||||
const cfg = {
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
base_url: this.baseUrl,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
site_id: this.siteId,
|
||||
};
|
||||
this.authenticator = new NetlifyAuthenticator(cfg);
|
||||
}
|
||||
|
||||
this.refreshedTokenPromise = this.authenticator! // eslint-disable-next-line @typescript-eslint/camelcase
|
||||
.refresh({ provider: 'bitbucket', refresh_token: this.refreshToken as string })
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
.then(({ token, refresh_token }) => {
|
||||
this.token = token;
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
this.refreshToken = refresh_token;
|
||||
this.refreshedTokenPromise = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
this.updateUserCredentials({ token, refresh_token });
|
||||
return token;
|
||||
});
|
||||
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
apiRequestFunction = async (req: ApiRequest) => {
|
||||
const token = (this.refreshedTokenPromise
|
||||
? await this.refreshedTokenPromise
|
||||
: this.token) as string;
|
||||
|
||||
return flow([
|
||||
unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }) as (
|
||||
req: ApiRequest,
|
||||
) => ApiRequest,
|
||||
unsentRequest.performRequest,
|
||||
then(async (res: Response) => {
|
||||
if (res.status === 401) {
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
|
||||
const newToken = await this.getRefreshedAccessToken();
|
||||
const reqWithNewToken = unsentRequest.withHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
req,
|
||||
) as ApiRequest;
|
||||
return unsentRequest.performRequest(reqWithNewToken);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}),
|
||||
])(req);
|
||||
};
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
let cursor: Cursor;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, depth).then(({ entries, cursor: c }) => {
|
||||
cursor = c;
|
||||
return filterByPropExtension(extension, 'path')(entries);
|
||||
});
|
||||
|
||||
const files = await entriesByFolder(listFiles, this.api!.readFile.bind(this.api!), 'BitBucket');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
}
|
||||
|
||||
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const listFiles = () =>
|
||||
this.api!.listAllFiles(folder, depth).then(filterByPropExtension(extension, 'path'));
|
||||
|
||||
const files = await entriesByFolder(listFiles, this.api!.readFile.bind(this.api!), 'BitBucket');
|
||||
return files;
|
||||
}
|
||||
|
||||
async entriesByFiles(files: ImplementationFile[]) {
|
||||
return entriesByFiles(files, this.api!.readFile.bind(this.api!), 'BitBucket');
|
||||
}
|
||||
|
||||
getEntry(path: string) {
|
||||
return this.api!.readFile(path).then(data => ({
|
||||
file: { path, id: null },
|
||||
data: data as string,
|
||||
}));
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
return this.api!.listAllFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
|
||||
);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const name = basename(path);
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
const fileObj = new File([blob], name);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(fileObj);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry, mediaFiles, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
const fileObj = mediaFile.fileObj as File;
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles(null, [mediaFile], options),
|
||||
]);
|
||||
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path: trimStart(mediaFile.path, '/k'),
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
id,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
deleteFile(path: string, commitMessage: string) {
|
||||
return this.api!.deleteFile(path, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.api!.traverseCursor(cursor, action).then(
|
||||
async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(
|
||||
entries.map(file =>
|
||||
this.api!.readFile(file.path, file.id).then(data => ({ file, data: data as string })),
|
||||
),
|
||||
),
|
||||
cursor: newCursor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
|
||||
const readFile = (
|
||||
path: string,
|
||||
id: string | null | undefined,
|
||||
{ parseText }: { parseText: boolean },
|
||||
) => this.api!.readFile(path, id, { branch, parseText });
|
||||
|
||||
return getMediaAsBlob(file.path, null, readFile).then(blob => {
|
||||
const name = basename(file.path);
|
||||
const fileObj = new File([blob], name);
|
||||
return {
|
||||
id: file.path,
|
||||
displayURL: URL.createObjectURL(fileObj),
|
||||
path: file.path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) {
|
||||
const mediaFiles = await Promise.all(files.map(file => this.loadMediaFile(branch, file)));
|
||||
|
||||
return mediaFiles;
|
||||
}
|
||||
|
||||
async unpublishedEntries() {
|
||||
const listEntriesKeys = () =>
|
||||
this.api!.listUnpublishedBranches().then(branches =>
|
||||
branches.map(branch => this.api!.contentKeyFromBranch(branch)),
|
||||
);
|
||||
|
||||
const readUnpublishedBranchFile = (contentKey: string) =>
|
||||
this.api!.readUnpublishedBranchFile(contentKey);
|
||||
|
||||
return unpublishedEntries(listEntriesKeys, readUnpublishedBranchFile, API_NAME);
|
||||
}
|
||||
|
||||
async unpublishedEntry(
|
||||
collection: string,
|
||||
slug: string,
|
||||
{
|
||||
loadEntryMediaFiles = (branch: string, files: UnpublishedEntryMediaFile[]) =>
|
||||
this.loadEntryMediaFiles(branch, files),
|
||||
} = {},
|
||||
) {
|
||||
const contentKey = this.api!.generateContentKey(collection, slug);
|
||||
const data = await this.api!.readUnpublishedBranchFile(contentKey);
|
||||
const mediaFiles = await loadEntryMediaFiles(
|
||||
data.metaData.branch,
|
||||
// TODO: fix this
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
data.metaData.objects.entry.mediaFiles,
|
||||
);
|
||||
return {
|
||||
slug,
|
||||
file: { path: data.metaData.objects.entry.path, id: null },
|
||||
data: data.fileData as string,
|
||||
metaData: data.metaData,
|
||||
mediaFiles,
|
||||
isModification: data.isModification,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
|
||||
// updateUnpublishedEntryStatus is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
|
||||
'Failed to acquire update entry status lock',
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUnpublishedEntry(collection: string, slug: string) {
|
||||
// deleteUnpublishedEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.deleteUnpublishedEntry(collection, slug),
|
||||
'Failed to acquire delete entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async publishUnpublishedEntry(collection: string, slug: string) {
|
||||
// publishUnpublishedEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.publishUnpublishedEntry(collection, slug),
|
||||
'Failed to acquire publish entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async getDeployPreview(collection: string, slug: string) {
|
||||
try {
|
||||
const statuses = await this.api!.getStatuses(collection, slug);
|
||||
const deployStatus = getPreviewStatus(statuses, this.previewContext);
|
||||
|
||||
if (deployStatus) {
|
||||
const { target_url: url, state } = deployStatus;
|
||||
return { url, status: state };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
5
packages/netlify-cms-backend-bitbucket/src/types/semaphore.d.ts
vendored
Normal file
5
packages/netlify-cms-backend-bitbucket/src/types/semaphore.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module 'semaphore' {
|
||||
export type Semaphore = { take: (f: Function) => void; leave: () => void };
|
||||
const semaphore: (count: number) => Semaphore;
|
||||
export default semaphore;
|
||||
}
|
Reference in New Issue
Block a user