feat(backend-github): GitHub GraphQL API support (#2456)

* add GitHub GraphQL api initial support

* support mutiple backends for e2e tests - initial commit

* add github backend e2e test (currently skipped), fix bugs per tests

* refactor e2e tests, add fork workflow tests, support fork workflow in GraphQL api

* remove log message that might contain authentication token

* return empty error when commit is not found when using GraphQL (align with base class)

* disable github backend tests

* fix bugs introduced after rebase of GraphQL and OpenAuthoring features

* test: update tests per openAuthoring changes, split tests into multiple files

* fix: pass in headers for pagination requests, avoid async iterator as it requires a polyfill on old browsers

* test(e2e): disable github backend tests
This commit is contained in:
Erez Rokah
2019-09-03 21:56:20 +03:00
committed by Shawn Erquhart
parent 083a336ba4
commit ece136c92e
34 changed files with 3103 additions and 529 deletions

View File

@ -17,10 +17,17 @@
"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",
"createFragmentTypes": "node scripts/createFragmentTypes.js"
},
"dependencies": {
"apollo-cache-inmemory": "^1.6.2",
"apollo-client": "^2.6.3",
"apollo-link-context": "^1.0.18",
"apollo-link-http": "^1.5.15",
"common-tags": "^1.8.0",
"graphql": "^14.4.2",
"graphql-tag": "^2.10.1",
"js-base64": "^2.5.1",
"semaphore": "^1.1.0"
},

View File

@ -0,0 +1,48 @@
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com';
const API_TOKEN = process.env.GITHUB_API_TOKEN;
if (!API_TOKEN) {
throw new Error('Missing environment variable GITHUB_API_TOKEN');
}
fetch(`${API_HOST}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `bearer ${API_TOKEN}` },
body: JSON.stringify({
variables: {},
query: `
{
__schema {
types {
kind
name
possibleTypes {
name
}
}
}
}
`,
}),
})
.then(result => result.json())
.then(result => {
// here we're filtering out any type information unrelated to unions or interfaces
const filteredData = result.data.__schema.types.filter(type => type.possibleTypes !== null);
result.data.__schema.types = filteredData;
fs.writeFile(
path.join(__dirname, '..', 'src', 'fragmentTypes.js'),
`module.exports = ${JSON.stringify(result.data)}`,
err => {
if (err) {
console.error('Error writing fragmentTypes file', err);
} else {
console.log('Fragment types successfully extracted!');
}
},
);
});

View File

@ -3,7 +3,7 @@ import semaphore from 'semaphore';
import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash';
import { map } from 'lodash/fp';
import {
getPaginatedRequestIterator,
getAllResponses,
APIError,
EditorialWorkflowError,
filterPromisesWith,
@ -21,15 +21,19 @@ export default class API {
this.api_root = config.api_root || 'https://api.github.com';
this.token = config.token || false;
this.branch = config.branch || 'master';
this.originRepo = config.originRepo;
this.useOpenAuthoring = config.useOpenAuthoring;
this.repo = config.repo || '';
this.originRepo = config.originRepo || this.repo;
this.repoURL = `/repos/${this.repo}`;
this.originRepoURL = this.originRepo && `/repos/${this.originRepo}`;
// when not in 'useOpenAuthoring' mode originRepoURL === repoURL
this.originRepoURL = `/repos/${this.originRepo}`;
this.merge_method = config.squash_merges ? '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() {
if (!this._userPromise) {
this._userPromise = this.request('/user');
@ -113,13 +117,10 @@ export default class API {
}
async requestAllPages(url, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const processedURL = this.urlFor(url, options);
const pagesIterator = getPaginatedRequestIterator(processedURL, options);
const pagesToParse = [];
for await (const page of pagesIterator) {
pagesToParse.push(this.parseResponse(page));
}
const pages = await Promise.all(pagesToParse);
const allResponses = await getAllResponses(processedURL, { ...options, headers });
const pages = await Promise.all(allResponses.map(res => this.parseResponse(res)));
return [].concat(...pages);
}
@ -224,34 +225,46 @@ export default class API {
cache: 'no-store',
};
const errorHandler = err => {
if (err.message === 'Not Found') {
console.log(
'%c %s does not have metadata',
'line-height: 30px;text-align: center;font-weight: bold',
key,
);
}
throw err;
};
if (!this.useOpenAuthoring) {
return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.catch(err => {
if (err.message === 'Not Found') {
console.log(
'%c %s does not have metadata',
'line-height: 30px;text-align: center;font-weight: bold',
key,
);
}
throw err;
});
.catch(errorHandler);
}
const [user, repo] = key.split('/');
return this.request(`/repos/${user}/${repo}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.catch(err => {
if (err.message === 'Not Found') {
console.log(
'%c %s does not have metadata',
'line-height: 30px;text-align: center;font-weight: bold',
key,
);
}
throw err;
});
.catch(errorHandler);
});
}
retrieveContent(path, branch, repoURL) {
return this.request(`${repoURL}/contents/${path}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: branch },
cache: 'no-store',
}).catch(error => {
if (hasIn(error, 'message.errors') && find(error.message.errors, { code: 'too_large' })) {
const dir = path
.split('/')
.slice(0, -1)
.join('/');
return this.listFiles(dir, { repoURL, branch })
.then(files => files.find(file => file.path === path))
.then(file => this.getBlob(file.sha, { repoURL }));
}
throw error;
});
}
@ -259,34 +272,23 @@ export default class API {
if (sha) {
return this.getBlob(sha);
} else {
return this.request(`${repoURL}/contents/${path}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: branch },
cache: 'no-store',
}).catch(error => {
if (hasIn(error, 'message.errors') && find(error.message.errors, { code: 'too_large' })) {
const dir = path
.split('/')
.slice(0, -1)
.join('/');
return this.listFiles(dir, { repoURL, branch })
.then(files => files.find(file => file.path === path))
.then(file => this.getBlob(file.sha, { repoURL }));
}
throw error;
});
return this.retrieveContent(path, branch, repoURL);
}
}
retrieveBlob(sha, repoURL) {
return this.request(`${repoURL}/git/blobs/${sha}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
});
}
getBlob(sha, { repoURL = this.repoURL } = {}) {
return localForage.getItem(`gh.${sha}`).then(cached => {
if (cached) {
return cached;
}
return this.request(`${repoURL}/git/blobs/${sha}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
}).then(result => {
return this.retrieveBlob(sha, repoURL).then(result => {
localForage.setItem(`gh.${sha}`, result);
return result;
});
@ -335,7 +337,7 @@ export default class API {
isUnpublishedEntryModification(path, branch) {
return this.readFile(path, null, {
branch,
repoURL: this.useOpenAuthoring ? this.originRepoURL : this.repoURL,
repoURL: this.originRepoURL,
})
.then(() => true)
.catch(err => {
@ -385,7 +387,7 @@ export default class API {
// closed or not and update the status accordingly.
if (prMetadata) {
const { number: prNumber } = prMetadata;
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
const originPRInfo = await this.getPullRequest(prNumber);
const { state: currentState, merged_at: mergedAt } = originPRInfo;
if (currentState === 'closed' && mergedAt) {
// The PR has been merged; delete the unpublished entry
@ -453,9 +455,8 @@ export default class API {
* concept of entry "status". Useful for things like deploy preview links.
*/
async getStatuses(sha) {
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
try {
const resp = await this.request(`${repoURL}/commits/${sha}/status`);
const resp = await this.request(`${this.originRepoURL}/commits/${sha}/status`);
return resp.statuses;
} catch (err) {
if (err && err.message && err.message === 'Ref not found') {
@ -517,25 +518,33 @@ export default class API {
});
}
deleteFile(path, message, options = {}) {
if (this.useOpenAuthoring) {
return Promise.reject('Cannot delete published entries as an Open Authoring user!');
}
const branch = options.branch || this.branch;
const pathArray = path.split('/');
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
const fileURL = `${this.repoURL}/contents/${path}`;
getFileSha(path, 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
* through the tree.
*/
const pathArray = path.split('/');
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
return this.request(fileDataURL, { cache: 'no-store' }).then(resp => {
const { sha } = resp.tree.find(file => file.path === filename);
return sha;
});
}
deleteFile(path, message, options = {}) {
if (this.useOpenAuthoring) {
return Promise.reject('Cannot delete published entries as an Open Authoring user!');
}
const branch = options.branch || this.branch;
return this.getFileSha(path, branch).then(sha => {
const opts = { method: 'DELETE', params: { sha, message, branch } };
if (this.commitAuthor) {
opts.params.author = {
@ -543,10 +552,16 @@ export default class API {
date: new Date().toISOString(),
};
}
const fileURL = `${this.repoURL}/contents/${path}`;
return this.request(fileURL, opts);
});
}
async createBranchAndPullRequest(branchName, sha, commitMessage) {
await this.createBranch(branchName, sha);
return this.createPR(commitMessage, branchName);
}
async editorialWorkflowGit(fileTree, entry, filesList, options) {
const contentKey = this.generateContentKey(options.collectionName, entry.slug);
const branchName = this.generateBranchName(contentKey);
@ -557,10 +572,18 @@ export default class API {
const branchData = await this.getBranch();
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
const commitResponse = await this.commit(options.commitMessage, changeTree);
await this.createBranch(branchName, commitResponse.sha);
const pr = this.useOpenAuthoring
? undefined
: await this.createPR(options.commitMessage, branchName);
let pr;
if (this.useOpenAuthoring) {
await this.createBranch(branchName, commitResponse.sha);
} else {
pr = await this.createBranchAndPullRequest(
branchName,
commitResponse.sha,
options.commitMessage,
);
}
const user = await userPromise;
return this.storeMetadata(contentKey, {
type: 'PR',
@ -610,6 +633,9 @@ export default class API {
if (pr) {
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, commit);
} else if (this.useOpenAuthoring) {
// if a PR hasn't been created yet for the forked repo, just patch the branch
await this.patchBranch(branchName, commit.sha, { force: true });
}
return this.storeMetadata(contentKey, updatedMetadata);
@ -629,8 +655,10 @@ export default class API {
* Get the published branch and create new commits over it. If the pull
* request is up to date, no rebase will occur.
*/
const baseBranch = await this.getBranch();
const commits = await this.getPullRequestCommits(prNumber, head);
const [baseBranch, commits] = await Promise.all([
this.getBranch(),
this.getPullRequestCommits(prNumber, head),
]);
/**
* Sometimes the list of commits for a pull request isn't updated
@ -731,16 +759,14 @@ export default class API {
* Get a pull request by PR number.
*/
getPullRequest(prNumber) {
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
return this.request(`${repoURL}/pulls/${prNumber} }`);
return this.request(`${this.originRepoURL}/pulls/${prNumber} }`);
}
/**
* Get the list of commits for a given pull request.
*/
getPullRequestCommits(prNumber) {
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
return this.request(`${repoURL}/pulls/${prNumber}/commits`);
return this.request(`${this.originRepoURL}/pulls/${prNumber}/commits`);
}
/**
@ -779,7 +805,7 @@ export default class API {
const { pr: prMetadata } = metadata;
if (prMetadata) {
const { number: prNumber } = prMetadata;
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
const originPRInfo = await this.getPullRequest(prNumber);
const { state } = originPRInfo;
if (state === 'open' && status === 'draft') {
await this.closePR(prMetadata);
@ -800,7 +826,7 @@ export default class API {
if (!prMetadata && status === 'pending_review') {
const branchName = this.generateBranchName(contentKey);
const commitMessage = metadata.commitMessage || 'Automatically generated by Netlify CMS';
const commitMessage = metadata.commitMessage || API.DEFAULT_COMMIT_MESSAGE;
const { number, head } = await this.createPR(commitMessage, branchName);
return this.storeMetadata(contentKey, {
...metadata,
@ -883,21 +909,23 @@ export default class API {
return this.deleteRef('heads', branchName);
}
async createPR(title, head, base = this.branch) {
const body = 'Automatically generated by Netlify CMS';
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
async createPR(title, head) {
const headReference = this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head;
return this.request(`${repoURL}/pulls`, {
return this.request(`${this.originRepoURL}/pulls`, {
method: 'POST',
body: JSON.stringify({ title, body, head: headReference, base }),
body: JSON.stringify({
title,
body: API.DEFAULT_PR_BODY,
head: headReference,
base: this.branch,
}),
});
}
async openPR(pullRequest) {
const { number } = pullRequest;
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
console.log('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${repoURL}/pulls/${number}`, {
return this.request(`${this.originRepoURL}/pulls/${number}`, {
method: 'PATCH',
body: JSON.stringify({
state: 'open',
@ -905,11 +933,10 @@ export default class API {
});
}
closePR(pullrequest) {
const prNumber = pullrequest.number;
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
closePR(pullRequest) {
const { number } = pullRequest;
console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${repoURL}/pulls/${prNumber}`, {
return this.request(`${this.originRepoURL}/pulls/${number}`, {
method: 'PATCH',
body: JSON.stringify({
state: 'closed',
@ -918,11 +945,9 @@ export default class API {
}
mergePR(pullrequest, objects) {
const headSha = pullrequest.head;
const prNumber = pullrequest.number;
const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL;
const { head: headSha, number } = pullrequest;
console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${repoURL}/pulls/${prNumber}/merge`, {
return this.request(`${this.originRepoURL}/pulls/${number}/merge`, {
method: 'PUT',
body: JSON.stringify({
commit_message: 'Automatically generated. Merged on Netlify CMS.',

View File

@ -0,0 +1,627 @@
import { ApolloClient } from 'apollo-client';
import {
InMemoryCache,
defaultDataIdFromObject,
IntrospectionFragmentMatcher,
} from 'apollo-cache-inmemory';
import { createHttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import { APIError, EditorialWorkflowError } from 'netlify-cms-lib-util';
import introspectionQueryResultData from './fragmentTypes';
import API from './API';
import * as queries from './queries';
import * as mutations from './mutations';
const NO_CACHE = 'no-cache';
const CACHE_FIRST = 'cache-first';
const TREE_ENTRY_TYPE_TO_MODE = {
blob: '100644',
tree: '040000',
commit: '160000',
};
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
export default class GraphQLAPI extends API {
constructor(config) {
super(config);
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
this.repo_owner = repoParts[0];
this.repo_name = repoParts[1];
this.origin_repo_owner = originRepoParts[0];
this.origin_repo_name = originRepoParts[1];
this.client = this.getApolloClient();
}
getApolloClient() {
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: this.token ? `token ${this.token}` : '',
},
};
});
const httpLink = createHttpLink({ uri: `${this.api_root}/graphql` });
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({ fragmentMatcher }),
defaultOptions: {
watchQuery: {
fetchPolicy: NO_CACHE,
errorPolicy: 'ignore',
},
query: {
fetchPolicy: NO_CACHE,
errorPolicy: 'all',
},
},
});
}
reset() {
return this.client.resetStore();
}
async getRepository(owner, name) {
const { data } = await this.query({
query: queries.repository,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // repository id doesn't change
});
return data.repository;
}
query(options = {}) {
return this.client.query(options).catch(error => {
throw new APIError(error.message, 500, 'GitHub');
});
}
mutate(options = {}) {
return this.client.mutate(options).catch(error => {
throw new APIError(error.message, 500, 'GitHub');
});
}
async hasWriteAccess() {
const { repo_owner: owner, repo_name: name } = this;
try {
const { data } = await this.query({
query: queries.repoPermission,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often
});
// https://developer.github.com/v4/enum/repositorypermission/
const { viewerPermission } = data.repository;
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission);
} catch (error) {
console.error('Problem fetching repo data from GitHub');
throw error;
}
}
async user() {
const { data } = await this.query({
query: queries.user,
fetchPolicy: CACHE_FIRST, // we can assume user details don't change often
});
return data.viewer;
}
async retrieveBlobObject(owner, name, expression, options = {}) {
const { data } = await this.query({
query: queries.blob,
variables: { owner, name, expression },
...options,
});
// https://developer.github.com/v4/object/blob/
if (data.repository.object) {
const { is_binary, text } = data.repository.object;
return { is_null: false, is_binary, text };
} else {
return { is_null: true };
}
}
getOwnerAndNameFromRepoUrl(repoURL) {
let { repo_owner: owner, repo_name: name } = this;
if (repoURL === this.originRepoURL) {
({ origin_repo_owner: owner, origin_repo_name: name } = this);
}
return { owner, name };
}
async retrieveContent(path, branch, repoURL) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { is_null, is_binary, text } = await this.retrieveBlobObject(
owner,
name,
`${branch}:${path}`,
);
if (is_null) {
throw new APIError('Not Found', 404, 'GitHub');
} else if (!is_binary) {
return text;
} else {
return super.retrieveContent(path, branch, repoURL);
}
}
async retrieveBlob(sha, repoURL) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { is_null, is_binary, text } = await this.retrieveBlobObject(
owner,
name,
sha,
{ fetchPolicy: CACHE_FIRST }, // blob sha is derived from file content
);
if (is_null) {
throw new APIError('Not Found', 404, 'GitHub');
} else if (!is_binary) {
return text;
} else {
return super.retrieveBlob(sha);
}
}
async getStatuses(sha) {
const { origin_repo_owner: owner, origin_repo_name: name } = this;
const { data } = await this.query({ query: queries.statues, variables: { owner, name, sha } });
if (data.repository.object) {
const { status } = data.repository.object;
const { contexts } = status || { contexts: [] };
return contexts;
} else {
return [];
}
}
async listFiles(path) {
const { repo_owner: owner, repo_name: name } = this;
const { data } = await this.query({
query: queries.files,
variables: { owner, name, expression: `${this.branch}:${path}` },
});
if (data.repository.object) {
const files = data.repository.object.entries.map(e => ({
...e,
path: `${path}/${e.name}`,
download_url: `https://raw.githubusercontent.com/${this.repo}/${this.branch}/${path}/${e.name}`,
size: e.blob && e.blob.size,
}));
return files;
} else {
throw new APIError('Not Found', 404, 'GitHub');
}
}
async listUnpublishedBranches() {
if (this.useOpenAuthoring) {
return super.listUnpublishedBranches();
}
console.log(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
const { repo_owner: owner, repo_name: name } = this;
const { data } = await this.query({
query: queries.unpublishedPrBranches,
variables: { owner, name },
});
const { nodes } = data.repository.refs;
if (nodes.length > 0) {
const branches = [];
nodes.forEach(({ associatedPullRequests }) => {
associatedPullRequests.nodes.forEach(({ headRef }) => {
branches.push({ ref: `${headRef.prefix}${headRef.name}` });
});
});
return branches;
} else {
console.log(
'%c No Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
throw new APIError('Not Found', 404, 'GitHub');
}
}
async readUnpublishedBranchFile(contentKey) {
// retrieveMetadata(contentKey) rejects in case of no metadata
const metaData = await this.retrieveMetadata(contentKey).catch(() => null);
if (metaData && metaData.objects && metaData.objects.entry && metaData.objects.entry.path) {
const { path } = metaData.objects.entry;
const { repo_owner: headOwner, repo_name: headRepoName } = this;
const { origin_repo_owner: baseOwner, origin_repo_name: baseRepoName } = this;
const { data } = await this.query({
query: queries.unpublishedBranchFile,
variables: {
headOwner,
headRepoName,
headExpression: `${metaData.branch}:${path}`,
baseOwner,
baseRepoName,
baseExpression: `${this.branch}:${path}`,
},
});
if (!data.head.object) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
const result = {
metaData,
fileData: data.head.object.text,
isModification: !!data.base.object,
};
return result;
} else {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
}
getBranchQualifiedName(branch) {
return `refs/heads/${branch}`;
}
getBranchQuery(branch) {
const { repo_owner: owner, repo_name: name } = this;
return {
query: queries.branch,
variables: {
owner,
name,
qualifiedName: this.getBranchQualifiedName(branch),
},
};
}
async getBranch(branch = this.branch) {
// don't cache base branch to always get the latest data
const fetchPolicy = branch === this.branch ? NO_CACHE : CACHE_FIRST;
const { data } = await this.query({
...this.getBranchQuery(branch),
fetchPolicy,
});
return data.repository.branch;
}
async patchRef(type, name, sha, opts = {}) {
if (type !== 'heads') {
return super.patchRef(type, name, sha, opts);
}
const force = opts.force || false;
const branch = await this.getBranch(name);
const { data } = await this.mutate({
mutation: mutations.updateBranch,
variables: {
input: { oid: sha, refId: branch.id, force },
},
});
return data.updateRef.branch;
}
async deleteBranch(branchName) {
const branch = await this.getBranch(branchName);
const { data } = await this.mutate({
mutation: mutations.deleteBranch,
variables: {
deleteRefInput: { refId: branch.id },
},
update: store => store.data.delete(defaultDataIdFromObject(branch)),
});
return data.deleteRef;
}
getPullRequestQuery(number) {
const { origin_repo_owner: owner, origin_repo_name: name } = this;
return {
query: queries.pullRequest,
variables: { owner, name, number },
};
}
async getPullRequest(number) {
const { data } = await this.query({
...this.getPullRequestQuery(number),
fetchPolicy: CACHE_FIRST,
});
// https://developer.github.com/v4/enum/pullrequeststate/
// GraphQL state: [CLOSED, MERGED, OPEN]
// REST API state: [closed, open]
const state = data.repository.pullRequest.state === 'OPEN' ? 'open' : 'closed';
return {
...data.repository.pullRequest,
state,
};
}
getPullRequestAndBranchQuery(branch, number) {
const { repo_owner: owner, repo_name: name } = this;
const { origin_repo_owner: origin_owner, origin_repo_name: origin_name } = this;
return {
query: queries.pullRequestAndBranch,
variables: {
owner,
name,
origin_owner,
origin_name,
number,
qualifiedName: this.getBranchQualifiedName(branch),
},
};
}
async getPullRequestAndBranch(branch, number) {
const { data } = await this.query({
...this.getPullRequestAndBranchQuery(branch, number),
fetchPolicy: CACHE_FIRST,
});
const { repository, origin } = data;
return { branch: repository.branch, pullRequest: origin.pullRequest };
}
async openPR({ number }) {
const pullRequest = await this.getPullRequest(number);
const { data } = await this.mutate({
mutation: mutations.reopenPullRequest,
variables: {
reopenPullRequestInput: { pullRequestId: pullRequest.id },
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult.reopenPullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
return data.closePullRequest;
}
async closePR({ number }) {
const pullRequest = await this.getPullRequest(number);
const { data } = await this.mutate({
mutation: mutations.closePullRequest,
variables: {
closePullRequestInput: { pullRequestId: pullRequest.id },
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult.closePullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
return data.closePullRequest;
}
async deleteUnpublishedEntry(collectionName, slug) {
try {
const contentKey = this.generateContentKey(collectionName, slug);
const branchName = this.generateBranchName(contentKey);
const metadata = await this.retrieveMetadata(contentKey);
if (metadata && metadata.pr) {
const { branch, pullRequest } = await this.getPullRequestAndBranch(
branchName,
metadata.pr.number,
);
const { data } = await this.mutate({
mutation: mutations.closePullRequestAndDeleteBranch,
variables: {
deleteRefInput: { refId: branch.id },
closePullRequestInput: { pullRequestId: pullRequest.id },
},
update: store => {
store.data.delete(defaultDataIdFromObject(branch));
store.data.delete(defaultDataIdFromObject(pullRequest));
},
});
return data.closePullRequest;
} else {
return await this.deleteBranch(branchName);
}
} catch (e) {
const { graphQLErrors } = e;
if (graphQLErrors && graphQLErrors.length > 0) {
const branchNotFound = graphQLErrors.some(e => e.type === 'NOT_FOUND');
if (branchNotFound) {
return;
}
}
throw e;
}
}
async createPR(title, head) {
const [repository, headReference] = await Promise.all([
this.getRepository(this.origin_repo_owner, this.origin_repo_name),
this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head,
]);
const { data } = await this.mutate({
mutation: mutations.createPullRequest,
variables: {
createPullRequestInput: {
baseRefName: this.branch,
body: API.DEFAULT_PR_BODY,
title,
headRefName: headReference,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult.createPullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
const { pullRequest } = data.createPullRequest;
return { ...pullRequest, head: { sha: pullRequest.headRefOid } };
}
async createBranch(branchName, sha) {
const repository = await this.getRepository(this.repo_owner, this.repo_name);
const { data } = await this.mutate({
mutation: mutations.createBranch,
variables: {
createRefInput: {
name: this.getBranchQualifiedName(branchName),
oid: sha,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { branch } = mutationResult.createRef;
const branchData = { repository: { ...branch.repository, branch } };
store.writeQuery({
...this.getBranchQuery(branchName),
data: branchData,
});
},
});
const { branch } = data.createRef;
return branch;
}
async createBranchAndPullRequest(branchName, sha, title) {
const repository = await this.getRepository(this.origin_repo_owner, this.origin_repo_name);
const { data } = await this.mutate({
mutation: mutations.createBranchAndPullRequest,
variables: {
createRefInput: {
name: this.getBranchQualifiedName(branchName),
oid: sha,
repositoryId: repository.id,
},
createPullRequestInput: {
baseRefName: this.branch,
body: API.DEFAULT_PR_BODY,
title,
headRefName: branchName,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { branch } = mutationResult.createRef;
const { pullRequest } = mutationResult.createPullRequest;
const branchData = { repository: { ...branch.repository, branch } };
const pullRequestData = {
repository: { ...pullRequest.repository, branch },
origin: { ...pullRequest.repository, pullRequest },
};
store.writeQuery({
...this.getBranchQuery(branchName),
data: branchData,
});
store.writeQuery({
...this.getPullRequestAndBranchQuery(branchName, pullRequest.number),
data: pullRequestData,
});
},
});
const { pullRequest } = data.createPullRequest;
return { ...pullRequest, head: { sha: pullRequest.headRefOid } };
}
async getTree(sha) {
if (!sha) {
return Promise.resolve({ tree: [] });
}
const { repo_owner: owner, repo_name: name } = this;
const variables = {
owner,
name,
sha,
};
// sha can be either for a commit or a tree
const [commitTree, tree] = await Promise.all([
this.client.query({
query: queries.commitTree,
variables,
fetchPolicy: CACHE_FIRST,
}),
this.client.query({
query: queries.tree,
variables,
fetchPolicy: CACHE_FIRST,
}),
]);
let entries = null;
if (commitTree.data.repository.commit.tree) {
entries = commitTree.data.repository.commit.tree.entries;
}
if (tree.data.repository.tree.entries) {
entries = tree.data.repository.tree.entries;
}
if (entries) {
return { tree: entries.map(e => ({ ...e, mode: TREE_ENTRY_TYPE_TO_MODE[e.type] })) };
}
return Promise.reject('Could not get tree');
}
async getPullRequestCommits(number) {
const { origin_repo_owner: owner, origin_repo_name: name } = this;
const { data } = await this.query({
query: queries.pullRequestCommits,
variables: { owner, name, number },
});
const { nodes } = data.repository.pullRequest.commits;
const commits = nodes.map(n => ({ ...n.commit, parents: n.commit.parents.nodes }));
return commits;
}
async getFileSha(path, branch) {
const { repo_owner: owner, repo_name: name } = this;
const { data } = await this.query({
query: queries.fileSha,
variables: { owner, name, expression: `${branch}:${path}` },
});
return data.repository.file.sha;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,76 @@
import gql from 'graphql-tag';
export const repository = gql`
fragment RepositoryParts on Repository {
id
}
`;
export const blobWithText = gql`
fragment BlobWithTextParts on Blob {
id
text
is_binary: isBinary
}
`;
export const object = gql`
fragment ObjectParts on GitObject {
id
sha: oid
}
`;
export const branch = gql`
fragment BranchParts on Ref {
commit: target {
...ObjectParts
}
id
name
repository {
...RepositoryParts
}
}
${object}
${repository}
`;
export const pullRequest = gql`
fragment PullRequestParts on PullRequest {
id
baseRefName
body
headRefName
headRefOid
number
state
title
merged_at: mergedAt
repository {
...RepositoryParts
}
}
${repository}
`;
export const treeEntry = gql`
fragment TreeEntryParts on TreeEntry {
path: name
sha: oid
type
mode
}
`;
export const fileEntry = gql`
fragment FileEntryParts on TreeEntry {
name
sha: oid
blob: object {
... on Blob {
size: byteSize
}
}
}
`;

View File

@ -4,6 +4,7 @@ import semaphore from 'semaphore';
import { stripIndent } from 'common-tags';
import AuthenticationPage from './AuthenticationPage';
import API from './API';
import GraphQLAPI from './GraphQLAPI';
const MAX_CONCURRENT_DOWNLOADS = 10;
@ -59,12 +60,13 @@ export default class GitHub {
}
this.originRepo = config.getIn(['backend', 'repo'], '');
} else {
this.repo = config.getIn(['backend', 'repo'], '');
this.repo = this.originRepo = config.getIn(['backend', 'repo'], '');
}
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.github.com');
this.token = '';
this.squash_merges = config.getIn(['backend', 'squash_merges']);
this.use_graphql = config.getIn(['backend', 'use_graphql']);
}
authComponent() {
@ -155,11 +157,12 @@ export default class GitHub {
async authenticate(state) {
this.token = state.token;
this.api = new API({
const apiCtor = this.use_graphql ? GraphQLAPI : API;
this.api = new apiCtor({
token: this.token,
branch: this.branch,
repo: this.repo,
originRepo: this.useOpenAuthoring ? this.originRepo : undefined,
originRepo: this.originRepo,
api_root: this.api_root,
squash_merges: this.squash_merges,
useOpenAuthoring: this.useOpenAuthoring,
@ -191,6 +194,9 @@ export default class GitHub {
logout() {
this.token = null;
if (typeof this.api.reset === 'function') {
return this.api.reset();
}
return;
}
@ -243,7 +249,7 @@ export default class GitHub {
// Fetches a single entry.
getEntry(collection, slug, path) {
const repoURL = `/repos/${this.useOpenAuthoring ? this.originRepo : this.repo}`;
const repoURL = `/repos/${this.originRepo}`;
return this.api.readFile(path, null, { repoURL }).then(data => ({
file: { path },
data,

View File

@ -0,0 +1,109 @@
import gql from 'graphql-tag';
import * as fragments from './fragments';
// updateRef only works for branches at the moment
export const updateBranch = gql`
mutation updateRef($input: UpdateRefInput!) {
updateRef(input: $input) {
branch: ref {
...BranchParts
}
}
}
${fragments.branch}
`;
// deleteRef only works for branches at the moment
const deleteRefMutationPart = `
deleteRef(input: $deleteRefInput) {
clientMutationId
}
`;
export const deleteBranch = gql`
mutation deleteRef($deleteRefInput: DeleteRefInput!) {
${deleteRefMutationPart}
}
`;
const closePullRequestMutationPart = `
closePullRequest(input: $closePullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
`;
export const closePullRequest = gql`
mutation closePullRequestAndDeleteBranch($closePullRequestInput: ClosePullRequestInput!) {
${closePullRequestMutationPart}
}
${fragments.pullRequest}
`;
export const closePullRequestAndDeleteBranch = gql`
mutation closePullRequestAndDeleteBranch(
$closePullRequestInput: ClosePullRequestInput!
$deleteRefInput: DeleteRefInput!
) {
${closePullRequestMutationPart}
${deleteRefMutationPart}
}
${fragments.pullRequest}
`;
const createPullRequestMutationPart = `
createPullRequest(input: $createPullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
`;
export const createPullRequest = gql`
mutation createPullRequest($createPullRequestInput: CreatePullRequestInput!) {
${createPullRequestMutationPart}
}
${fragments.pullRequest}
`;
export const createBranch = gql`
mutation createBranch($createRefInput: CreateRefInput!) {
createRef(input: $createRefInput) {
branch: ref {
...BranchParts
}
}
}
${fragments.branch}
`;
// createRef only works for branches at the moment
export const createBranchAndPullRequest = gql`
mutation createBranchAndPullRequest(
$createRefInput: CreateRefInput!
$createPullRequestInput: CreatePullRequestInput!
) {
createRef(input: $createRefInput) {
branch: ref {
...BranchParts
}
}
${createPullRequestMutationPart}
}
${fragments.branch}
${fragments.pullRequest}
`;
export const reopenPullRequest = gql`
mutation reopenPullRequest($reopenPullRequestInput: ReopenPullRequestInput!) {
reopenPullRequest(input: $reopenPullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
}
${fragments.pullRequest}
`;

View File

@ -0,0 +1,274 @@
import gql from 'graphql-tag';
import * as fragments from './fragments';
export const repoPermission = gql`
query repoPermission($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
viewerPermission
}
}
${fragments.repository}
`;
export const user = gql`
query {
viewer {
id
avatar_url: avatarUrl
name
login
}
}
`;
export const blob = gql`
query blob($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
... on Blob {
...BlobWithTextParts
}
}
}
}
${fragments.repository}
${fragments.blobWithText}
`;
export const unpublishedBranchFile = gql`
query unpublishedBranchFile(
$headOwner: String!
$headRepoName: String!
$headExpression: String!
$baseOwner: String!
$baseRepoName: String!
$baseExpression: String!
) {
head: repository(owner: $headOwner, name: $headRepoName) {
...RepositoryParts
object(expression: $headExpression) {
... on Blob {
...BlobWithTextParts
}
}
}
base: repository(owner: $baseOwner, name: $baseRepoName) {
...RepositoryParts
object(expression: $baseExpression) {
... on Blob {
id
oid
}
}
}
}
${fragments.repository}
${fragments.blobWithText}
`;
export const statues = gql`
query statues($owner: String!, $name: String!, $sha: GitObjectID!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(oid: $sha) {
...ObjectParts
... on Commit {
status {
id
contexts {
id
context
state
target_url: targetUrl
}
}
}
}
}
}
${fragments.repository}
${fragments.object}
`;
export const files = gql`
query files($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
...ObjectParts
... on Tree {
entries {
...FileEntryParts
}
}
}
}
}
${fragments.repository}
${fragments.object}
${fragments.fileEntry}
`;
export const unpublishedPrBranches = gql`
query unpublishedPrBranches($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
refs(refPrefix: "refs/heads/cms/", last: 50) {
nodes {
id
associatedPullRequests(last: 50, states: OPEN) {
nodes {
id
headRef {
id
name
prefix
}
}
}
}
}
}
}
${fragments.repository}
`;
const branchQueryPart = `
branch: ref(qualifiedName: $qualifiedName) {
...BranchParts
}
`;
export const branch = gql`
query branch($owner: String!, $name: String!, $qualifiedName: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
${branchQueryPart}
}
}
${fragments.repository}
${fragments.branch}
`;
export const repository = gql`
query repository($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
}
}
${fragments.repository}
`;
const pullRequestQueryPart = `
pullRequest(number: $number) {
...PullRequestParts
}
`;
export const pullRequest = gql`
query pullRequest($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
id
${pullRequestQueryPart}
}
}
${fragments.pullRequest}
`;
export const pullRequestAndBranch = gql`
query pullRequestAndBranch($owner: String!, $name: String!, $origin_owner: String!, $origin_name: String!, $qualifiedName: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
${branchQueryPart}
}
origin: repository(owner: $origin_owner, name: $origin_name) {
...RepositoryParts
${pullRequestQueryPart}
}
}
${fragments.repository}
${fragments.branch}
${fragments.pullRequest}
`;
export const commitTree = gql`
query commitTree($owner: String!, $name: String!, $sha: GitObjectID!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
commit: object(oid: $sha) {
...ObjectParts
... on Commit {
tree {
...ObjectParts
entries {
...TreeEntryParts
}
}
}
}
}
}
${fragments.repository}
${fragments.object}
${fragments.treeEntry}
`;
export const tree = gql`
query tree($owner: String!, $name: String!, $sha: GitObjectID!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
tree: object(oid: $sha) {
...ObjectParts
... on Tree {
entries {
...TreeEntryParts
}
}
}
}
}
${fragments.repository}
${fragments.object}
${fragments.treeEntry}
`;
export const pullRequestCommits = gql`
query pullRequestCommits($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
pullRequest(number: $number) {
id
commits(last: 100) {
nodes {
id
commit {
...ObjectParts
parents(last: 100) {
nodes {
...ObjectParts
}
}
}
}
}
}
}
}
${fragments.repository}
${fragments.object}
`;
export const fileSha = gql`
query fileSha($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
file: object(expression: $expression) {
...ObjectParts
}
}
}
${fragments.repository}
${fragments.object}
`;