Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(WIP) Bitbucket integration #525

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ yarn-error.log
.vscode/
manifest.yml
.imdone/
website/site/data/contributors.yml
/coverage/
website/site/data/contributors.yml
/coverage/
.idea/
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@
"webpack": "^3.6.0",
"webpack-dev-server": "^2.9.1",
"webpack-merge": "^4.1.0",
"webpack-postcss-tools": "^1.1.1"
"webpack-postcss-tools": "^1.1.1",
"fetch-mock": "^5.12.1"
},
"dependencies": {
"classnames": "^2.2.5",
"create-react-class": "^15.6.0",
"form-data": "^2.2.0",
"fuzzy": "^0.1.1",
"gotrue-js": "^0.9.15",
"gray-matter": "^3.0.6",
Expand Down
3 changes: 2 additions & 1 deletion src/backends/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ import { sanitizeSlug } from "Lib/urlHelper";
import TestRepoBackend from "./test-repo/implementation";
import GitHubBackend from "./github/implementation";
import GitGatewayBackend from "./git-gateway/implementation";
import BitbucketBackend from "./bitbucket/implementation";
import { registerBackend, getBackend } from 'Lib/registry';

/**
* Register internal backends
*/
registerBackend('git-gateway', GitGatewayBackend);
registerBackend('github', GitHubBackend);
registerBackend('bitbucket', BitbucketBackend);
registerBackend('test-repo', TestRepoBackend);


class LocalStorageAuthStore {
storageKey = "netlify-cms-user";

Expand Down
200 changes: 200 additions & 0 deletions src/backends/bitbucket/API.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import LocalForage from "localforage";
import { Base64 } from "js-base64";
import FormData from "form-data";
import AssetProxy from "ValueObjects/AssetProxy";
import { APIError } from "ValueObjects/errors";
import { SIMPLE } from "Constants/publishModes";

export default class API {
constructor(config) {
this.api_root = config.api_root || "https://api.bitbucket.org/2.0";
this.token = config.token || false;
this.branch = config.branch || "master";
this.repo = config.repo || "";
this.repoURL = `/repositories/${ this.repo }`;
}

user() {
return this.request("/user");
}

hasWriteAccess(user) {
return this.request(`/repositories/${ user.username }`, {
params: { role: "contributor", q: `full_name="${ this.repo }"` },
}).then((r) => {
const repos = r.values;
let contributor = false;
for (const repo of repos) {
if (this.repo === `${ repo.full_name }`) contributor = true;
}
return contributor;
});
}

requestHeaders(headers = {}) {
const baseHeader = {
"Content-Type": "application/json",
...headers,
};

if (this.token) {
baseHeader.Authorization = `Bearer ${ this.token }`;
return baseHeader;
}

return baseHeader;
}

formRequestHeaders(headers = {}) {
const baseHeader = {
...headers,
};

if (this.token) {
baseHeader.Authorization = `Bearer ${ this.token }`;
return baseHeader;
}

return baseHeader;
}

parseJsonResponse(response) {
return response.json().then((json) => {
if (!response.ok) {
return Promise.reject(json);
}

return json;
});
}

urlFor(path, options) {
const cacheBuster = new Date().getTime();
const params = [`ts=${cacheBuster}`];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${ params.join("&") }`;
}
return this.api_root + path;
}

request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus;
return fetch(url, { ...options, headers }).then((response) => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
return response.text();
})
.catch((error) => {
throw new APIError(error.message, responseStatus, 'Bitbucket');
});
}

//formrequest uses different headers for a form request vs normal json request
formRequest(path, options = {}) {
const headers = this.formRequestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus;
return fetch(url, { ...options, headers }).then((response) => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
return response.text();
})
.catch((error) => {
throw new APIError(error.message, responseStatus, 'Bitbucket');
});
}

readFile(path, sha, branch = this.branch) {
const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null);
return cache.then((cached) => {
if (cached) { return cached; }

return this.request(`${ this.repoURL }/src/${ branch }/${ path }`, {
cache: "no-store",
}).then((result) => {
if (sha) {
LocalForage.setItem(`gh.${ sha }`, result);
}
return result;
});
});
}

listFiles(path) {
return this.request(`${ this.repoURL }/src/${ this.branch }/${ path }`, {})
.then(files => {
if (!Array.isArray(files.values)) {
throw new Error(`Cannot list files, path ${path} is not a directory`);
}
return files.values;
})
.then(files => {
return files.filter(file => file.type === "commit_file")
});
}

persistFiles(entry, mediaFiles, options) {
const uploadPromises = [];
const files = mediaFiles.concat(entry);

files.forEach((file) => {
Copy link
Contributor

@tech4him1 tech4him1 Aug 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zanedev This has been discussed before, and there are obviously pros and cons to both sides, but I wonder if it would be best to upload all the media files first, then upload the entry. That way, if there is a problem with one of the media files, the entry would not be broken. But that is an opinion, some people may prefer to have the content saved first. Anyone else?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no problem with doing media files first. I feel like all of these backend implementations need to be refactored to have some shared code to avoid code/logic duplication however.. Maybe it's best to do it all at once for all backends so they follow the same logic and we don't further spaghetti weave it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tortilaman and I were actually already talking about combining the backends: https://gitter.im/netlify/NetlifyCMS?at=5989e0e91c8697534a9c1a7d, as I totally think that is an awesome idea. He was going to make an issue for it, but I'm not sure if he got around to that yet.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think someone would have to own it across the board to do that it would be a big effort requiring ongoing maintenance and way more testing etc. Not sure if it's worth it yet. But some sort of shared interface would be a good start at least. Typescript might be a good fit for something this delicate.

if (file.uploaded) { return; }
uploadPromises.push(this.uploadBlob(file));
});

return Promise.all(uploadPromises).then(() => {
if (!options.mode || (options.mode && options.mode === SIMPLE)) {
return this.getBranch();
}
});
}

deleteFile(path, message, options={}) {
const branch = options.branch || this.branch;

let formData = new FormData();
formData.append('files', path);
if (message && message != "") {
formData.append('message', message) ;
}
return this.formRequest(`${ this.repoURL }/src`, {
method: 'POST',
body: formData
});
}

getBranch(branch = this.branch) {
return this.request(`${ this.repoURL }/refs/branches/${ encodeURIComponent(branch) }`);
}

uploadBlob(item) {
const content = item instanceof AssetProxy ? item.toBlob() : Promise.resolve(item.raw);

return content.then(contentBase64 => {
let formData = new FormData();
formData.append(item.path, contentBase64);

this.formRequest(`${ this.repoURL }/src`, {
method: 'POST',
body: formData
}).then((response) => {
//item.sha = response.sha; //todo: get commit sha from bitbucket?
item.uploaded = true;
return item;
})
});
}
}
49 changes: 49 additions & 0 deletions src/backends/bitbucket/AuthenticationPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import Authenticator from 'Lib/netlify-auth';
import { Icon } from 'UI';

export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
};

state = {};

handleLogin = (e) => {
e.preventDefault();
const cfg = {
base_url: this.props.base_url,
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId
};
const auth = new Authenticator(cfg);

auth.authenticate({ provider: 'bitbucket', scope: 'repo' }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};

render() {
const { loginError } = this.state;
const { inProgress } = this.props;

return (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
{loginError && <p>{loginError}</p>}
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="bitbucket" /> {inProgress ? "Logging in..." : "Login with Bitbucket"}
</button>
</section>
);
}
}
88 changes: 88 additions & 0 deletions src/backends/bitbucket/__tests__/API.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import fetchMock from 'fetch-mock';
import { curry, escapeRegExp, isMatch, merge } from 'lodash';
import { Map } from 'immutable';
import AssetProxy from "ValueObjects/AssetProxy";
import API from '../API';

const compose = (...fns) => val => fns.reduceRight((newVal, fn) => fn(newVal), val);
const pipe = (...fns) => compose(...fns.reverse());

const regExpOrString = rOrS => (rOrS instanceof RegExp ? rOrS.toString() : escapeRegExp(rOrS));

const mockForAllParams = url => `${url}(\\?.*)?`;
const prependRoot = urlRoot => url => `${urlRoot}${url}`;
const matchWholeURL = str => `^${str}$`;
const strToRegex = str => new RegExp(str);
const matchURL = curry((urlRoot, forAllParams, url) => pipe(
regExpOrString,
...(forAllParams ? [mockForAllParams] : []),
pipe(regExpOrString, prependRoot)(urlRoot),
matchWholeURL,
strToRegex,
)(url));

// `mock` gives us a few advantages over using the standard
// `fetchMock.mock`:
// - Routes can have a root specified that is prepended to the path
// - By default, routes swallow URL parameters (the GitHub API code
// uses a `ts` parameter on _every_ request)
const mockRequest = curry((urlRoot, url, response, options={}) => {
const mergedOptions = merge({}, {
forAllParams: true,
fetchMockOptions: {},
}, options);
return fetchMock.mock(
matchURL(urlRoot, mergedOptions.forAllParams, url),
response,
options.fetchMockOptions,
);
});

const defaultResponseHeaders = { "Content-Type": "application/json; charset=utf-8" };

afterEach(() => fetchMock.restore());

describe('bitbucket API', () => {

it('should list the files in a directory', () => {
const api = new API({ branch: 'test-branch', repo: 'test-user/test-repo' });
mockRequest(api.api_root)(`${ api.repoURL }/src/${ api.branch }/test-directory`, {
headers: defaultResponseHeaders,
body: {
"pagelen": 10,
"values": [
{
"path": "test-directory/octokit.rb",
"attributes": [],
"type": "commit_file",
"size": 625
}
],
"page": 1,
"size": 1
}
});
return expect(api.listFiles('test-directory')).resolves.toMatchObject([
{
"path": "test-directory/octokit.rb",
"attributes": [],
"type": "commit_file",
"size": 625
}
]);
});

it('should throw error if there no files to list', () => {
const api = new API({ branch: 'test-branch', repo: 'test-user/test-repo' });
mockRequest(api.api_root)(`${ api.repoURL }/src/${ api.branch }/test-directory`, {
headers: defaultResponseHeaders,
body: {
"pagelen": 10,
"page": 1,
"size": 1
}
});
return expect(api.listFiles('test-directory')).rejects.toBeDefined();
});

});
Loading