-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
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
Closed
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
d989a1c
bitbucket-integration work in progress
zanedev 1a2fcdc
some cleanup
zanedev 3db8bd1
blob function on assets
1be4467
removed tests for bitbucket temporarily until they can be written
zanedev 0edbaf9
brought over recent changes from github api work upstream
zanedev fd67193
refactoring blob code to asset proxy
zanedev 6bde5e5
Cleanup and fixed remaining api errors with pushing
zanedev 3e8e41b
removed unused editorial workflow code and other functionality not su…
zanedev 32db8e0
put comment about delete
zanedev 5d7f4c1
delete working
zanedev 4078d78
working on bitbucket api tests
zanedev f2c295c
fix avatar icon from bitbucket
zanedev f8e9992
more tests
zanedev c65a5ed
added some notes about workflow and bitbucket
zanedev 3cf754d
removed more workflow unused code
zanedev 5d8ad54
isCollaborator paginated recursive implementation for bitbucket api
zanedev 71beffa
Added kill switch for supporting editorial workflow or not
zanedev 7085d68
removed recursive repo request nonsense. it turns out we can just pas…
zanedev 7652a12
rename isCollaborator to hasWriteAccess
zanedev 7b4ed27
changed to simpler editorial workflow check in api constructor
zanedev 0dd1495
1.x updates, cleanup
erquhart File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) => { | ||
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; | ||
}) | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
|
||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.